fix(i18n): distinguish singular import and conversion feedback

This commit is contained in:
cesnimda
2026-11-11 15:10:22 +01:00
parent 50e16ff2e1
commit 44112c7cbb
5 changed files with 51 additions and 10 deletions
@@ -10,7 +10,8 @@ import { gzipSync } from 'node:zlib';
import ts from 'typescript';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/job-export-lifetime');
const conversionCountOnly = process.env.A3_CONVERSION_COUNT === '1';
const out = path.resolve(import.meta.dirname, conversionCountOnly ? '../../docs/i18n/evidence/import-conversion-counts' : '../../docs/ui-modernisation/evidence/job-export-lifetime');
fs.mkdirSync(out, { recursive: true });
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'jobjakt-export-'));
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
@@ -18,7 +19,7 @@ const json = '[{"id":42,"jobTitle":"Synthetic lærer","company":{"name":"Synthet
const csv = '\ufeffJob Title,Company\r\nSynthetic lærer,Synthetic skole\r\n';
const conversion = 'Job Title,Company\nSynthetic lærer,Synthetic skole';
const report = { journeys: [], downloads: [], captures: [], errors: [], writes: [], unavailableReads: [], blockedExternal: [] };
const configurations = process.env.A3_PILOT ? [[390, 'nb', 'dark']] : [390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(theme => [width, language, theme])));
const configurations = conversionCountOnly ? [[390, 'nb', 'dark'], [1440, 'en', 'light']] : process.env.A3_PILOT ? [[390, 'nb', 'dark']] : [390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(theme => [width, language, theme])));
const browser = await chromium.launch();
try {
for (const [width, language, theme] of configurations) {
@@ -62,21 +63,35 @@ try {
report.unavailableReads.push(p); return route.fulfill({ status: 404, json: {} });
});
const group = page.getByRole('group', { name: copy.importExportTitle, exact: true });
const saveDownload = async (kind, trigger) => {
const saveDownload = async (kind, trigger, expectedCount = 1) => {
const event = page.waitForEvent('download'); await trigger(); const download = await event;
assert.match(download.suggestedFilename(), kind === 'converted' ? /^job-tracker-import-.*\.json$/ : new RegExp(`^job-tracker-export-.*\.${kind}$`));
const target = path.join(temporary, `${name}-${kind}`); await download.saveAs(target);
const tag = `${name}-${kind}${conversionCountOnly ? `-${expectedCount}` : ''}`;
const target = path.join(temporary, tag); await download.saveAs(target);
const bytes = fs.readFileSync(target);
if (kind === 'json') assert.deepEqual(bytes, Buffer.from(json));
else if (kind === 'csv') assert.deepEqual(bytes, Buffer.from(csv));
else { const rows = JSON.parse(bytes.toString('utf8')); assert.equal(rows.length, 1); assert.equal(rows[0].jobTitle, 'Synthetic lærer'); assert.equal(rows[0].company.name, 'Synthetic skole'); }
const compressed = gzipSync(bytes), filename = `${name}-${kind}.gz`; fs.writeFileSync(path.join(out, filename), compressed);
report.downloads.push({ name, kind, filename, bytes: bytes.length, rawSha256: sha(bytes), gzipSha256: sha(compressed) });
else { const rows = JSON.parse(bytes.toString('utf8')); assert.equal(rows.length, expectedCount); for (const row of rows) { assert.equal(row.jobTitle, 'Synthetic lærer'); assert.equal(row.company.name, 'Synthetic skole'); } }
const compressed = gzipSync(bytes), filename = `${tag}.gz`; fs.writeFileSync(path.join(out, filename), compressed);
report.downloads.push({ name, kind, expectedCount, filename, bytes: bytes.length, rawSha256: sha(bytes), gzipSha256: sha(compressed) });
};
const spaNavigate = async destination => {
await page.evaluate(value => { history.pushState({}, '', value); window.dispatchEvent(new PopStateEvent('popstate')); }, destination);
};
await page.goto('http://localhost:4338/settings'); await expect(group).toBeVisible();
if (conversionCountOnly) {
for (const count of [0, 1, 2]) {
const text = 'Job Title,Company\n' + Array(count).fill('Synthetic lærer,Synthetic skole').join('\n');
await saveDownload('converted', () => page.locator('input[accept=".csv,text/csv"]').setInputFiles({ name: `count-${count}.csv`, mimeType: 'text/csv', buffer: Buffer.from(text) }), count);
await expect(page.getByText(copy[count === 1 ? 'convertedRow' : 'convertedRows'].replace('{count}', String(count)), { exact: true })).toBeVisible();
await group.scrollIntoViewIfNeeded();
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
report.captures.push({ name, count, violations: scan.violations }); assert.deepEqual(scan.violations, []);
await page.screenshot({ path: path.join(out, `${name}-count-${count}.png`) });
}
assert.equal(downloads, 3); report.journeys.push({ name, counts: [0, 1, 2], downloads });
await context.close(); continue;
}
await saveDownload('json', () => group.getByRole('button', { name: copy.exportJson, exact: true }).click());
await saveDownload('csv', () => group.getByRole('button', { name: copy.exportCsv, exact: true }).click());
await saveDownload('converted', () => page.locator('input[accept=".csv,text/csv"]').setInputFiles({ name: 'normal.csv', mimeType: 'text/csv', buffer: Buffer.from(conversion) }));
@@ -362,7 +362,7 @@ export default function ImportExportJobs() {
setLastImportCount(created);
setImportState(null);
toast(currentTranslation.current("importedJobs", { count: created }), "success");
toast(currentTranslation.current(created === 1 ? "importedJob" : "importedJobs", { count: created }), "success");
} catch (error) {
if (!active()) return;
if (error instanceof InvalidImport) setImportState({ kind: "invalid", row: error.row });
@@ -390,7 +390,7 @@ export default function ImportExportJobs() {
const jobs = csvToImportJobs(text);
const stamp = new Date().toISOString().slice(0, 10);
downloadFile(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json");
toast(currentTranslation.current("convertedRows", { count: jobs.length }), "success");
toast(currentTranslation.current(jobs.length === 1 ? "convertedRow" : "convertedRows", { count: jobs.length }), "success");
} catch {
if (active()) toast(currentTranslation.current("csvConversionFailed"), "error");
}
+4
View File
@@ -2779,8 +2779,10 @@ export const translations = {
exportedJobs: "Exported jobs ({format}).",
exportFailed: "Export failed.",
importedJobs: "Imported {count} jobs.",
importedJob: "Imported {count} job.",
importFailedJson: "Import failed (expecting exported JSON array).",
convertedRows: "Converted {count} rows to import JSON.",
convertedRow: "Converted {count} row to import JSON.",
csvConversionFailed: "CSV conversion failed.",
lastImport: "Last import: {count}",
signInTitle: "Sign in",
@@ -6021,8 +6023,10 @@ export const translations = {
exportedJobs: "Eksporterte jobber ({format}).",
exportFailed: "Eksport mislyktes.",
importedJobs: "Importerte {count} jobber.",
importedJob: "Importerte {count} jobb.",
importFailedJson: "Import mislyktes (forventer eksportert JSON-array).",
convertedRows: "Konverterte {count} rader til import-JSON.",
convertedRow: "Konverterte {count} rad til import-JSON.",
csvConversionFailed: "CSV-konvertering mislyktes.",
lastImport: "Siste import: {count}",
signInTitle: "Logg inn",
@@ -95,3 +95,15 @@ test.each(["json", "convert"] as const)("%s delayed rejection uses the current U
await act(async () => pending.reject(new Error("offline")));
expect(await screen.findByText(action === "json" ? translations.nb.exportFailed : translations.nb.csvConversionFailed)).toBeVisible();
});
test.each(["en", "nb"] as const)("%s conversion distinguishes zero, one and multiple rows", async language => {
localStorage.setItem("uiLanguage", language); view();
for (const count of [0, 1, 2]) {
const contents = "Job Title,Company\n" + Array(count).fill("Teacher,Synthetic school").join("\n");
const file = new File([], "jobs.csv"); Object.defineProperty(file, "text", { value: () => Promise.resolve(contents) });
await act(async () => fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [file] } }));
const message = language === "en" ? `Converted ${count} ${count === 1 ? "row" : "rows"} to import JSON.` : `Konverterte ${count} ${count === 1 ? "rad" : "rader"} til import-JSON.`;
expect(await screen.findByText(message)).toBeVisible();
}
expect(download).toHaveBeenCalledTimes(3); expect(client.post).not.toHaveBeenCalled();
});
+11 -1
View File
@@ -54,6 +54,16 @@ beforeEach(() => {
});
afterEach(() => jest.restoreAllMocks());
it.each(["en", "nb"] as const)("%s import feedback distinguishes one job from multiple jobs", async language => {
localStorage.setItem("uiLanguage", language); view();
for (const count of [1, 2]) {
await select(selectedFile(Array.from({ length: count }, (_, i) => row(`Synthetic role ${i + 1}`))));
const message = language === "en" ? `Imported ${count} ${count === 1 ? "job" : "jobs"}.` : `Importerte ${count} ${count === 1 ? "jobb" : "jobber"}.`;
expect(await screen.findByText(message)).toBeVisible();
}
expect(mockedApi.post).toHaveBeenCalledTimes(6);
});
it("[P7-09 import] app PascalCase export recreates every supported create field without owner, history or attachment state", async () => {
view();
await select(selectedFile([{
@@ -154,7 +164,7 @@ it("[P7-09 import] unmount during file reading stops the batch and a fresh panel
const held = deferred<string>(); view(); await select(selectedFile([], () => held.promise));
fireEvent.click(screen.getByRole("button", { name: "Toggle import panel" }));
await act(async () => held.resolve(JSON.stringify([row("Disposed role")])));
expect(mockedApi.post).not.toHaveBeenCalled(); expect(screen.queryByText(tr("importedJobs", "en", { count: 1 }))).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled(); expect(screen.queryByText(tr("importedJob", "en", { count: 1 }))).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle import panel" }));
await select(selectedFile([row("Current role")]));
expect(mockedApi.post).toHaveBeenCalledTimes(2);