_` et, faute de traduction, retombe * SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le * manque passe donc inapercu jusqu'a observation dans l'UI. * * Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) : * il scanne les entites `#[Auditable]` sous `src/Module//Domain/Entity/`, * derive la cle attendue comme le fait le front, et echoue si elle est absente * du `fr.json`. * * Derivation de la cle (miroir exact de AuditListener::formatEntityType + de * formatEntityType cote front) : * FQCN `App\Module\Commercial\Domain\Entity\ClientAddress` * -> entity_type `commercial.ClientAddress` (module en minuscules, Entity intacte) * -> cle i18n `commercial_clientaddress` (tout en minuscules, `.` -> `_`) * * @internal */ final class AuditableEntitiesHaveI18nLabelTest extends TestCase { /** * Chemin du fichier de traductions FR du shell. Source unique des libelles * d'entite audit (decision ERP-99 : emplacement centralise, schema flat). */ private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json'; public function testEveryAuditableEntityHasAnI18nLabel(): void { $labels = $this->loadAuditEntityLabels(); $finder = new Finder() ->files() ->in(__DIR__.'/../../src/Module') ->path('Domain/Entity') ->name('*.php') ; // Garde : si le scan ne trouve rien, le chemin est casse — le test // deviendrait un faux positif vert. On verifie qu'il a du grain a moudre. self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?'); $checked = 0; foreach ($finder as $file) { $fqcn = $this->extractFqcn($file->getRealPath()); if (null === $fqcn) { continue; } $reflection = new ReflectionClass($fqcn); // On ne s'interesse qu'aux entites reellement auditees. if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) { continue; } $key = $this->deriveI18nKey($fqcn); self::assertNotNull( $key, sprintf('Entite %s hors structure modulaire attendue (App\Module\\Domain\Entity\).', $fqcn), ); self::assertArrayHasKey( $key, $labels, sprintf( 'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc ' .'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log ' .'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.', $fqcn, $key, ), ); self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key)); ++$checked; } // Garde : au moins une entite auditable doit avoir ete verifiee, sinon // la detection de l'attribut est cassee (faux positif vert). self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?'); } /** * Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle. * * @return array */ private function loadAuditEntityLabels(): array { $raw = file_get_contents(self::LOCALE_FILE); self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE)); /** @var array $json */ $json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); $entity = $json['audit']['entity'] ?? null; self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.'); $labels = []; foreach ($entity as $key => $value) { if (is_string($key) && is_string($value)) { $labels[$key] = $value; } } return $labels; } /** * Derive la cle i18n `_` depuis le FQCN, en miroir de * AuditListener::formatEntityType (module en minuscules) suivi de * l'aplatissement front (tout en minuscules, `.` -> `_`). * * Retourne null si le FQCN ne respecte pas la structure modulaire. */ private function deriveI18nKey(string $fqcn): ?string { if (1 !== preg_match('#^App\\\Module\\\(?[^\\\]+)\\\.+\\\(?[^\\\]+)$#', $fqcn, $m)) { return null; } return strtolower($m['module']).'_'.strtolower($m['entity']); } /** * Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du * source, sans charger le fichier. */ private function extractFqcn(string $path): ?string { $source = file_get_contents($path); if (false === $source) { return null; } if ( 1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch) || 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch) ) { return null; } return trim($nsMatch[1]).'\\'.$classMatch[1]; } }