whisper-money/tests/Feature/LocalizationTest.php

233 lines
7.2 KiB
PHP

<?php
use PHPUnit\Framework\Assert;
/**
* Localization completeness tests.
*
* Spanish is the reference locale and is strictly enforced: any English
* string without a Spanish translation fails the suite, preventing
* untranslated content from reaching production.
*
* French is an optional, community-maintained locale. Its gaps only emit a
* warning — an incomplete French translation never blocks CI, but the run
* still surfaces exactly which keys are missing so they can be filled in.
*/
/**
* Locales whose translation gaps must fail the suite.
*
* @var array<string>
*/
const ENFORCED_LOCALES = ['es'];
/**
* Locales whose translation gaps only emit a warning.
*
* @var array<string>
*/
const OPTIONAL_LOCALES = ['fr'];
/**
* Recursively flattens a nested PHP translation array using dot notation.
*
* @param array<string, mixed> $array
* @param array<string, string> $result
* @return array<string, string>
*/
function flattenTranslations(array $array, string $prefix = '', array $result = []): array
{
foreach ($array as $key => $value) {
$fullKey = $prefix !== '' ? "{$prefix}.{$key}" : (string) $key;
if (is_array($value)) {
$result = flattenTranslations($value, $fullKey, $result);
} else {
$result[$fullKey] = (string) $value;
}
}
return $result;
}
/**
* Extracts all static string keys passed to the __() i18n function from
* TypeScript/TSX source files. Only literal string arguments are captured;
* dynamic expressions are intentionally skipped.
*
* @return array<string>
*/
function extractI18nKeysFromSource(): array
{
$resourcesPath = base_path('resources/js');
$keys = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($resourcesPath, FilesystemIterator::SKIP_DOTS)
);
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! in_array($file->getExtension(), ['ts', 'tsx'], strict: true)) {
continue;
}
$content = file_get_contents($file->getPathname());
if ($content === false) {
continue;
}
// Match __('single-quoted key') and __("double-quoted key")
// Skips template literals and variable references by design.
preg_match_all("/__\(\s*'([^'\\\\]*(?:\\\\.[^'\\\\]*)*)'\s*[,)]/", $content, $singleQuoted);
preg_match_all('/__\(\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"\s*[,)]/', $content, $doubleQuoted);
$keys = array_merge($keys, $singleQuoted[1], $doubleQuoted[1]);
}
return array_unique($keys);
}
/**
* Computes the PHP translation keys present in lang/en but missing from the
* given locale, grouped by filename. A wholly absent file is reported as the
* sentinel "__missing_file__".
*
* @return array<string, array<string>>
*/
function missingPhpTranslationKeys(string $locale): array
{
$englishDir = lang_path('en');
$missingByFile = [];
/** @var SplFileInfo $file */
foreach (new FilesystemIterator($englishDir, FilesystemIterator::SKIP_DOTS) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$filename = $file->getFilename();
$localePath = lang_path("{$locale}/{$filename}");
if (! file_exists($localePath)) {
$missingByFile[$filename] = ['__missing_file__'];
continue;
}
$englishKeys = flattenTranslations(require $file->getPathname());
$localeKeys = flattenTranslations(require $localePath);
$missingKeys = array_keys(array_diff_key($englishKeys, $localeKeys));
if ($missingKeys !== []) {
$missingByFile[$filename] = $missingKeys;
}
}
return $missingByFile;
}
/**
* Renders a human-readable report of missing PHP translation keys.
*
* @param array<string, array<string>> $missingByFile
*/
function formatPhpTranslationReport(string $locale, array $missingByFile): string
{
return implode("\n", array_map(
fn (string $f, array $keys) => "lang/{$locale}/{$f}:\n - ".implode("\n - ", $keys),
array_keys($missingByFile),
$missingByFile,
));
}
/**
* Computes the __() source keys missing from the given locale's JSON file.
* A missing or malformed file is reported via a sentinel entry.
*
* @return array<string>
*/
function missingJsonTranslationKeys(string $locale): array
{
$jsonPath = lang_path("{$locale}.json");
if (! file_exists($jsonPath)) {
return ['__missing_file__'];
}
$json = json_decode(file_get_contents($jsonPath), associative: true);
if (! is_array($json)) {
return ['__invalid_json__'];
}
$sourceKeys = extractI18nKeysFromSource();
$missingKeys = array_values(array_filter(
$sourceKeys,
fn (string $key) => ! array_key_exists($key, $json)
));
sort($missingKeys);
return $missingKeys;
}
describe('enforced PHP translations', function () {
it('has every English PHP key translated', function (string $locale) {
$missingByFile = missingPhpTranslationKeys($locale);
expect($missingByFile)->toBeEmpty(
"{$locale} PHP translation files have missing keys:\n".formatPhpTranslationReport($locale, $missingByFile)
);
})->with(ENFORCED_LOCALES);
});
describe('enforced JSON translations', function () {
it('has every __() source key translated', function (string $locale) {
expect(lang_path("{$locale}.json"))->toBeFile("Missing lang/{$locale}.json translation file");
$missingKeys = missingJsonTranslationKeys($locale);
expect($missingKeys)->toBeEmpty(
count($missingKeys)." key(s) used in source via __() are missing from lang/{$locale}.json:\n - ".implode("\n - ", $missingKeys)
);
})->with(ENFORCED_LOCALES);
});
describe('optional PHP translations', function () {
it('warns about untranslated PHP keys', function (string $locale) {
$missingByFile = missingPhpTranslationKeys($locale);
if ($missingByFile !== []) {
// Optional locale: surface the gap as an incomplete test so it stays
// visible in the run summary without failing CI (no failOnIncomplete
// is configured).
Assert::markTestIncomplete(
"Optional locale '{$locale}' has missing PHP translation keys (not blocking):\n"
.formatPhpTranslationReport($locale, $missingByFile)
);
}
expect($missingByFile)->toBeEmpty();
})->with(OPTIONAL_LOCALES);
});
describe('optional JSON translations', function () {
it('warns about __() source keys missing from the locale JSON', function (string $locale) {
$missingKeys = missingJsonTranslationKeys($locale);
if ($missingKeys !== []) {
// Optional locale: surface the gap as an incomplete test so it stays
// visible in the run summary without failing CI (no failOnIncomplete
// is configured).
Assert::markTestIncomplete(
count($missingKeys)." key(s) used in source via __() are missing from lang/{$locale}.json (not blocking):\n - "
.implode("\n - ", $missingKeys)
);
}
expect($missingKeys)->toBeEmpty();
})->with(OPTIONAL_LOCALES);
});