diff --git a/web/src/features/accounts/PageStates.tsx b/web/src/features/accounts/PageStates.tsx index c837b5f..afe0bb3 100644 --- a/web/src/features/accounts/PageStates.tsx +++ b/web/src/features/accounts/PageStates.tsx @@ -34,7 +34,7 @@ export function LoadFailedCard({ error, onRetry }: { error: unknown; onRetry: ()
-

{t('web_accounts_load_failed')}

+

{t('web_ledger_load_failed')}

{t(writeErrorKey(error))}

diff --git a/web/src/features/accounts/i18n/en.json b/web/src/features/accounts/i18n/en.json index 83c6b18..6b32c02 100644 --- a/web/src/features/accounts/i18n/en.json +++ b/web/src/features/accounts/i18n/en.json @@ -1,5 +1,4 @@ { - "web_accounts_load_failed": "Could not get the household's expenses", "web_accounts_split_presets": "Quick choices", "web_accounts_saving": "Saving…", "web_accounts_not_found_body": "The account may have been deleted on another device." diff --git a/web/src/features/accounts/i18n/sv.json b/web/src/features/accounts/i18n/sv.json index 800c778..c8dc054 100644 --- a/web/src/features/accounts/i18n/sv.json +++ b/web/src/features/accounts/i18n/sv.json @@ -1,5 +1,4 @@ { - "web_accounts_load_failed": "Kunde inte hämta hushållets utgifter", "web_accounts_split_presets": "Snabbval", "web_accounts_saving": "Sparar…", "web_accounts_not_found_body": "Kontot kan ha tagits bort på en annan enhet." diff --git a/web/src/features/settings/ProfilePage.test.tsx b/web/src/features/settings/ProfilePage.test.tsx index f010435..6d5d4e2 100644 --- a/web/src/features/settings/ProfilePage.test.tsx +++ b/web/src/features/settings/ProfilePage.test.tsx @@ -9,7 +9,7 @@ import { settingsServer } from './settingsTestServer'; /** The fake server parses request bodies as JSON, so the test picture's bytes are a JSON string. */ function picture(type = 'image/jpeg', name = 'me.jpg'): File { - return new File(['"picture-bytes"'], name, { type }); + return new File(['picture-bytes'], name, { type }); } describe('W-14 Profile', () => { @@ -72,7 +72,8 @@ describe('W-14 Profile', () => { expect(await screen.findByRole('button', { name: 'Byt bild' })).toBeInTheDocument(); const put = server.calls('PUT', '/me/picture')[0]; expect(put?.headers.get('Content-Type')).toBe('image/jpeg'); - expect(put?.body).toBe('picture-bytes'); + expect(put?.body).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(put?.body as Uint8Array)).toBe('picture-bytes'); await user.click(screen.getByRole('button', { name: 'Ta bort bild' })); expect(await screen.findByRole('button', { name: 'Lägg till bild' })).toBeInTheDocument(); diff --git a/web/src/i18n/catalog.test.ts b/web/src/i18n/catalog.test.ts index 7161c77..0a4f534 100644 --- a/web/src/i18n/catalog.test.ts +++ b/web/src/i18n/catalog.test.ts @@ -1,3 +1,5 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { featureCatalogs, fill, LOADED_FEATURE_CATALOGS, mergeCatalogs, translate, translatePlural } from './catalog'; import en from './en.json'; @@ -46,6 +48,26 @@ describe('feature catalogs', () => { expect(merged).toEqual({ common_save: 'Spara', web_settle_hint: 'Tips' }); }); + it('loads every feature catalog on disk (no stale transform of catalog.ts)', () => { + const featuresDir = join(process.cwd(), 'src', 'features'); // Vitest runs from web/ + const onDisk = readdirSync(featuresDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + let files: string[] = []; + try { + files = readdirSync(join(featuresDir, entry.name, 'i18n')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + return files.filter((file) => /^(sv|en)\.json$/.test(file)).map((file) => `${entry.name}/${file}`); + }) + .sort(); + const loaded = Object.entries(LOADED_FEATURE_CATALOGS) + .flatMap(([feature, catalogs]) => Object.keys(catalogs).map((language) => `${feature}/${language}.json`)) + .sort(); + expect(loaded).toEqual(onDisk); + }); + it('every loaded feature has the same web_ keys in sv and en, none shared', () => { for (const [feature, catalogs] of Object.entries(LOADED_FEATURE_CATALOGS)) { const svKeys = Object.keys(catalogs.sv ?? {}).sort(); diff --git a/web/src/i18n/catalog.ts b/web/src/i18n/catalog.ts index b28eeb7..8951105 100644 --- a/web/src/i18n/catalog.ts +++ b/web/src/i18n/catalog.ts @@ -46,7 +46,13 @@ export function mergeCatalogs(base: Catalog, features: readonly (Catalog | undef return merged; } -const FEATURES = featureCatalogs(import.meta.glob<{ default: Catalog }>('../features/*/i18n/*.json', { eager: true })); +// No type argument on `import.meta.glob` (typed by the annotation instead): Vitest's file-system module cache +// (on by default since Vitest 5) skips only modules whose source contains the literal text `import.meta.glob(`. +// Written as `import.meta.glob<…>(`, this module was cached by its own content, so a feature catalog added later was +// missing from the cached glob and its keys rendered raw in tests. catalog.test.ts compares the loaded catalogs with +// the files on disk. +const FEATURE_MODULES: FeatureModules = import.meta.glob('../features/*/i18n/*.json', { eager: true }); +const FEATURES = featureCatalogs(FEATURE_MODULES); const CATALOGS: Record = { sv: mergeCatalogs( diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index f02a6ec..06f11bd 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -595,6 +595,7 @@ "web_language_auto": "Browser language", "web_language_en": "English", "web_language_sv": "Svenska", + "web_ledger_load_failed": "Could not get the household's expenses", "web_login_body": "Enter an invite code from Settings › Pair a device on your phone.", "web_login_name_needed": "This code adds a new person to the household. Enter your name.", "web_login_server": "Server", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index a9a5b1a..638ffd0 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -595,6 +595,7 @@ "web_language_auto": "Webbläsarens språk", "web_language_en": "English", "web_language_sv": "Svenska", + "web_ledger_load_failed": "Kunde inte hämta hushållets utgifter", "web_login_body": "Skriv in en inbjudningskod från Inställningar › Para ihop en enhet på telefonen.", "web_login_name_needed": "Koden lägger till en ny person i hushållet. Skriv ditt namn.", "web_login_server": "Server", diff --git a/web/src/i18n/web-strings.csv b/web/src/i18n/web-strings.csv index a5f095c..6b2a3e7 100644 --- a/web/src/i18n/web-strings.csv +++ b/web/src/i18n/web-strings.csv @@ -32,3 +32,4 @@ web_not_found_title,Page not found,Sidan finns inte,module=web; unknown route un web_not_found_action,Go to Home,Gå till Översikt,module=web web_account_detail_title,Account,Konto,module=web; placeholder title of /app/accounts/:id until the page shows the account name web_expense_detail_title,Expense,Utgift,module=web; placeholder title of /app/expenses/:id until the page shows the description +web_ledger_load_failed,Could not get the household's expenses,Kunde inte hämta hushållets utgifter,module=web; title of the failed first read of the ledger (Home and the account pages) diff --git a/web/src/lib/vectors.test.ts b/web/src/lib/vectors.test.ts index c8eaf27..bdcc825 100644 --- a/web/src/lib/vectors.test.ts +++ b/web/src/lib/vectors.test.ts @@ -1,11 +1,12 @@ /** * Runs every case of every file in contracts/test-vectors/ (SPEC §14.5.5) against the web's port of kvitt-domain, * like server/crates/kvitt-domain/tests/vectors.rs. The directory is read at test time, so a new file is picked up; - * a function this port does not implement fails unless it is Kotlin-only (analytics, the category suggestion). + * a function this port does not implement fails unless it is listed as not ported. */ import { readdirSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { suggestCategory, type KeywordMatch } from '../features/expenses/suggest/suggest'; import { computeBalances, type BalanceExpense, type BalanceSettlement } from './balances'; import { overSettledAccounts, @@ -19,13 +20,14 @@ import { computeSplit, type SplitSpec } from './split'; /** Tests run from web/ (npm scripts), so the repository's contracts are one level up. */ const DIR = resolve(process.cwd(), '..', 'contracts', 'test-vectors'); /** Functions only the app implements (SPEC §14.5.4, §14.5.6); a web feature plan that ports one moves it below. */ -const NOT_PORTED = ['spend_series', 'resolve_range', 'suggest_category']; +const NOT_PORTED = ['spend_series', 'resolve_range']; const PORTED = [ 'compute_split', 'compute_balances', 'propose_allocation', 'validate_settlement', 'over_settled_accounts', + 'suggest_category', // W2: features/expenses/suggest ]; interface VectorCase { @@ -111,6 +113,30 @@ function run(fn: string, input: Record): unknown { } case 'over_settled_accounts': return { ok: overSettledAccounts(allocationsOf(input.allocations), positionsOf(input.accounts)) }; + case 'suggest_category': { + const history = input.history as { + description: string; + category_id: string; + expense_date: string; + created_at: string; + }[]; + const keywords = input.keywords as { keyword: string; match: KeywordMatch; default_key: string }[]; + const categories = input.active_categories as { id: string; default_key: string | null }[]; + return { + ok: suggestCategory( + input.description as string, + history.map((h) => ({ + description: h.description, + categoryId: h.category_id, + expenseDate: h.expense_date, + createdAt: h.created_at, + })), + keywords.map((k) => ({ keyword: k.keyword, match: k.match, defaultKey: k.default_key })), + categories.map((c) => ({ id: c.id, defaultKey: c.default_key })), + input.fallback as string | null, + ), + }; + } default: throw new Error(`no web implementation for ${fn}`); } diff --git a/web/src/test/fakeServer.ts b/web/src/test/fakeServer.ts index ff8871e..b9451ce 100644 --- a/web/src/test/fakeServer.ts +++ b/web/src/test/fakeServer.ts @@ -7,9 +7,23 @@ export interface RecordedRequest { path: string; search: string; headers: Headers; + /** + * The request body by its `Content-Type`: parsed for `application/json` (and `+json`), the text for `text/*`, + * the raw bytes otherwise (a picture upload); `undefined` when there is none. + */ body: unknown; } +async function bodyOf(request: Request): Promise { + if (request.method === 'GET' || request.method === 'HEAD') return undefined; + const bytes = new Uint8Array(await request.arrayBuffer()); + if (bytes.length === 0) return undefined; + const type = (request.headers.get('Content-Type') ?? '').split(';')[0]?.trim().toLowerCase() ?? ''; + if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(new TextDecoder().decode(bytes)); + if (type.startsWith('text/')) return new TextDecoder().decode(bytes); + return bytes; +} + export type Handler = (request: RecordedRequest) => Response | Promise; export function json(body: unknown, status = 200, headers: Record = {}): Response { @@ -39,13 +53,12 @@ export class FakeServer { readonly fetch = async (request: Request): Promise => { const url = new URL(request.url); - const text = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text(); const recorded: RecordedRequest = { method: request.method, path: url.pathname, search: url.search, headers: request.headers, - body: text === '' ? undefined : (JSON.parse(text) as unknown), + body: await bodyOf(request), }; this.requests.push(recorded); const handler = this.routes.get(`${request.method} ${url.pathname}`);