Files
pleno-vue/tests/e2e/support/viewI18nKeyScanner.ts
Jeppe Bundgaard a4676f8f87 Add e2e and unit tests for i18n coverage and POS workflows:
- Introduced i18n key usage validation with `tests/e2e/i18n.views.spec.ts` for deterministic locale coverage.
- Added `viewI18nKeyScanner` utility to scan and validate view translation keys.
- Created `PosDepartmentStep1` unit tests for duplicate warnings and booking selection flow.
- Enhanced POS mobile popup with `SelectOrderBookingPopupProps` and new header close options.
- Updated e2e tests with scenarios to verify booking selections, duplicate handling, and locale alignment.
- Added new `test:e2e:i18n:views` npm script for targeted i18n test execution.
2026-04-14 10:49:20 +02:00

269 lines
7.3 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
export interface ViewTranslationKeyUsage {
functionName: "$t" | "$tc" | "t" | "te";
key: string;
filePath: string;
relativePath: string;
line: number;
column: number;
}
export interface NonLiteralTranslationCall {
functionName: "$t" | "$tc" | "t" | "te";
filePath: string;
relativePath: string;
line: number;
column: number;
snippet: string;
}
export interface ViewTranslationScanResult {
scannedFiles: string[];
literalKeyUsages: ViewTranslationKeyUsage[];
keyUsageByKey: Map<string, ViewTranslationKeyUsage[]>;
nonLiteralCalls: NonLiteralTranslationCall[];
}
const TRANSLATION_CALL_PATTERN = /(\$t|\$tc|\bte|\bt)\s*\(/g;
const VIEW_EXTENSIONS = new Set([".vue", ".js", ".ts"]);
const toProjectRelativePath = (projectRoot: string, absolutePath: string) => {
return path.relative(projectRoot, absolutePath).split(path.sep).join("/");
};
const collectFilesRecursively = (directory: string): string[] => {
const collected: string[] = [];
if (!fs.existsSync(directory)) {
return collected;
}
const walk = (currentPath: string) => {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (VIEW_EXTENSIONS.has(path.extname(entry.name))) {
collected.push(absolutePath);
}
}
};
walk(directory);
collected.sort();
return collected;
};
const buildLineStarts = (content: string): number[] => {
const starts = [0];
for (let i = 0; i < content.length; i += 1) {
if (content[i] === "\n") {
starts.push(i + 1);
}
}
return starts;
};
const findLineIndex = (lineStarts: number[], index: number): number => {
let low = 0;
let high = lineStarts.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const lineStart = lineStarts[mid];
const nextLineStart = mid + 1 < lineStarts.length ? lineStarts[mid + 1] : Number.POSITIVE_INFINITY;
if (index >= lineStart && index < nextLineStart) {
return mid;
}
if (index < lineStart) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return lineStarts.length - 1;
};
const getLineAndColumn = (lineStarts: number[], index: number) => {
const lineIndex = findLineIndex(lineStarts, index);
const lineStart = lineStarts[lineIndex] ?? 0;
return {
line: lineIndex + 1,
column: index - lineStart + 1,
};
};
const getLineSnippet = (content: string, lineStarts: number[], index: number): string => {
const lineIndex = findLineIndex(lineStarts, index);
const start = lineStarts[lineIndex] ?? 0;
const nextStart = lineIndex + 1 < lineStarts.length ? lineStarts[lineIndex + 1] : content.length;
return content.slice(start, nextStart).trim();
};
const skipWhitespace = (content: string, startIndex: number): number => {
let index = startIndex;
while (index < content.length && /\s/.test(content[index])) {
index += 1;
}
return index;
};
const parseQuotedStringLiteral = (
content: string,
quoteCharacter: "'" | '"',
quoteStartIndex: number
): { key: string; endIndex: number } | null => {
let index = quoteStartIndex + 1;
let rawValue = "";
while (index < content.length) {
const character = content[index];
if (character === "\\") {
if (index + 1 < content.length) {
rawValue += content.slice(index, index + 2);
index += 2;
continue;
}
rawValue += character;
index += 1;
continue;
}
if (character === quoteCharacter) {
const normalizedValue = rawValue
.replace(/\\\\/g, "\\")
.replace(quoteCharacter === "'" ? /\\'/g : /\\"/g, quoteCharacter);
return {
key: normalizedValue,
endIndex: index,
};
}
rawValue += character;
index += 1;
}
return null;
};
const normalizeFunctionName = (value: string): "$t" | "$tc" | "t" | "te" => {
if (value === "$t" || value === "$tc" || value === "t" || value === "te") {
return value;
}
return value.endsWith("te") ? "te" : "t";
};
export const scanViewTranslationKeys = (options?: {
projectRoot?: string;
viewsDirectory?: string;
}): ViewTranslationScanResult => {
const projectRoot = options?.projectRoot ?? process.cwd();
const viewsDirectory = options?.viewsDirectory ?? path.join(projectRoot, "src", "views");
const scannedFiles = collectFilesRecursively(viewsDirectory);
const literalKeyUsages: ViewTranslationKeyUsage[] = [];
const nonLiteralCalls: NonLiteralTranslationCall[] = [];
const keyUsageByKey = new Map<string, ViewTranslationKeyUsage[]>();
for (const filePath of scannedFiles) {
const content = fs.readFileSync(filePath, "utf8");
const lineStarts = buildLineStarts(content);
TRANSLATION_CALL_PATTERN.lastIndex = 0;
for (const match of content.matchAll(TRANSLATION_CALL_PATTERN)) {
if (match.index === undefined) {
continue;
}
const rawFunctionName = match[1] ?? "t";
const functionName = normalizeFunctionName(rawFunctionName);
const openParenOffset = match[0].lastIndexOf("(");
const openParenIndex = match.index + openParenOffset;
const argumentStart = skipWhitespace(content, openParenIndex + 1);
const position = getLineAndColumn(lineStarts, openParenIndex);
const relativePath = toProjectRelativePath(projectRoot, filePath);
const firstCharacter = content[argumentStart];
if (firstCharacter !== "'" && firstCharacter !== '"') {
nonLiteralCalls.push({
functionName,
filePath,
relativePath,
line: position.line,
column: position.column,
snippet: getLineSnippet(content, lineStarts, openParenIndex),
});
continue;
}
const parsedLiteral = parseQuotedStringLiteral(content, firstCharacter, argumentStart);
if (!parsedLiteral) {
nonLiteralCalls.push({
functionName,
filePath,
relativePath,
line: position.line,
column: position.column,
snippet: getLineSnippet(content, lineStarts, openParenIndex),
});
continue;
}
const keyPosition = getLineAndColumn(lineStarts, argumentStart);
const usage: ViewTranslationKeyUsage = {
functionName,
key: parsedLiteral.key,
filePath,
relativePath,
line: keyPosition.line,
column: keyPosition.column,
};
literalKeyUsages.push(usage);
const existingUsages = keyUsageByKey.get(usage.key) ?? [];
existingUsages.push(usage);
keyUsageByKey.set(usage.key, existingUsages);
}
}
literalKeyUsages.sort((left, right) => {
if (left.key !== right.key) {
return left.key.localeCompare(right.key);
}
if (left.relativePath !== right.relativePath) {
return left.relativePath.localeCompare(right.relativePath);
}
return left.line - right.line;
});
nonLiteralCalls.sort((left, right) => {
if (left.relativePath !== right.relativePath) {
return left.relativePath.localeCompare(right.relativePath);
}
if (left.line !== right.line) {
return left.line - right.line;
}
return left.column - right.column;
});
return {
scannedFiles,
literalKeyUsages,
keyUsageByKey,
nonLiteralCalls,
};
};