get(EntityManagerInterface::class); return array_map( static fn($meta) => $meta->getName(), $em->getMetadataFactory()->getAllMetadata(), ); } private function isTenantOwned(string $class): bool { $em = static::getContainer()->get(EntityManagerInterface::class); return $em->getClassMetadata($class)->hasField('entityType'); } /** * هر entity دقیقاً یکی از چهار وضعیت را دارد: جفت tenant، سراسری، فرزند * aggregate، یا بدهی ثبت‌شده. */ public function testEveryEntityIsClassified(): void { $unclassified = array_values(array_filter( $this->allEntityClasses(), fn(string $class) => !$this->isTenantOwned($class) && !isset(GlobalTables::ENTITIES[$class]) && !isset(GlobalTables::AGGREGATE_CHILDREN[$class]) && !isset(GlobalTables::DEFERRED[$class]), )); self::assertSame([], $unclassified, sprintf( "این entityها نه جفت tenant دارند و نه در GlobalTables ثبت شده‌اند:\n%s", implode("\n", $unclassified), )); } /** هیچ کلاسی نباید هم‌زمان در دو دستهٔ GlobalTables باشد. */ public function testClassificationsDoNotOverlap(): void { $buckets = [ 'ENTITIES' => array_keys(GlobalTables::ENTITIES), 'AGGREGATE_CHILDREN' => array_keys(GlobalTables::AGGREGATE_CHILDREN), 'DEFERRED' => array_keys(GlobalTables::DEFERRED), ]; foreach ($buckets as $name => $classes) { foreach ($buckets as $otherName => $otherClasses) { if ($name === $otherName) { continue; } self::assertSame( [], array_values(array_intersect($classes, $otherClasses)), "{$name} و {$otherName} کلاس مشترک دارند", ); } } } /** * هر فرزند aggregate باید زنجیره‌ای به یک ریشهٔ tenant-دار داشته باشد. بدون این، * ثبت‌کردنش در whitelist فقط تست را ساکت می‌کند بی‌آنکه محیطی وجود داشته باشد. */ public function testEveryAggregateChildReachesATenantOwnedRoot(): void { foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) { $chain = [$child]; $current = $child; while (isset(GlobalTables::AGGREGATE_CHILDREN[$current])) { $current = GlobalTables::AGGREGATE_CHILDREN[$current]; self::assertNotContains($current, $chain, 'زنجیرهٔ aggregate حلقه دارد: ' . implode(' → ', $chain)); $chain[] = $current; } self::assertTrue( $this->isTenantOwned($current), sprintf('ریشهٔ %s باید جفت tenant داشته باشد: %s', $child, implode(' → ', $chain)), ); } } /** فرزندِ خودش نباید جفت tenant داشته باشد — وگرنه دو منبع حقیقت می‌شود. */ public function testAggregateChildrenDoNotCarryTheirOwnTenant(): void { foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) { self::assertFalse( $this->isTenantOwned($child), "{$child} هم جفت tenant دارد هم فرزند aggregate ثبت شده — یکی را بردار", ); } } /** هر ثبت باید کلاس موجود و دلیل ناتهی داشته باشد. */ public function testEveryClassificationIsRealAndJustified(): void { foreach ([GlobalTables::ENTITIES, GlobalTables::DEFERRED] as $bucket) { foreach ($bucket as $class => $reason) { self::assertTrue(class_exists($class), "کلاس ثبت‌شده وجود ندارد: {$class}"); self::assertNotSame('', trim($reason), "دلیل {$class} خالی است"); } } foreach (GlobalTables::AGGREGATE_CHILDREN as $child => $root) { self::assertTrue(class_exists($child), "کلاس ثبت‌شده وجود ندارد: {$child}"); self::assertTrue(class_exists($root), "ریشهٔ ثبت‌شده وجود ندارد: {$root}"); } } /** * فاز ۶ بدهی را صفر کرد. از «رشد نکن» به «صفر بمان»: افزودن دوباره یعنی جدولی * بیرون از هر تضمینی مانده، و باید تصمیم آگاهانه باشد نه یک ردشدنِ بی‌صدا. */ public function testThereIsNoUnclassifiedDebtLeft(): void { self::assertSame( [], GlobalTables::DEFERRED, 'بدهی طبقه‌بندی باید صفر بماند؛ هر کلاس یا جفت محیط می‌گیرد یا با دلیل سراسری/فرزند aggregate می‌شود', ); } }