Brings all of the develop branch's commits into master. ## What this contains The 9 commits on develop that landed in this round — all XL Vask-related UI fixes plus the 'visible primary-button hover state' visual-diff: - **PR #303** (TRU-5 / AUT-1) — style(AUT-1): visible primary-button hover state + visual diff - **PR #304** (TRU-10 / AUT-6) — fix(invoicing-period): propagate flagged wash start date to Selvvask view - **PR #305** (TRU-12 / AUT-8) — feat(xlvask): render friendly notice for 404 from /modules/xlvask/services/usage/orders - **PR #306** (TRU-9 / AUT-5) — i18n(test): lock in xlvask_review / xlvask_usage_log mirroring to the global v2 fallback - **PR #307** (TRU-13 / AUT-9) — i18n(xlvask_review): translate missing keys for no, sv, de, en - **PR #308** (TRU-15 / AUT-11) — test(e2e): add Playwright smoke test for XL Vask flag → Selvvash navigation - **PR #309** (TRU-11 / AUT-7) — feat(TRU-11): propagate department selector to Selvvask usage query - **PR #310** (TRU-19 / AUT-15) — test(TRU-19): lock self-serve program number range + button registry contract - **PR #311** (TRU-8 / AUT-4) — fix(invoicing-flag-list): explain empty XL Vask hover preview when flag context has no metadata ## Why The XL Vask integration bug surfaced from the user-reported message "XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre i den valgte periode. doesn't show the wash." After dispatching 9 diagnostic + fix tasks and merging all 9 PRs into develop via the OpenSymphony orchestrator running against MiniMax M3, this PR is the canonical release to bring develop's accumulated changes into master. No new code in this PR — just the squash-merged output of the 9 source PRs combined into a single develop→master merge. ## Verification All 9 source PRs passed: - Required CI (Action Runners) - App Store Readiness - Quality lint/i18n/build/unit/e2e suites The required checks on this PR will run the same gate. ## Notes - The api repo has its own equivalent PR/merge — see CHANGELOG for that side. --------- Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io> Co-authored-by: openhands <openhands@all-hands.dev>
863 lines
32 KiB
TypeScript
863 lines
32 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
const ACTIVE_LOCALES = ["da", "en", "sv", "de", "no"] as const;
|
|
const LEGACY_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "locales");
|
|
const GENERATED_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "generated");
|
|
const SOURCE_DIRECTORY = path.join(process.cwd(), "src", "i18n", "source");
|
|
const MAX_SOURCE_JSON_LINES = 500;
|
|
const LINK_TOKEN_PATTERN = /@(?:\.[\p{L}]+)?:(?:\{'([^']+)'\}|([\p{L}\p{N}_.-]+))/gu;
|
|
const EXACT_LINK_PATTERN = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
|
|
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
const WORD_TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
|
|
|
|
type Locale = (typeof ACTIVE_LOCALES)[number];
|
|
type LocaleMessages = Record<string, unknown>;
|
|
type StringEntry = { key: string; value: string };
|
|
|
|
const readJsonFile = (absolutePath: string): LocaleMessages => {
|
|
const content = fs.readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, "");
|
|
return JSON.parse(content) as LocaleMessages;
|
|
};
|
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const mergeMessages = (sharedMessages: LocaleMessages = {}, localeMessages: LocaleMessages = {}): LocaleMessages => {
|
|
const mergedMessages: LocaleMessages = { ...sharedMessages };
|
|
|
|
for (const [key, value] of Object.entries(localeMessages)) {
|
|
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
|
|
mergedMessages[key] = mergeMessages(mergedMessages[key] as LocaleMessages, value as LocaleMessages);
|
|
} else {
|
|
mergedMessages[key] = value;
|
|
}
|
|
}
|
|
|
|
return mergedMessages;
|
|
};
|
|
|
|
const readGlobalV2 = () => readJsonFile(path.join(GENERATED_LOCALES_DIRECTORY, "global-v2.json"));
|
|
|
|
const readRawLocale = (locale: Locale, version = "v2"): LocaleMessages => {
|
|
if (version !== "v2") {
|
|
throw new Error("Only v2 locale files are supported.");
|
|
}
|
|
|
|
return readJsonFile(path.join(GENERATED_LOCALES_DIRECTORY, `${locale}-v2.json`));
|
|
};
|
|
|
|
const listJsonFiles = (directory: string): string[] => {
|
|
if (!fs.existsSync(directory)) {
|
|
return [];
|
|
}
|
|
|
|
return fs
|
|
.readdirSync(directory, { withFileTypes: true })
|
|
.flatMap((entry) => {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return listJsonFiles(entryPath);
|
|
}
|
|
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
|
|
})
|
|
.sort();
|
|
};
|
|
|
|
const readLocale = (locale: Locale, version = "v2"): LocaleMessages => {
|
|
if (version !== "v2") {
|
|
throw new Error("Only v2 locale files are supported.");
|
|
}
|
|
|
|
const localeMessages = readRawLocale(locale, version);
|
|
const globalV2 = readGlobalV2();
|
|
return mergeMessages(
|
|
mergeMessages(globalV2.shared as LocaleMessages, getValueAtPath(globalV2, `locales.${locale}`) as LocaleMessages),
|
|
localeMessages
|
|
);
|
|
};
|
|
|
|
const flattenStringEntries = (value: unknown, prefix = ""): StringEntry[] => {
|
|
if (typeof value === "string") {
|
|
return [{ key: prefix, value }];
|
|
}
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(value as Record<string, unknown>).flatMap(([key, entry]) =>
|
|
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
|
);
|
|
};
|
|
|
|
const getValueAtPath = (value: unknown, keyPath: string): unknown => {
|
|
let current = value;
|
|
|
|
for (const segment of keyPath.split(".")) {
|
|
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
return undefined;
|
|
}
|
|
|
|
const objectValue = current as Record<string, unknown>;
|
|
if (!(segment in objectValue)) {
|
|
return undefined;
|
|
}
|
|
|
|
current = objectValue[segment];
|
|
}
|
|
|
|
return current;
|
|
};
|
|
|
|
const isLinkedMessage = (value: string): boolean => EXACT_LINK_PATTERN.test(value.trim());
|
|
|
|
const getLinkedTarget = (match: RegExpMatchArray): string => match[1] ?? match[2];
|
|
|
|
const getExactLinkedTarget = (match: RegExpMatchArray): string | undefined =>
|
|
match.groups?.literal ?? match.groups?.path;
|
|
|
|
const applyLinkedModifier = (value: string, modifier?: string): string => {
|
|
switch (modifier) {
|
|
case ".capitalize":
|
|
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
|
|
case ".upper":
|
|
return value.toLocaleUpperCase();
|
|
case ".lower":
|
|
return value.toLocaleLowerCase();
|
|
default:
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const resolveLinkedMessage = (
|
|
messages: LocaleMessages,
|
|
keyPath: string,
|
|
seen = new Set<string>()
|
|
): string | undefined => {
|
|
if (seen.has(keyPath)) {
|
|
return undefined;
|
|
}
|
|
seen.add(keyPath);
|
|
|
|
const value = getValueAtPath(messages, keyPath);
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
|
|
const exactLink = value.trim().match(EXACT_LINK_PATTERN);
|
|
if (exactLink) {
|
|
const target = getExactLinkedTarget(exactLink);
|
|
if (!target) {
|
|
return undefined;
|
|
}
|
|
|
|
const resolved = resolveLinkedMessage(messages, target, seen);
|
|
return resolved === undefined ? undefined : applyLinkedModifier(resolved, exactLink.groups?.modifier);
|
|
}
|
|
|
|
return value.replace(LINK_TOKEN_PATTERN, (token, literalTarget, pathTarget) => {
|
|
const target = literalTarget ?? pathTarget;
|
|
const resolved = resolveLinkedMessage(messages, target, new Set(seen));
|
|
return resolved ?? token;
|
|
});
|
|
};
|
|
|
|
const protectedTextSegments = (value: string): Array<{ protected: boolean; value: string }> => {
|
|
const ranges: Array<{ start: number; end: number }> = [];
|
|
for (const pattern of [LINK_TOKEN_PATTERN, PLACEHOLDER_PATTERN]) {
|
|
pattern.lastIndex = 0;
|
|
for (const match of value.matchAll(pattern)) {
|
|
const start = match.index ?? 0;
|
|
ranges.push({ start, end: start + match[0].length });
|
|
}
|
|
}
|
|
|
|
ranges.sort((left, right) => left.start - right.start || right.end - left.end);
|
|
const mergedRanges: Array<{ start: number; end: number }> = [];
|
|
for (const range of ranges) {
|
|
const previous = mergedRanges.at(-1);
|
|
if (previous && range.start <= previous.end) {
|
|
previous.end = Math.max(previous.end, range.end);
|
|
} else {
|
|
mergedRanges.push({ ...range });
|
|
}
|
|
}
|
|
|
|
const segments: Array<{ protected: boolean; value: string }> = [];
|
|
let offset = 0;
|
|
for (const range of mergedRanges) {
|
|
if (offset < range.start) {
|
|
segments.push({ protected: false, value: value.slice(offset, range.start) });
|
|
}
|
|
segments.push({ protected: true, value: value.slice(range.start, range.end) });
|
|
offset = range.end;
|
|
}
|
|
if (offset < value.length) {
|
|
segments.push({ protected: false, value: value.slice(offset) });
|
|
}
|
|
|
|
return segments;
|
|
};
|
|
|
|
const normalizeWordToken = (value: string): string => value.normalize("NFKC").toLocaleLowerCase();
|
|
|
|
const collectAuditedWordTokens = (value: string): string[] => {
|
|
const tokens: string[] = [];
|
|
for (const segment of protectedTextSegments(value)) {
|
|
if (segment.protected) {
|
|
continue;
|
|
}
|
|
|
|
WORD_TOKEN_PATTERN.lastIndex = 0;
|
|
for (const match of segment.value.matchAll(WORD_TOKEN_PATTERN)) {
|
|
if (/\p{L}/u.test(match[0])) {
|
|
tokens.push(normalizeWordToken(match[0]));
|
|
}
|
|
}
|
|
}
|
|
|
|
return tokens;
|
|
};
|
|
|
|
const placeholderSignature = (value: string | undefined): string =>
|
|
[...String(value || "").matchAll(PLACEHOLDER_PATTERN)]
|
|
.map((match) => match[1])
|
|
.sort()
|
|
.join("|");
|
|
|
|
const normalizeExactPhrase = (value: string): string => value.trim().replace(/\s+/g, " ");
|
|
|
|
const normalizeNearDuplicatePhrase = (value: string): string =>
|
|
value
|
|
.normalize("NFKC")
|
|
.toLowerCase()
|
|
.replace(PLACEHOLDER_PATTERN, "{}")
|
|
.replace(/[\p{P}\p{S}]+/gu, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
const normalizeCaseOnlyPhrase = (value: string): string =>
|
|
value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
|
|
|
const requiredWordGroups = [
|
|
"grammar",
|
|
"actions",
|
|
"entities",
|
|
"statuses",
|
|
"domain",
|
|
"services",
|
|
"units",
|
|
"replication",
|
|
"generated",
|
|
];
|
|
|
|
const expectedReplicationDatabaseHostTargets: Record<Locale, string> = {
|
|
da: "database-v\u00e6rt",
|
|
en: "database host",
|
|
sv: "databas-v\u00e4rd",
|
|
de: "Datenbank-Host",
|
|
no: "database-vert",
|
|
};
|
|
|
|
const expectedReplicationHostWords: Record<Locale, Record<string, string>> = {
|
|
da: {
|
|
host: "v\u00e6rt",
|
|
host_definite_suffix: "en",
|
|
database: "database",
|
|
redis: "Redis",
|
|
minio: "MinIO",
|
|
},
|
|
en: {
|
|
host: "host",
|
|
host_definite_suffix: "",
|
|
database: "database",
|
|
redis: "Redis",
|
|
minio: "MinIO",
|
|
},
|
|
sv: {
|
|
host: "v\u00e4rd",
|
|
host_definite_suffix: "en",
|
|
database: "databas",
|
|
redis: "Redis",
|
|
minio: "MinIO",
|
|
},
|
|
de: {
|
|
host: "Host",
|
|
host_definite_suffix: "",
|
|
database: "Datenbank",
|
|
redis: "Redis",
|
|
minio: "MinIO",
|
|
},
|
|
no: {
|
|
host: "vert",
|
|
host_definite_suffix: "en",
|
|
database: "database",
|
|
redis: "Redis",
|
|
minio: "MinIO",
|
|
},
|
|
};
|
|
|
|
const expectedReplicationHostMentions: Record<Locale, Record<string, string>> = {
|
|
da: {
|
|
primary_database: "den prim\u00e6re database-v\u00e6rt",
|
|
redis: "Redis-v\u00e6rten",
|
|
minio: "MinIO-v\u00e6rten",
|
|
},
|
|
en: {
|
|
primary_database: "primary database host",
|
|
redis: "Redis host",
|
|
minio: "MinIO host",
|
|
},
|
|
sv: {
|
|
primary_database: "den prim\u00e4ra databas-v\u00e4rden",
|
|
redis: "Redis-v\u00e4rden",
|
|
minio: "MinIO-v\u00e4rden",
|
|
},
|
|
de: {
|
|
primary_database: "den prim\u00e4ren Datenbank-Host",
|
|
redis: "den Redis-Host",
|
|
minio: "den MinIO-Host",
|
|
},
|
|
no: {
|
|
primary_database: "den prim\u00e6re database-verten",
|
|
redis: "Redis-verten",
|
|
minio: "MinIO-verten",
|
|
},
|
|
};
|
|
|
|
const rejectedReplicationDatabaseHostFragments: Record<Locale, string[]> = {
|
|
da: ["databasev\u00e6rt"],
|
|
en: [],
|
|
sv: ["databasv\u00e4rd"],
|
|
de: ["Datenbankhost"],
|
|
no: ["databasevert"],
|
|
};
|
|
|
|
const groupBy = <T>(entries: T[], getKey: (entry: T) => string): Map<string, T[]> => {
|
|
const grouped = new Map<string, T[]>();
|
|
for (const entry of entries) {
|
|
const key = getKey(entry);
|
|
const group = grouped.get(key) ?? [];
|
|
group.push(entry);
|
|
grouped.set(key, group);
|
|
}
|
|
return grouped;
|
|
};
|
|
|
|
const reviewedNearDuplicatePathPatterns = [
|
|
/^admin\.daily_report\./,
|
|
/^admin\.department_modules\./,
|
|
/^admin\.pos\./,
|
|
/^admin\.pos\.stripe\.(actions|states)\./,
|
|
/^admin\.time_bookings\.opening_hours\./,
|
|
/^admin\.wash_lanes\./,
|
|
/^auth\./,
|
|
/^bookings\./,
|
|
/^bookings_table\./,
|
|
/^common\./,
|
|
/^customer_creation\./,
|
|
/^customers\./,
|
|
/^customers\.x_/,
|
|
/^department_dashboard\./,
|
|
/^department_reports\./,
|
|
/^global\./,
|
|
/^global_search\.badges\./,
|
|
/^invoice_period\.flags\.tokens\./,
|
|
/^messages\./,
|
|
/^modals\./,
|
|
/^nav\./,
|
|
/^objects\./,
|
|
/^pagination\./,
|
|
/^pos\./,
|
|
/^products\./,
|
|
/^profile\./,
|
|
/^settings\./,
|
|
/^statistics\./,
|
|
/^superuser\.user\./,
|
|
/^system_status\./,
|
|
/^tables\./,
|
|
/^time_booking_flow\./,
|
|
/^time_bookings\./,
|
|
/^user_dashboard\./,
|
|
];
|
|
|
|
const isGeneratedCompatibilityTemplate = (entry: StringEntry): boolean =>
|
|
entry.key.startsWith("templates.generated.compat.");
|
|
|
|
const isReviewedNearDuplicateGroup = (entries: StringEntry[]): boolean => {
|
|
const caseOnlySignatures = new Set(entries.map((entry) => normalizeCaseOnlyPhrase(entry.value)));
|
|
if (caseOnlySignatures.size === 1) {
|
|
return true;
|
|
}
|
|
|
|
if (entries.some((entry) => entry.value.includes("..."))) {
|
|
return true;
|
|
}
|
|
|
|
if (entries.some((entry) => /\.(placeholder|subtitle|title|single|multiple)$/.test(entry.key))) {
|
|
return true;
|
|
}
|
|
|
|
return entries.every((entry) => reviewedNearDuplicatePathPatterns.some((pattern) => pattern.test(entry.key)));
|
|
};
|
|
|
|
test.describe("i18n v2 catalog integrity", () => {
|
|
test("keeps v2 translator source files small and generated runtime files isolated", () => {
|
|
const sourceFiles = listJsonFiles(SOURCE_DIRECTORY);
|
|
expect(sourceFiles.length, "v2 source catalog fragments should exist").toBeGreaterThan(0);
|
|
|
|
const oversizedSourceFiles = sourceFiles
|
|
.map((filePath) => ({
|
|
filePath,
|
|
lineCount: fs.readFileSync(filePath, "utf8").split(/\r?\n/).length,
|
|
}))
|
|
.filter((entry) => entry.lineCount > MAX_SOURCE_JSON_LINES)
|
|
.map((entry) => `${path.relative(process.cwd(), entry.filePath)}: ${entry.lineCount} lines`);
|
|
|
|
expect(oversizedSourceFiles, "v2 source files should stay small enough to edit").toEqual([]);
|
|
|
|
const legacyV2Files = ["global-v2.json", ...ACTIVE_LOCALES.map((locale) => `${locale}-v2.json`)]
|
|
.map((fileName) => path.join(LEGACY_LOCALES_DIRECTORY, fileName))
|
|
.filter((filePath) => fs.existsSync(filePath))
|
|
.map((filePath) => path.relative(process.cwd(), filePath));
|
|
|
|
expect(legacyV2Files, "v2 runtime files should live under src/i18n/generated, not src/i18n/locales").toEqual([]);
|
|
|
|
const legacyV1Files = ACTIVE_LOCALES.map((locale) => `${locale}.json`)
|
|
.map((fileName) => path.join(LEGACY_LOCALES_DIRECTORY, fileName))
|
|
.filter((filePath) => fs.existsSync(filePath))
|
|
.map((filePath) => path.relative(process.cwd(), filePath));
|
|
|
|
expect(legacyV1Files, "v1 locale files should be removed after v2 migration").toEqual([]);
|
|
|
|
for (const fileName of ["global-v2.json", ...ACTIVE_LOCALES.map((locale) => `${locale}-v2.json`)]) {
|
|
expect(fs.existsSync(path.join(GENERATED_LOCALES_DIRECTORY, fileName)), `${fileName} should be generated`).toBe(
|
|
true
|
|
);
|
|
}
|
|
});
|
|
|
|
test("keeps v2 locale files parseable after v1 catalog removal", () => {
|
|
const globalV2 = readGlobalV2();
|
|
expect(isPlainObject(globalV2.shared), "global-v2 shared aliases must be an object").toBe(true);
|
|
expect(isPlainObject(globalV2.locales), "global-v2 locale alias maps must be an object").toBe(true);
|
|
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const rawV2 = readRawLocale(locale, "v2");
|
|
expect(Object.keys(rawV2).slice(0, 2), `${locale} v2 should start with words then templates`).toEqual([
|
|
"words",
|
|
"templates",
|
|
]);
|
|
|
|
const v2 = readLocale(locale, "v2");
|
|
|
|
expect(flattenStringEntries(v2).length, `${locale} v2 runtime key count`).toBeGreaterThan(0);
|
|
expect(getValueAtPath(v2, "common.templates.step_of")).toBeTruthy();
|
|
expect(getValueAtPath(v2, "common.templates.field_required")).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test("keeps compatibility paths in global-v2 instead of raw locale packs", () => {
|
|
const globalV2 = readGlobalV2();
|
|
expect(getValueAtPath(globalV2, "shared.invoice_period.flags.tooltip.created_at")).toBe("@:common.created");
|
|
expect(getValueAtPath(globalV2, "shared.invoice_period.flags.preview.no_order_items")).toBe(
|
|
"@:common.templates.no_entity_available"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.invoice_period.flags.preview.no_xlvask_usage_log")).toBe(
|
|
"@:common.templates.no_entity_available"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.common.templates.no_entity_available")).toBe(
|
|
"@:templates.common.no_entity_available"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.database")).toBe(
|
|
"@.capitalize:{'words.replication.services.database'}"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.redis")).toBe("@:words.replication.services.redis");
|
|
expect(getValueAtPath(globalV2, "shared.replication.minio")).toBe("@:words.replication.services.minio");
|
|
expect(getValueAtPath(globalV2, "shared.replication.fields.host")).toBe("@.capitalize:{'words.replication.host'}");
|
|
expect(getValueAtPath(globalV2, "shared.replication.host_targets.database")).toBe(
|
|
"@:templates.replication.host_targets.database"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.host_mentions.redis")).toBe(
|
|
"@:templates.replication.host_mentions.redis"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.system_status.cards.database")).toBe(
|
|
"@:{'templates.generated.compat.system_status.cards.database'}"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.templates.add_host")).toBe("@:templates.replication.add_host");
|
|
expect(getValueAtPath(globalV2, "shared.replication.actions.rename")).toBe(
|
|
"@:{'templates.generated.compat.replication.actions.rename'}"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.add_database")).toBe("@:replication.templates.add_host");
|
|
expect(getValueAtPath(globalV2, "shared.replication.add_redis")).toBe("@:replication.templates.add_host");
|
|
expect(getValueAtPath(globalV2, "shared.replication.add_minio")).toBe("@:replication.templates.add_host");
|
|
expect(getValueAtPath(globalV2, "shared.replication.compose.steps.database_register_primary")).toBe(
|
|
"@:replication.templates.register_host_when_reachable"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.compose.steps.redis_register")).toBe(
|
|
"@:replication.templates.register_host_when_reachable"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.replication.compose.steps.minio_register")).toBe(
|
|
"@:replication.templates.register_host_when_reachable_with_space_requirement"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.invoice_period.flags.tooltip.unknown")).toBe(
|
|
"@:{'templates.generated.compat.common.unknown'}"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.global.mark_as_not_duplicate")).toBe(
|
|
"@:{'templates.generated.compat.global.mark_as_not_duplicate'}"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.global.no_data")).toBe("@:{'templates.generated.compat.global.no_data'}");
|
|
expect(getValueAtPath(globalV2, "shared.global.not_configured")).toBe(
|
|
"@:{'templates.generated.compat.global.not_configured'}"
|
|
);
|
|
expect(getValueAtPath(globalV2, "shared.pagination.page_of")).toBe(
|
|
"@:{'templates.generated.compat.pagination.page_of'}"
|
|
);
|
|
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const rawLocale = readRawLocale(locale, "v2");
|
|
const rawPhraseEntries = flattenStringEntries(rawLocale)
|
|
.filter((entry) => !entry.key.startsWith("words.") && !entry.key.startsWith("templates."))
|
|
.map((entry) => entry.key);
|
|
const globalLocaleEntries = flattenStringEntries(getValueAtPath(globalV2, `locales.${locale}`) ?? {}).map(
|
|
(entry) => entry.key
|
|
);
|
|
|
|
expect(rawPhraseEntries, `${locale} v2 feature phrase strings should be global aliases`).toEqual([]);
|
|
expect(globalLocaleEntries, `${locale} global-v2 locale override strings should be shared aliases`).toEqual([]);
|
|
expect(
|
|
getValueAtPath(rawLocale, "common.templates"),
|
|
`${locale} v2 common templates should be top-level`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.host_targets"),
|
|
`${locale} v2 host target words should be top-level`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.host_mentions"),
|
|
`${locale} v2 host mention words should be top-level`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.templates"),
|
|
`${locale} v2 replication templates should be top-level`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.database"),
|
|
`${locale} v2 database label should come from words`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.redis"),
|
|
`${locale} v2 Redis label should come from words`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.minio"),
|
|
`${locale} v2 MinIO label should come from words`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.fields.host"),
|
|
`${locale} v2 host field should come from words`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "system_status.cards.database"),
|
|
`${locale} v2 database card should come from words`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "words.replication.host_targets"),
|
|
`${locale} v2 host targets should be templates`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "words.replication.host_mentions"),
|
|
`${locale} v2 host mentions should be templates`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.add_database"),
|
|
`${locale} v2 add database label should be global`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.add_redis"),
|
|
`${locale} v2 add Redis label should be global`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.add_minio"),
|
|
`${locale} v2 add MinIO label should be global`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.compose.steps.database_register_primary"),
|
|
`${locale} v2 database register instruction should be global`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.compose.steps.redis_register"),
|
|
`${locale} v2 Redis register instruction should be global`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "replication.compose.steps.minio_register"),
|
|
`${locale} v2 MinIO register instruction should be global`
|
|
).toBeUndefined();
|
|
expect(
|
|
getValueAtPath(rawLocale, "templates.generated.compat.global.mark_as_not_duplicate"),
|
|
`${locale} v2 global mark-as-not-duplicate phrase should be a generated template`
|
|
).toBeTruthy();
|
|
expect(
|
|
getValueAtPath(rawLocale, "templates.generated.compat.global.no_data"),
|
|
`${locale} v2 no-data phrase should be a generated template`
|
|
).toBeTruthy();
|
|
expect(
|
|
getValueAtPath(rawLocale, "templates.generated.compat.pagination.page_of"),
|
|
`${locale} v2 page-of phrase should be a generated template`
|
|
).toBeTruthy();
|
|
|
|
const rawLinkedAliases = flattenStringEntries(rawLocale)
|
|
.filter((entry) => {
|
|
const exactLink = entry.value.trim().match(EXACT_LINK_PATTERN);
|
|
if (!exactLink) {
|
|
return false;
|
|
}
|
|
|
|
const target = getExactLinkedTarget(exactLink) ?? "";
|
|
return !target.startsWith("words.") && !target.startsWith("templates.");
|
|
})
|
|
.map((entry) => `${entry.key}: ${entry.value}`);
|
|
|
|
expect(rawLinkedAliases, `${locale} v2 raw locale pack should only exact-link to words/templates`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps the same v2 string key coverage across active locales", () => {
|
|
const keySets = new Map<Locale, string[]>();
|
|
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
keySets.set(
|
|
locale,
|
|
flattenStringEntries(readLocale(locale, "v2"))
|
|
.map((entry) => entry.key)
|
|
.filter((key) => !key.startsWith("words.generated."))
|
|
.sort()
|
|
);
|
|
}
|
|
|
|
const baseline = keySets.get("en") ?? [];
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
expect(keySets.get(locale), `${locale} v2 key set should match en v2`).toEqual(baseline);
|
|
}
|
|
});
|
|
|
|
test("keeps v2 word and template structure aligned across active locales", () => {
|
|
const baselineTemplateKeys = flattenStringEntries(getValueAtPath(readRawLocale("en", "v2"), "templates"))
|
|
.map((entry) => entry.key)
|
|
.sort();
|
|
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const rawLocale = readRawLocale(locale, "v2");
|
|
const words = getValueAtPath(rawLocale, "words");
|
|
expect(isPlainObject(words), `${locale} v2 words must be an object`).toBe(true);
|
|
expect(Object.keys(words as LocaleMessages).sort(), `${locale} v2 word groups`).toEqual(
|
|
[...requiredWordGroups].sort()
|
|
);
|
|
|
|
const templateKeys = flattenStringEntries(getValueAtPath(rawLocale, "templates"))
|
|
.map((entry) => entry.key)
|
|
.sort();
|
|
expect(templateKeys, `${locale} v2 template key coverage should match en`).toEqual(baselineTemplateKeys);
|
|
expect(flattenStringEntries(getValueAtPath(rawLocale, "words.generated")).length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test("centralizes every repeated raw v2 word token under words", () => {
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const rawLocale = readRawLocale(locale, "v2");
|
|
const tokenCounts = new Map<string, number>();
|
|
|
|
for (const entry of flattenStringEntries(rawLocale).filter(
|
|
(entry) => !entry.key.startsWith("words.") && !isGeneratedCompatibilityTemplate(entry)
|
|
)) {
|
|
for (const token of collectAuditedWordTokens(entry.value)) {
|
|
tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
const repeatedTokens = [...tokenCounts.entries()]
|
|
.filter(([, count]) => count > 1)
|
|
.map(([token, count]) => `${token}: ${count}`);
|
|
|
|
expect(repeatedTokens, `${locale} v2 repeated word tokens outside words`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps raw v2 words as unique literal definitions", () => {
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const wordEntries = flattenStringEntries(getValueAtPath(readRawLocale(locale, "v2"), "words"));
|
|
const duplicateWordValues = [...groupBy(wordEntries, (entry) => normalizeExactPhrase(entry.value)).entries()]
|
|
.filter(([value, group]) => value.length > 0 && group.length > 1)
|
|
.map(
|
|
([value, group]) => `${locale}: ${JSON.stringify(value)} <- ${group.map((entry) => entry.key).join(", ")}`
|
|
);
|
|
|
|
expect(duplicateWordValues, `${locale} v2 duplicate word definitions`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps replication database host formatting aligned with Redis and MinIO hosts", () => {
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const messages = readLocale(locale, "v2");
|
|
const rawLocale = readRawLocale(locale, "v2");
|
|
const expectedWords = expectedReplicationHostWords[locale];
|
|
|
|
expect(getValueAtPath(rawLocale, "words.replication.host")).toBe(expectedWords.host);
|
|
expect(getValueAtPath(rawLocale, "words.replication.host_definite_suffix")).toBe(
|
|
expectedWords.host_definite_suffix
|
|
);
|
|
expect(getValueAtPath(rawLocale, "words.replication.services.database")).toBe(expectedWords.database);
|
|
expect(getValueAtPath(rawLocale, "words.replication.services.redis")).toBe(expectedWords.redis);
|
|
expect(getValueAtPath(rawLocale, "words.replication.services.minio")).toBe(expectedWords.minio);
|
|
|
|
expect(resolveLinkedMessage(messages, "replication.host_targets.database")).toBe(
|
|
expectedReplicationDatabaseHostTargets[locale]
|
|
);
|
|
for (const [key, value] of Object.entries(expectedReplicationHostMentions[locale])) {
|
|
expect(resolveLinkedMessage(messages, `replication.host_mentions.${key}`)).toBe(value);
|
|
}
|
|
|
|
const rejectedFragments = rejectedReplicationDatabaseHostFragments[locale];
|
|
const offendingEntries = flattenStringEntries(messages)
|
|
.filter((entry) => rejectedFragments.some((fragment) => entry.value.includes(fragment)))
|
|
.map((entry) => `${entry.key}: ${entry.value}`);
|
|
|
|
expect(offendingEntries, `${locale} v2 should not use unhyphenated database host fragments`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps v2 linked messages resolvable and acyclic", () => {
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const messages = readLocale(locale, "v2");
|
|
const entries = flattenStringEntries(messages);
|
|
const missingLinks: string[] = [];
|
|
const cyclicLinks: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
for (const match of entry.value.matchAll(LINK_TOKEN_PATTERN)) {
|
|
const target = getLinkedTarget(match);
|
|
if (typeof getValueAtPath(messages, target) !== "string") {
|
|
missingLinks.push(`${entry.key} -> ${target}`);
|
|
}
|
|
}
|
|
|
|
if (isLinkedMessage(entry.value) && resolveLinkedMessage(messages, entry.key) === undefined) {
|
|
cyclicLinks.push(entry.key);
|
|
}
|
|
}
|
|
|
|
expect(missingLinks, `${locale} v2 unresolved linked messages`).toEqual([]);
|
|
expect(cyclicLinks, `${locale} v2 cyclic linked messages`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps v2 placeholder names aligned across active locales", () => {
|
|
const messagesByLocale = Object.fromEntries(
|
|
ACTIVE_LOCALES.map((locale) => [locale, readLocale(locale, "v2")])
|
|
) as Record<Locale, LocaleMessages>;
|
|
const baselineKeys = flattenStringEntries(messagesByLocale.en)
|
|
.map((entry) => entry.key)
|
|
.sort();
|
|
const mismatches: string[] = [];
|
|
|
|
for (const key of baselineKeys) {
|
|
const expected = placeholderSignature(resolveLinkedMessage(messagesByLocale.en, key));
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const actual = placeholderSignature(resolveLinkedMessage(messagesByLocale[locale], key));
|
|
if (actual !== expected) {
|
|
mismatches.push(`${locale}.${key}: expected {${expected}}, got {${actual}}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(mismatches).toEqual([]);
|
|
});
|
|
|
|
test("prevents duplicate raw v2 phrases outside linked compatibility aliases", () => {
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const entries = flattenStringEntries(readLocale(locale, "v2")).filter(
|
|
(entry) => !isLinkedMessage(entry.value) && !isGeneratedCompatibilityTemplate(entry)
|
|
);
|
|
const duplicateGroups = [...groupBy(entries, (entry) => normalizeExactPhrase(entry.value)).entries()]
|
|
.filter(([phrase, group]) => phrase.length > 0 && group.length > 1)
|
|
.map(
|
|
([phrase, group]) => `${locale}: ${JSON.stringify(phrase)} <- ${group.map((entry) => entry.key).join(", ")}`
|
|
);
|
|
|
|
expect(duplicateGroups).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps near-duplicate v2 phrase scan within reviewed contextual categories", () => {
|
|
const unexpectedGroups: string[] = [];
|
|
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const entries = flattenStringEntries(readLocale(locale, "v2")).filter(
|
|
(entry) => !isLinkedMessage(entry.value) && !isGeneratedCompatibilityTemplate(entry)
|
|
);
|
|
const grouped = groupBy(
|
|
entries,
|
|
(entry) => `${normalizeNearDuplicatePhrase(entry.value)}::${placeholderSignature(entry.value)}`
|
|
);
|
|
|
|
for (const [signature, group] of grouped.entries()) {
|
|
const [phrase] = signature.split("::");
|
|
if (phrase.length < 4 || group.length <= 1 || isReviewedNearDuplicateGroup(group)) {
|
|
continue;
|
|
}
|
|
|
|
unexpectedGroups.push(`${locale}: ${phrase} <- ${group.map((entry) => entry.key).join(", ")}`);
|
|
}
|
|
}
|
|
|
|
expect(unexpectedGroups).toEqual([]);
|
|
});
|
|
|
|
test("mirrors xlvask_review keys into the global v2 fallback", () => {
|
|
const daSource = readJsonFile(
|
|
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoicing_period", "xlvask_review.json")
|
|
);
|
|
const globalShared = readJsonFile(
|
|
path.join(SOURCE_DIRECTORY, "global", "shared", "invoicing_period", "xlvask_review.json")
|
|
);
|
|
|
|
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoicing_period.xlvask_review"));
|
|
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoicing_period.xlvask_review"));
|
|
|
|
const daKeys = daEntries.map((entry) => entry.key).sort();
|
|
const globalKeys = globalEntries.map((entry) => entry.key).sort();
|
|
|
|
expect(globalKeys, "global fallback should mirror every da xlvask_review key").toEqual(daKeys);
|
|
|
|
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
|
|
|
expect(nonLinked, "every global xlvask_review entry should be a linked reference").toEqual([]);
|
|
});
|
|
|
|
test("mirrors xlvask_usage_log flag keys into the global v2 fallback", () => {
|
|
const daSource = readJsonFile(
|
|
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoice_period", "flags.json")
|
|
);
|
|
const globalShared = readJsonFile(path.join(SOURCE_DIRECTORY, "global", "shared", "invoice_period", "flags.json"));
|
|
|
|
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoice_period.flags")).filter(
|
|
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
|
);
|
|
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoice_period.flags")).filter(
|
|
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
|
);
|
|
|
|
const daKeySet = new Set(daEntries.map((entry) => entry.key));
|
|
const globalKeySet = new Set(globalEntries.map((entry) => entry.key));
|
|
|
|
const missing = [...daKeySet].filter((key) => !globalKeySet.has(key));
|
|
expect(missing, "global flags fallback should mirror every da xlvask flag key").toEqual([]);
|
|
|
|
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
|
|
|
expect(nonLinked, "every mirrored global xlvask flag entry should be a linked reference").toEqual([]);
|
|
});
|
|
});
|