feat: module-owned i18n with merge-at-boot translation loading
Each module can now ship its own i18n/ directory with translation files (default_de.json, default_en.json). At boot time, ModuleI18nLoader preloads core translations then merges module translations on top via Reflection into I18n::$strings — the t() helper stays unchanged. - Add i18nPath property to ModuleManifest - Add ModuleI18nLoader with Reflection-based preload - Extract 59 module-exclusive keys from core i18n into module files (notifications: 8, bookmarks: 41, addressbook: 10) - Expand TranslationKeysTest to scan modules/ and validate against merged core + module translations - Add ModuleI18nContractTest for per-module de/en parity and completeness Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
127
tests/Architecture/ModuleI18nContractTest.php
Normal file
127
tests/Architecture/ModuleI18nContractTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* GR-UI-015 (module extension): Every module with an i18n_path must ship
|
||||
* matching default_de.json and default_en.json with identical keys and
|
||||
* non-empty values.
|
||||
*/
|
||||
class ModuleI18nContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/**
|
||||
* @return list<array{string, string}>
|
||||
*/
|
||||
public static function moduleProvider(): array
|
||||
{
|
||||
$root = realpath(__DIR__ . '/../..');
|
||||
if ($root === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
foreach (glob($root . '/modules/*/module.php') as $manifestFile) {
|
||||
$raw = (static function (string $path): mixed {
|
||||
return require $path;
|
||||
})($manifestFile);
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
$i18nPath = $raw['i18n_path'] ?? null;
|
||||
if (!is_string($i18nPath) || trim($i18nPath) === '') {
|
||||
continue;
|
||||
}
|
||||
$moduleDir = dirname($manifestFile);
|
||||
$moduleId = basename($moduleDir);
|
||||
$resolvedPath = rtrim($moduleDir, '/') . '/' . ltrim(trim($i18nPath), '/');
|
||||
$modules[$moduleId] = [$moduleId, $resolvedPath];
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
#[DataProvider('moduleProvider')]
|
||||
public function testModuleI18nFilesExistAndAreValidJson(string $moduleId, string $i18nPath): void
|
||||
{
|
||||
$this->assertDirectoryExists($i18nPath, "Module '{$moduleId}' declares i18n_path but directory does not exist.");
|
||||
|
||||
$deFile = $i18nPath . '/default_de.json';
|
||||
$enFile = $i18nPath . '/default_en.json';
|
||||
|
||||
$this->assertFileExists($deFile, "Module '{$moduleId}' is missing default_de.json");
|
||||
$this->assertFileExists($enFile, "Module '{$moduleId}' is missing default_en.json");
|
||||
|
||||
$deRaw = file_get_contents($deFile);
|
||||
$enRaw = file_get_contents($enFile);
|
||||
$this->assertNotFalse($deRaw);
|
||||
$this->assertNotFalse($enRaw);
|
||||
|
||||
$this->assertIsArray(json_decode($deRaw, true), "Module '{$moduleId}' default_de.json is not valid JSON");
|
||||
$this->assertIsArray(json_decode($enRaw, true), "Module '{$moduleId}' default_en.json is not valid JSON");
|
||||
}
|
||||
|
||||
#[DataProvider('moduleProvider')]
|
||||
public function testModuleI18nKeyParity(string $moduleId, string $i18nPath): void
|
||||
{
|
||||
$de = $this->loadModuleKeys($i18nPath . '/default_de.json');
|
||||
$en = $this->loadModuleKeys($i18nPath . '/default_en.json');
|
||||
|
||||
$missingInEn = array_values(array_diff($de, $en));
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missingInEn,
|
||||
"Module '{$moduleId}' default_de.json keys missing from default_en.json:\n" . implode("\n", $missingInEn)
|
||||
);
|
||||
|
||||
$missingInDe = array_values(array_diff($en, $de));
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missingInDe,
|
||||
"Module '{$moduleId}' default_en.json keys missing from default_de.json:\n" . implode("\n", $missingInDe)
|
||||
);
|
||||
}
|
||||
|
||||
#[DataProvider('moduleProvider')]
|
||||
public function testModuleI18nValuesAreNonEmpty(string $moduleId, string $i18nPath): void
|
||||
{
|
||||
foreach (['default_de.json', 'default_en.json'] as $file) {
|
||||
$path = $i18nPath . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
$this->assertIsArray($data);
|
||||
|
||||
$empty = array_keys(array_filter($data, static fn ($v): bool => trim((string) $v) === ''));
|
||||
$this->assertSame(
|
||||
[],
|
||||
$empty,
|
||||
"Module '{$moduleId}' {$file} has empty values:\n" . implode("\n", $empty)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function loadModuleKeys(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = array_keys($data);
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ class TranslationKeysTest extends TestCase
|
||||
$root = realpath(__DIR__ . '/../..');
|
||||
$this->assertNotFalse($root, 'Project root not found.');
|
||||
|
||||
$paths = [
|
||||
// Core scan paths
|
||||
$corePaths = [
|
||||
$root . '/pages',
|
||||
$root . '/templates',
|
||||
$root . '/lib',
|
||||
@@ -21,6 +22,108 @@ class TranslationKeysTest extends TestCase
|
||||
];
|
||||
$extensions = ['php', 'phtml', 'js'];
|
||||
|
||||
// Collect core keys
|
||||
$coreKeys = $this->collectKeysFromPaths($corePaths, $extensions);
|
||||
|
||||
// Load core translations
|
||||
$coreTranslations = $this->loadTranslationMap($root . '/i18n', ['default_de.json', 'default_en.json']);
|
||||
|
||||
// Also load all module translations so core keys provided by modules are available
|
||||
$moduleTranslations = $this->loadAllModuleTranslations($root . '/modules');
|
||||
$mergedTranslations = [];
|
||||
foreach ($coreTranslations as $name => $map) {
|
||||
$mergedTranslations[$name] = $map;
|
||||
}
|
||||
foreach ($moduleTranslations as $name => $maps) {
|
||||
foreach ($maps as $locale => $map) {
|
||||
$mergedTranslations[$locale] = array_merge($mergedTranslations[$locale] ?? [], $map);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate core keys exist in merged (core + module) translations
|
||||
$missing = [];
|
||||
foreach ($coreKeys as $key) {
|
||||
foreach ($mergedTranslations as $name => $map) {
|
||||
if (!array_key_exists($key, $map)) {
|
||||
$missing[$name][] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($missing as $name => $keys) {
|
||||
$messages[] = $name . ' missing: ' . implode(', ', $keys);
|
||||
}
|
||||
$this->assertSame([], $missing, implode(' | ', $messages));
|
||||
}
|
||||
|
||||
public function testAllModuleTranslationKeysExist(): void
|
||||
{
|
||||
$root = realpath(__DIR__ . '/../..');
|
||||
$this->assertNotFalse($root, 'Project root not found.');
|
||||
|
||||
$modulesDir = $root . '/modules';
|
||||
if (!is_dir($modulesDir)) {
|
||||
$this->markTestSkipped('No modules directory found.');
|
||||
}
|
||||
|
||||
$extensions = ['php', 'phtml', 'js'];
|
||||
|
||||
// Load core translations as fallback
|
||||
$coreTranslations = $this->loadTranslationMap($root . '/i18n', ['default_de.json', 'default_en.json']);
|
||||
|
||||
$allMissing = [];
|
||||
|
||||
foreach (glob($modulesDir . '/*/module.php') as $manifestFile) {
|
||||
$moduleDir = dirname($manifestFile);
|
||||
$moduleId = basename($moduleDir);
|
||||
|
||||
// Scan module directories for keys
|
||||
$modulePaths = [
|
||||
$moduleDir . '/pages',
|
||||
$moduleDir . '/templates',
|
||||
$moduleDir . '/lib',
|
||||
$moduleDir . '/web',
|
||||
];
|
||||
$moduleKeys = $this->collectKeysFromPaths($modulePaths, $extensions);
|
||||
if ($moduleKeys === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load module translations and merge with core
|
||||
$moduleI18nDir = $moduleDir . '/i18n';
|
||||
$moduleTranslations = is_dir($moduleI18nDir)
|
||||
? $this->loadTranslationMap($moduleI18nDir, ['default_de.json', 'default_en.json'])
|
||||
: [];
|
||||
|
||||
$merged = [];
|
||||
foreach ($coreTranslations as $locale => $map) {
|
||||
$merged[$locale] = array_merge($map, $moduleTranslations[$locale] ?? []);
|
||||
}
|
||||
|
||||
foreach ($moduleKeys as $key) {
|
||||
foreach ($merged as $locale => $map) {
|
||||
if (!array_key_exists($key, $map)) {
|
||||
$allMissing["modules/{$moduleId} → {$locale}"][] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($allMissing as $context => $keys) {
|
||||
$messages[] = $context . ' missing: ' . implode(', ', $keys);
|
||||
}
|
||||
$this->assertSame([], $allMissing, implode(' | ', $messages));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $paths
|
||||
* @param list<string> $extensions
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectKeysFromPaths(array $paths, array $extensions): array
|
||||
{
|
||||
$usedKeys = [];
|
||||
foreach ($paths as $path) {
|
||||
if (!is_dir($path)) {
|
||||
@@ -45,40 +148,71 @@ class TranslationKeysTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
$usedKeys = array_keys($usedKeys);
|
||||
sort($usedKeys);
|
||||
$keys = array_keys($usedKeys);
|
||||
sort($keys);
|
||||
|
||||
$locales = [
|
||||
'default_de.json' => $root . '/i18n/default_de.json',
|
||||
'default_en.json' => $root . '/i18n/default_en.json',
|
||||
];
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
private function loadTranslationMap(string $dir, array $files): array
|
||||
{
|
||||
$translations = [];
|
||||
foreach ($locales as $name => $path) {
|
||||
$this->assertFileExists($path, 'Missing locale file: ' . $name);
|
||||
foreach ($files as $name) {
|
||||
$path = $dir . '/' . $name;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
$this->assertNotFalse($raw, 'Failed to read locale file: ' . $name);
|
||||
if ($raw === false) {
|
||||
continue;
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
$this->assertIsArray($json, 'Invalid JSON in locale file: ' . $name);
|
||||
$translations[$name] = $json;
|
||||
if (is_array($json)) {
|
||||
$translations[$name] = $json;
|
||||
}
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach ($usedKeys as $key) {
|
||||
foreach ($translations as $name => $map) {
|
||||
if (!array_key_exists($key, $map)) {
|
||||
$missing[$name][] = $key;
|
||||
return $translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, array<string, string>>>
|
||||
*/
|
||||
private function loadAllModuleTranslations(string $modulesDir): array
|
||||
{
|
||||
$result = [];
|
||||
if (!is_dir($modulesDir)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
foreach (glob($modulesDir . '/*/i18n') as $i18nDir) {
|
||||
$moduleId = basename(dirname($i18nDir));
|
||||
foreach (['default_de.json', 'default_en.json'] as $file) {
|
||||
$path = $i18nDir . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
continue;
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
if (is_array($json)) {
|
||||
$result[$moduleId][$file] = $json;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($missing as $name => $keys) {
|
||||
$messages[] = $name . ' missing: ' . implode(', ', $keys);
|
||||
}
|
||||
|
||||
$this->assertSame([], $missing, implode(' | ', $messages));
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractTranslationKeys(string $content): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
Reference in New Issue
Block a user