From 44112c7cbb480f96e0c9ca7a5dbed35650aa3a53 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 15:10:22 +0100 Subject: [PATCH] fix(i18n): distinguish singular import and conversion feedback --- .../scripts/verify-job-export-lifetime.mjs | 29 ++++++++++++++----- .../src/components/ImportExportJobs.tsx | 4 +-- job-tracker-ui/src/i18n/translations.ts | 4 +++ .../src/job-export-lifetime.test.tsx | 12 ++++++++ job-tracker-ui/src/job-import.test.tsx | 12 +++++++- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/job-tracker-ui/scripts/verify-job-export-lifetime.mjs b/job-tracker-ui/scripts/verify-job-export-lifetime.mjs index 8bd83839..c215c579 100644 --- a/job-tracker-ui/scripts/verify-job-export-lifetime.mjs +++ b/job-tracker-ui/scripts/verify-job-export-lifetime.mjs @@ -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) })); diff --git a/job-tracker-ui/src/components/ImportExportJobs.tsx b/job-tracker-ui/src/components/ImportExportJobs.tsx index 0beff18a..c2bfef5a 100644 --- a/job-tracker-ui/src/components/ImportExportJobs.tsx +++ b/job-tracker-ui/src/components/ImportExportJobs.tsx @@ -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"); } diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index e0429ba8..155e4803 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/job-export-lifetime.test.tsx b/job-tracker-ui/src/job-export-lifetime.test.tsx index 8e7b8b8b..ecab64cb 100644 --- a/job-tracker-ui/src/job-export-lifetime.test.tsx +++ b/job-tracker-ui/src/job-export-lifetime.test.tsx @@ -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(); +}); diff --git a/job-tracker-ui/src/job-import.test.tsx b/job-tracker-ui/src/job-import.test.tsx index edef6c5b..20fc5191 100644 --- a/job-tracker-ui/src/job-import.test.tsx +++ b/job-tracker-ui/src/job-import.test.tsx @@ -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(); 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);