Web fix: deterministic feature strings, suggest_category vectors, raw bodies in the fake server
- Root cause of the raw keys (web_accounts_load_failed, the settle missing state) on main: Vitest 5's file-system module cache (on by default) skips only modules whose source contains the literal `import.meta.glob(`. catalog.ts wrote `import.meta.glob<…>(`, so its transform was cached by its own content and kept the glob result from before a feature added its i18n/*.json; the main checkout's node_modules/.vitest-cache served that stale catalog until a config change invalidated it. Fresh worktrees passed. - catalog.ts: glob without a type argument (typed by annotation), with the reason. - catalog.test.ts: the loaded feature catalogs must equal the i18n files on disk. - A string two features render moves to the shared catalog: web_accounts_load_failed (accounts/PageStates, also rendered by Home) is now web_ledger_load_failed in src/i18n/web-strings.csv; sv.json/en.json regenerated. - vectors.test.ts: suggest_category is PORTED and runs against features/expenses/suggest. - fakeServer: bodies by Content-Type (JSON parsed, text/* as text, else raw bytes); ProfilePage's picture test asserts the uploaded bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -34,7 +34,7 @@ export function LoadFailedCard({ error, onRetry }: { error: unknown; onRetry: ()
|
|||||||
<div role="alert" className={styles.failedInner}>
|
<div role="alert" className={styles.failedInner}>
|
||||||
<Icon name="warning" filled size={24} className={styles.warning} />
|
<Icon name="warning" filled size={24} className={styles.warning} />
|
||||||
<div className={styles.failedText}>
|
<div className={styles.failedText}>
|
||||||
<h2 className={styles.failedTitle}>{t('web_accounts_load_failed')}</h2>
|
<h2 className={styles.failedTitle}>{t('web_ledger_load_failed')}</h2>
|
||||||
<p className={styles.muted}>{t(writeErrorKey(error))}</p>
|
<p className={styles.muted}>{t(writeErrorKey(error))}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"web_accounts_load_failed": "Could not get the household's expenses",
|
|
||||||
"web_accounts_split_presets": "Quick choices",
|
"web_accounts_split_presets": "Quick choices",
|
||||||
"web_accounts_saving": "Saving…",
|
"web_accounts_saving": "Saving…",
|
||||||
"web_accounts_not_found_body": "The account may have been deleted on another device."
|
"web_accounts_not_found_body": "The account may have been deleted on another device."
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"web_accounts_load_failed": "Kunde inte hämta hushållets utgifter",
|
|
||||||
"web_accounts_split_presets": "Snabbval",
|
"web_accounts_split_presets": "Snabbval",
|
||||||
"web_accounts_saving": "Sparar…",
|
"web_accounts_saving": "Sparar…",
|
||||||
"web_accounts_not_found_body": "Kontot kan ha tagits bort på en annan enhet."
|
"web_accounts_not_found_body": "Kontot kan ha tagits bort på en annan enhet."
|
||||||
|
|||||||
@@ -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. */
|
/** 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 {
|
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', () => {
|
describe('W-14 Profile', () => {
|
||||||
@@ -72,7 +72,8 @@ describe('W-14 Profile', () => {
|
|||||||
expect(await screen.findByRole('button', { name: 'Byt bild' })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'Byt bild' })).toBeInTheDocument();
|
||||||
const put = server.calls('PUT', '/me/picture')[0];
|
const put = server.calls('PUT', '/me/picture')[0];
|
||||||
expect(put?.headers.get('Content-Type')).toBe('image/jpeg');
|
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' }));
|
await user.click(screen.getByRole('button', { name: 'Ta bort bild' }));
|
||||||
expect(await screen.findByRole('button', { name: 'Lägg till bild' })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'Lägg till bild' })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { featureCatalogs, fill, LOADED_FEATURE_CATALOGS, mergeCatalogs, translate, translatePlural } from './catalog';
|
import { featureCatalogs, fill, LOADED_FEATURE_CATALOGS, mergeCatalogs, translate, translatePlural } from './catalog';
|
||||||
import en from './en.json';
|
import en from './en.json';
|
||||||
@@ -46,6 +48,26 @@ describe('feature catalogs', () => {
|
|||||||
expect(merged).toEqual({ common_save: 'Spara', web_settle_hint: 'Tips' });
|
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', () => {
|
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)) {
|
for (const [feature, catalogs] of Object.entries(LOADED_FEATURE_CATALOGS)) {
|
||||||
const svKeys = Object.keys(catalogs.sv ?? {}).sort();
|
const svKeys = Object.keys(catalogs.sv ?? {}).sort();
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export function mergeCatalogs(base: Catalog, features: readonly (Catalog | undef
|
|||||||
return merged;
|
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<Language, Catalog> = {
|
const CATALOGS: Record<Language, Catalog> = {
|
||||||
sv: mergeCatalogs(
|
sv: mergeCatalogs(
|
||||||
|
|||||||
@@ -595,6 +595,7 @@
|
|||||||
"web_language_auto": "Browser language",
|
"web_language_auto": "Browser language",
|
||||||
"web_language_en": "English",
|
"web_language_en": "English",
|
||||||
"web_language_sv": "Svenska",
|
"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_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_name_needed": "This code adds a new person to the household. Enter your name.",
|
||||||
"web_login_server": "Server",
|
"web_login_server": "Server",
|
||||||
|
|||||||
@@ -595,6 +595,7 @@
|
|||||||
"web_language_auto": "Webbläsarens språk",
|
"web_language_auto": "Webbläsarens språk",
|
||||||
"web_language_en": "English",
|
"web_language_en": "English",
|
||||||
"web_language_sv": "Svenska",
|
"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_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_name_needed": "Koden lägger till en ny person i hushållet. Skriv ditt namn.",
|
||||||
"web_login_server": "Server",
|
"web_login_server": "Server",
|
||||||
|
|||||||
@@ -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_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_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_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)
|
||||||
|
|||||||
|
@@ -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,
|
* 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;
|
* 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 { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { suggestCategory, type KeywordMatch } from '../features/expenses/suggest/suggest';
|
||||||
import { computeBalances, type BalanceExpense, type BalanceSettlement } from './balances';
|
import { computeBalances, type BalanceExpense, type BalanceSettlement } from './balances';
|
||||||
import {
|
import {
|
||||||
overSettledAccounts,
|
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. */
|
/** Tests run from web/ (npm scripts), so the repository's contracts are one level up. */
|
||||||
const DIR = resolve(process.cwd(), '..', 'contracts', 'test-vectors');
|
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. */
|
/** 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 = [
|
const PORTED = [
|
||||||
'compute_split',
|
'compute_split',
|
||||||
'compute_balances',
|
'compute_balances',
|
||||||
'propose_allocation',
|
'propose_allocation',
|
||||||
'validate_settlement',
|
'validate_settlement',
|
||||||
'over_settled_accounts',
|
'over_settled_accounts',
|
||||||
|
'suggest_category', // W2: features/expenses/suggest
|
||||||
];
|
];
|
||||||
|
|
||||||
interface VectorCase {
|
interface VectorCase {
|
||||||
@@ -111,6 +113,30 @@ function run(fn: string, input: Record<string, unknown>): unknown {
|
|||||||
}
|
}
|
||||||
case 'over_settled_accounts':
|
case 'over_settled_accounts':
|
||||||
return { ok: overSettledAccounts(allocationsOf(input.allocations), positionsOf(input.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:
|
default:
|
||||||
throw new Error(`no web implementation for ${fn}`);
|
throw new Error(`no web implementation for ${fn}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,23 @@ export interface RecordedRequest {
|
|||||||
path: string;
|
path: string;
|
||||||
search: string;
|
search: string;
|
||||||
headers: Headers;
|
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;
|
body: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bodyOf(request: Request): Promise<unknown> {
|
||||||
|
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<Response>;
|
export type Handler = (request: RecordedRequest) => Response | Promise<Response>;
|
||||||
|
|
||||||
export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
@@ -39,13 +53,12 @@ export class FakeServer {
|
|||||||
|
|
||||||
readonly fetch = async (request: Request): Promise<Response> => {
|
readonly fetch = async (request: Request): Promise<Response> => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const text = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
|
|
||||||
const recorded: RecordedRequest = {
|
const recorded: RecordedRequest = {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
search: url.search,
|
search: url.search,
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
body: text === '' ? undefined : (JSON.parse(text) as unknown),
|
body: await bodyOf(request),
|
||||||
};
|
};
|
||||||
this.requests.push(recorded);
|
this.requests.push(recorded);
|
||||||
const handler = this.routes.get(`${request.method} ${url.pathname}`);
|
const handler = this.routes.get(`${request.method} ${url.pathname}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user