From e05dcfbc25eda6b707b0fe72d39a3aba9a10adff Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 10:23:54 +0000 Subject: [PATCH] zh-CN demo dataset: a fully Chinese Ardenline for Chinese-locale demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese UI has been done for a while — nav, columns, statuses, progress phrases and form help all render in zh-CN. Every RECORD underneath it was still English, so a Chinese evaluator saw a Chinese frame around English content, which reads as "not localised" however good the chrome is. `DULY_DEMO_LOCALE` (unset = `en`, `zh-CN`) now chooses the fixture's language, read at compile time exactly as `DULY_DEMO_SEED` is and for the same reason — the seed is baked into `dist/objectstack.json`. `pnpm demo:zh` is `pnpm demo` with it set, and `scripts/demo.mjs` passes it to BOTH boots. One fixture, two languages, not two fixtures. The arrays stay authored in English and are mapped through `t()` once per section; `src/data/demo-zh.ts` holds every human-readable string keyed by its English original. That keeps one org chart, one history planner and one set of invariants — a parallel zh fixture would drift, and a Chinese demo with 19 catalog items where the English one has 20 is not a bug anybody spots on a screen. It also makes "is it translated?" a set comparison a test can make, in both directions. Machine values are deliberately untouched: unit codes, period keys, select values, timezones and frequencies are identical in both locales. Mailboxes stay ASCII pinyin on the reserved `.example` domain while display names are Chinese. Position codes are now readable in both languages — `Plant compliance officer` / `厂区合规专员` rather than `plant_compliance_officer`. The column is free text the app never parsed; it was only ever being read by people. The admin persona is MEASURED, not assumed (`@objectstack/*` 17.2.0): the priming boot PATCHes `sys_user.name` to `演示管理员` before the seed runs, signing in with the same credentials afterwards still returns 200, and the seed replays as a no-op against the renamed row. It fails loudly if the rename does not land, because the alternative is a fourteenth user owning the demo account's work and four empty screens on first boot. Tests: `test/seed-locale.test.ts` boots a kernel per locale and asserts equal row counts, equal status/caliber/period distributions, a Han character in every zh-CN display string, ASCII mailboxes, invented-only references, and the English names and subjects pinned literally. `test/demo-locale.test.ts` pins the switch and holds the dictionary to the fixture in both directions — which is what stops this card quietly rewording the English demo. `test/demo-script.test.ts` holds `scripts/demo.mjs` to the fixture's `ADMIN`. Closes #117 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- README.md | 14 +- package.json | 1 + scripts/demo.mjs | 196 ++++++++++++++++- src/data/demo-assignments.ts | 43 ++-- src/data/demo-catalog.ts | 60 ++++- src/data/demo-history.ts | 35 ++- src/data/demo-locale.ts | 150 +++++++++++++ src/data/demo-org.ts | 131 ++++++++++- src/data/demo-zh.ts | 312 ++++++++++++++++++++++++++ src/data/index.ts | 21 ++ src/data/log-entry.seed.ts | 23 +- src/data/org.seed.ts | 7 +- test/demo-locale.test.ts | 122 +++++++++++ test/demo-script.test.ts | 92 ++++++++ test/seed-locale.test.ts | 413 +++++++++++++++++++++++++++++++++++ 15 files changed, 1566 insertions(+), 54 deletions(-) create mode 100644 src/data/demo-locale.ts create mode 100644 src/data/demo-zh.ts create mode 100644 test/demo-locale.test.ts create mode 100644 test/demo-script.test.ts create mode 100644 test/seed-locale.test.ts diff --git a/README.md b/README.md index aa75a2a..533a838 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,21 @@ idea* are different things, so they are different commands: |:---|:---| | `pnpm dev` | An **empty Duly**. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from. | | `pnpm demo` | The same app **preloaded with a worked example**: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen. | +| `pnpm demo:zh` | The same worked example **in Chinese** — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match. | `pnpm demo` prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it. +The two demos are the **same fixture in two languages**, not two datasets: +one org chart, one duty catalog, one history planner, with the display strings +resolved through `src/data/demo-zh.ts`. Machine values — unit codes, period +keys, statuses, timezones — are identical in both, which is what keeps a +Chinese demo from being a second demo that quietly drifts. The language is +chosen at compile time by `DULY_DEMO_LOCALE`, so switch between them on a +database you have already seeded and you will get **both** organisations in +it; `rm -rf .objectstack/data` first. + To go back to an empty app, delete the local database and start again: ```bash @@ -68,7 +78,9 @@ pnpm dev Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is -named anywhere in it. +named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, +and every reference the catalog cites is an invented internal document +(《集团环境标准 GE-09》第1条), never a national or industry standard. Every metadata directory is pre-wired into `objectstack.config.ts`, empty ones included: add your entry to the named array in your own `src//index.ts` and diff --git a/package.json b/package.json index 4a8fde1..4dc05fe 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "objectstack dev --compile", "demo": "node scripts/demo.mjs", + "demo:zh": "DULY_DEMO_LOCALE=zh-CN node scripts/demo.mjs", "start": "objectstack start", "build": "objectstack build", "validate": "objectstack validate", diff --git a/scripts/demo.mjs b/scripts/demo.mjs index f183174..bf51c5a 100755 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -29,6 +29,15 @@ // Filed upstream as objectstack-ai/objectstack#14157. When that lands, the // priming boot below is deleted and this file becomes one spawn. // +// ── The language the demo is written in ──────────────────────────────────── +// +// `DULY_DEMO_LOCALE` (unset = English, `zh-CN` = Chinese) chooses the fixture's +// language, and `pnpm demo:zh` is `pnpm demo` with it set. It reaches BOTH +// boots below — the priming one because it decides the admin account's name +// (see `renameAdminAccount`) and because an unspellable value should be +// refused before anything is written, and the demo one because the fixture is +// baked into the artifact that boot compiles. +// // ── Why both boots pass `--compile` ──────────────────────────────────────── // // The seed is baked into `dist/objectstack.json` at compile time, and `os dev` @@ -42,6 +51,39 @@ import { spawn } from 'node:child_process'; import { createServer } from 'node:net'; const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; +const DEMO_LOCALE_ENV_VAR = 'DULY_DEMO_LOCALE'; + +/** + * `sys_user.name` the account you log in as carries, per locale. + * + * ⚠️ This MIRRORS `ADMIN` in `src/data/demo-org.ts`, which is what the seed + * matches on. They have to agree, and they cannot be one constant: this file + * is plain `.mjs` that runs before anything is compiled, and that one is + * TypeScript baked into the artifact. `test/demo-script.test.ts` reads this + * file and holds the two together, because the failure when they drift is + * silent — see `renameAdminAccount` below for what it looks like. + * + * The spellings are the ones `src/data/demo-locale.ts` accepts, normalised the + * same way (trimmed, lowercased). + */ +const ADMIN_NAME_BY_LOCALE = new Map([ + ['', 'Dev Admin'], + ['en', 'Dev Admin'], + ['en-us', 'Dev Admin'], + ['en-gb', 'Dev Admin'], + ['zh', '演示管理员'], + ['zh-cn', '演示管理员'], +]); + +/** + * What this run's fixture will call the admin. + * + * An unrecognised spelling falls back to the English name here and is REFUSED + * a moment later by `src/data/demo-locale.ts`, which is the right division of + * labour: this script does not get a second opinion about what a locale is. + */ +const ADMIN_NAME = + ADMIN_NAME_BY_LOCALE.get((process.env[DEMO_LOCALE_ENV_VAR] ?? '').trim().toLowerCase()) ?? 'Dev Admin'; // The same credentials and the same env overrides `@objectstack/plugin-auth` // itself reads, so an operator who has changed them is not silently probing @@ -84,8 +126,12 @@ const freePort = () => * asserted. `localhost` (not `127.0.0.1`) with a matching `Origin`: dev trusts * `http://localhost:*`, and better-auth rejects anything else with 403 * INVALID_ORIGIN. + * + * Returns the signed-in session — id, display name and session cookie — or + * `null` if the account is not loginable yet. The extra detail is what the + * rename below needs; the probe itself is unchanged. */ -const canSignIn = async (port) => { +const signIn = async (port) => { const origin = `http://localhost:${port}`; try { const response = await fetch(`${origin}/api/v1/auth/sign-in/email`, { @@ -94,11 +140,110 @@ const canSignIn = async (port) => { body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), signal: AbortSignal.timeout(5_000), }); - return response.status === 200; + if (response.status !== 200) return null; + const raw = response.headers.getSetCookie?.()?.[0] ?? response.headers.get('set-cookie') ?? ''; + const body = await response.json().catch(() => ({})); + const user = body?.user ?? {}; + return { + port, + origin, + // Just the `name=value` pair; the attributes are the browser's business. + cookie: String(raw).split(';')[0], + id: typeof user.id === 'string' ? user.id : null, + name: typeof user.name === 'string' ? user.name : null, + }; } catch { // Not up yet, or up and not answering. Either way: not ready. - return false; + return null; + } +}; + +/** + * Give the account you log in as a name in the demo's own language. + * + * ── Why this is here and not in the seed ───────────────────────────────── + * `@objectstack/plugin-auth` mints the dev admin and its `sys_user.name` is + * not configurable — only `OS_SEED_ADMIN_EMAIL` / `OS_SEED_ADMIN_PASSWORD` + * exist. The seed cannot write it either: the `sys_user` row for the admin + * carries its natural key and NOTHING else on purpose, because anything more + * would turn the loader's no-op skip into an UPDATE against a live + * credential-bearing account (see `src/data/demo-org.ts`). So the only place + * the rename can happen is here — in the priming step, against a database + * that holds exactly one user, BEFORE the seed runs and has to match it. + * + * ── What goes wrong if it silently does not happen ─────────────────────── + * The seed's `sys_user` dataset is keyed on `name`. With the fixture in + * Chinese it declares `演示管理员`; if the live account is still `Dev Admin`, + * the loader matches nothing, INSERTS a fourteenth user, and hands every one + * of the demo account's duties, tasks, assignment and log entries to a person + * nobody can log in as. The app comes up, the seed reports success, and My + * week, My duties, Sent by me and Work log are all empty on the screen the + * evaluator lands on. That is why this fails loudly rather than warning. + * + * Measured on `@objectstack/*` 17.2.0 (2026-09-02): the PATCH returns 200, + * signing in with the same credentials afterwards returns 200 and reports the + * new name, and the seed then replays as a no-op against the renamed row. + * Idempotent — a second `pnpm demo:zh` on the same database finds the name + * already right and does nothing. + */ +const renameAdminAccount = async (session) => { + if (session.name === ADMIN_NAME) return { ok: true }; + if (!session.id) { + return { + ok: false, + headline: 'the admin account could not be identified, so it was not renamed.', + detail: ['Signing in succeeded but returned no user id, so nothing was seeded.'], + }; } + + const response = await fetch(`${session.origin}/api/v1/data/sys_user/${session.id}`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + origin: session.origin, + cookie: session.cookie, + }, + body: JSON.stringify({ name: ADMIN_NAME }), + signal: AbortSignal.timeout(15_000), + }).catch((error) => ({ ok: false, status: 0, error })); + + if (!response.ok) { + return { + ok: false, + headline: `the admin account could not be renamed to ${JSON.stringify(ADMIN_NAME)}, so the demo was NOT loaded.`, + detail: [ + `PATCH /api/v1/data/sys_user/${session.id} answered ${response.status || 'no response'}.`, + '', + `The ${DEMO_LOCALE_ENV_VAR} fixture expects the account you log in as to be`, + `named ${JSON.stringify(ADMIN_NAME)}. Seeding it against an account still named`, + `${JSON.stringify(session.name)} would create a second user and leave every`, + '"my own work" screen empty, so nothing was seeded — the database is as it was.', + '', + 'Run `pnpm demo` for the English demo instead.', + ], + }; + } + + // The same discipline as the probe above: assert the handover rather than + // assume it. A rename that broke the login would be the worst outcome + // available here — a seeded database nobody can get into — and it is + // exactly the kind of thing that is fine until an auth provider starts + // treating `name` as part of the credential. + const after = await signIn(session.port); + if (!after || after.name !== ADMIN_NAME) { + return { + ok: false, + headline: 'the admin account could not sign in after being renamed, so the demo was NOT loaded.', + detail: [ + after + ? `Signed in, but the account is named ${JSON.stringify(after.name)} rather than ${JSON.stringify(ADMIN_NAME)}.` + : `\`${ADMIN_EMAIL}\` no longer signs in after the rename.`, + '', + 'Nothing was seeded by this run — the database is exactly as it was.', + ], + }; + } + return { ok: true, renamed: true }; }; const fail = (headline, detail, log) => { @@ -121,12 +266,24 @@ const fail = (headline, detail, log) => { * * Idempotent: on a database that already has an account this boot mints * nothing, the first probe succeeds, and it costs one short boot. + * + * Returns whether it had to rename the account for this run's locale, which is + * the one thing worth saying out loud in the output. */ const primeAdminAccount = async () => { const port = await freePort(); // Deleted rather than set to a falsy string: this must be off regardless of // how the gate spells "off", and regardless of what the operator exported. + // + // ⚠️ ONLY the seed gate is deleted. `DULY_DEMO_LOCALE` is passed straight + // through, and both halves of that matter. It reaches the compile, so an + // unspellable locale is refused HERE — in the quiet boot, before anything + // has been written — rather than after the priming step has reported + // success. And it must not be deleted "for symmetry": this boot is what + // decides the admin account's name for the run, so a priming step that could + // not see the locale would rename the account for a language it did not know + // it was preparing. const env = { ...process.env }; delete env[DEMO_SEED_ENV_VAR]; @@ -189,9 +346,17 @@ const primeAdminAccount = async () => { log, ); } - if (await canSignIn(port)) { + const session = await signIn(port); + if (session) { + // Still inside the priming boot, and deliberately so: the rename has to + // land BEFORE the seed's `sys_user` dataset tries to match on the name. + const rename = await renameAdminAccount(session); + // Stop the priming server FIRST either way: it is detached and holding + // the database, so exiting around it would orphan a server nobody can + // see and leave the port and the database locked. await stop(); - return; + if (!rename.ok) fail(rename.headline, rename.detail, log); + return rename.renamed === true; } await sleep(1_000); } @@ -218,7 +383,18 @@ const startDemo = () => { // Extra arguments are forwarded, so `pnpm demo -- --port 4000` works. const passthrough = process.argv.slice(2); const child = spawn('objectstack', ['dev', '--compile', ...passthrough], { - env: { ...process.env, [DEMO_SEED_ENV_VAR]: '1' }, + // The seed gate is turned ON for this boot; the locale is inherited and + // stated explicitly beside it, so the two variables the compiled artifact + // depends on are both visible at the one place that decides them. Both + // boots pass `--compile`, so this artifact is built for this locale — see + // the header for why reusing the previous one is the bug that costs. + env: { + ...process.env, + [DEMO_SEED_ENV_VAR]: '1', + ...(process.env[DEMO_LOCALE_ENV_VAR] === undefined + ? {} + : { [DEMO_LOCALE_ENV_VAR]: process.env[DEMO_LOCALE_ENV_VAR] }), + }, // Inherited, and NOT detached: the demo server shares this terminal's // process group so Ctrl+C reaches it the way it would `pnpm dev`. stdio: 'inherit', @@ -235,8 +411,12 @@ console.log(''); console.log(' Duly demo — two steps, then the server is yours.'); console.log(''); console.log(' 1/2 preparing an admin account (quiet, a few seconds)…'); -await primeAdminAccount(); -console.log(' 1/2 done — admin account ready.'); +const renamed = await primeAdminAccount(); +console.log( + renamed + ? ` 1/2 done — admin account ready, renamed to ${ADMIN_NAME} for this locale.` + : ' 1/2 done — admin account ready.', +); console.log(' 2/2 starting Duly with the demo organisation loaded…'); console.log(''); startDemo(); diff --git a/src/data/demo-assignments.ts b/src/data/demo-assignments.ts index 4a0c772..655c15e 100644 --- a/src/data/demo-assignments.ts +++ b/src/data/demo-assignments.ts @@ -2,6 +2,7 @@ import { visibleFromFor } from '../functions/period.js'; +import { t } from './demo-locale.js'; import { ADMIN } from './demo-org.js'; import { NOW, TODAY } from './demo-history.js'; @@ -54,24 +55,26 @@ export interface DemoAssignment { export const ASSIGNMENTS: readonly DemoAssignment[] = [ { - subject: 'Winter shutdown readiness check', - description: + subject: t('Winter shutdown readiness check'), + description: t( 'Before the shutdown window opens, confirm your area is ready: isolations listed, spares on site, contractors booked. One line per point — no report.', + ), // Assigned BY the account an evaluator is logged in as, so "Sent by me" is - // not an empty screen on first boot. + // not an empty screen on first boot. `ADMIN` already follows the locale. assigner: ADMIN, - assignees: ['Marek Dvorak', 'Sami Okonkwo', 'Yuki Tanabe', 'Rosa Delgado'], + assignees: ['Marek Dvorak', 'Sami Okonkwo', 'Yuki Tanabe', 'Rosa Delgado'].map(t), dueDate: inDays(21), // The assigner gets NO task of their own. That is the product rule: a // manager who hands out work does not inherit a to-do list from it. needsCollection: false, }, { - subject: 'Q3 supplier certificate sweep', - description: + subject: t('Q3 supplier certificate sweep'), + description: t( 'Pull the current certificate for every approved supplier you buy from and flag any that expired during the quarter.', - assigner: 'Priya Raman', - assignees: ['Rosa Delgado', 'Ibrahim Chaudhry'], + ), + assigner: t('Priya Raman'), + assignees: ['Rosa Delgado', 'Ibrahim Chaudhry'].map(t), dueDate: inDays(10), // The other half of the rule: ticking this — and only ticking this — is // what gives the assigner a follow-up task once everyone is in. @@ -110,7 +113,7 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ // ── Winter shutdown readiness check — four people, mixed ─────────────── { subject: readiness.subject, - owner: 'Marek Dvorak', + owner: t('Marek Dvorak'), assignment: readiness.subject, duty: null, source: 'assigned', @@ -119,11 +122,11 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ visibleFrom: readiness.dueDate, completedAt: daysAgo(4), lastUpdateAt: daysAgo(4), - note: 'Isolations listed and countersigned. Spares are on site bar the two long-lead seals.', + note: t('Isolations listed and countersigned. Spares are on site bar the two long-lead seals.'), }, { subject: readiness.subject, - owner: 'Sami Okonkwo', + owner: t('Sami Okonkwo'), assignment: readiness.subject, duty: null, source: 'assigned', @@ -135,7 +138,7 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ }, { subject: readiness.subject, - owner: 'Yuki Tanabe', + owner: t('Yuki Tanabe'), assignment: readiness.subject, duty: null, source: 'assigned', @@ -143,11 +146,11 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ dueDate: readiness.dueDate, visibleFrom: readiness.dueDate, lastUpdateAt: daysAgo(1), - note: 'Contractor slot still to be confirmed for the Line C isolation.', + note: t('Contractor slot still to be confirmed for the Line C isolation.'), }, { subject: readiness.subject, - owner: 'Rosa Delgado', + owner: t('Rosa Delgado'), assignment: readiness.subject, duty: null, source: 'assigned', @@ -160,7 +163,7 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ // ── Q3 supplier certificate sweep — two people, plus the assigner ────── { subject: sweep.subject, - owner: 'Rosa Delgado', + owner: t('Rosa Delgado'), assignment: sweep.subject, duty: null, source: 'assigned', @@ -171,7 +174,7 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ }, { subject: sweep.subject, - owner: 'Ibrahim Chaudhry', + owner: t('Ibrahim Chaudhry'), assignment: sweep.subject, duty: null, source: 'assigned', @@ -198,10 +201,10 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ { // `subject` is copied from the duty at dispatch, exactly as // `dispatch.plan.ts` does it, so renaming the duty never rewrites history. - subject: 'Commissioning file handover — Riverside upgrade', - owner: 'Owen Pryce', + subject: t('Commissioning file handover — Riverside upgrade'), + owner: t('Owen Pryce'), assignment: null, - duty: 'Commissioning file handover — Riverside upgrade', + duty: t('Commissioning file handover — Riverside upgrade'), source: 'catalog', status: 'in_progress', // A one-off carries a due date set directly rather than derived from a @@ -210,6 +213,6 @@ export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ dueDate: inDays(12), visibleFrom: inDays(-5), lastUpdateAt: daysAgo(2), - note: 'As-builts and test records in; waiting on the spares list from the supplier.', + note: t('As-builts and test records in; waiting on the spares list from the supplier.'), }, ]; diff --git a/src/data/demo-catalog.ts b/src/data/demo-catalog.ts index a24c221..7004e77 100644 --- a/src/data/demo-catalog.ts +++ b/src/data/demo-catalog.ts @@ -2,7 +2,8 @@ import type { Frequency } from '../functions/period.js'; -import { ADMIN, POSITIONS } from './demo-org.js'; +import { t } from './demo-locale.js'; +import { ADMIN, POSITIONS, personOf } from './demo-org.js'; /** * The role catalog, and the duties instantiated from it. @@ -59,7 +60,7 @@ const { compliance, supervisor, technician } = POSITIONS; * is filled on all but one — it is what makes the catalog read as an audit * answer rather than a to-do list. */ -export const CATALOG_ITEMS: readonly DemoCatalogItem[] = [ +const CATALOG_ITEMS_EN: readonly DemoCatalogItem[] = [ // ── Plant compliance officer ────────────────────────────────────────── { name: 'Emissions return', @@ -314,6 +315,28 @@ export const CATALOG_ITEMS: readonly DemoCatalogItem[] = [ }, ]; +/** + * The catalog in this compile's language. + * + * `name`, `description` and `reference` are prose; `position` is already + * localised by {@link POSITIONS}. Everything else — form, frequency, anchors, + * offsets, lead and grace days, `active` — is machine data and is copied + * through untouched, which is what makes the two locales the same catalog + * rather than two catalogs that happen to be the same length. + * + * `reference` is spread in only when the item has one, rather than being set + * to `undefined`, so the one item that deliberately cites no clause (the drift + * check) still cites none. Same rule as {@link cadenceOf} below and for the + * same reason: an explicit `undefined` is still an own property — the loader's + * no-op-replay check compares it, churning the row on every boot. + */ +export const CATALOG_ITEMS: readonly DemoCatalogItem[] = CATALOG_ITEMS_EN.map((item) => ({ + ...item, + name: t(item.name), + description: t(item.description), + ...(item.reference === undefined ? {} : { reference: t(item.reference) }), +})); + const ITEM_BY_NAME = new Map(CATALOG_ITEMS.map((i) => [i.name, i])); export const catalogItem = (name: string): DemoCatalogItem => { @@ -407,7 +430,7 @@ export interface DemoDuty { * would land unscored, and every dashboard measure would read zero — with no * error anywhere, because an unscored duty is a perfectly legal thing to be. */ -export const DUTIES: readonly DemoDuty[] = [ +const DUTIES_EN: readonly DemoDuty[] = [ // ── The account `objectstack dev` logs you in as ────────────────────── // Deliberately given a real week: a monthly pair that keeps My week // populated, a quarter that has already run three times, the semi-annual @@ -506,3 +529,34 @@ export const DUTIES: readonly DemoDuty[] = [ // `task.seed.ts` alongside the assignment fan-out. { name: 'Commissioning file handover — Riverside upgrade', item: 'Commissioning file handover', owner: 'Owen Pryce', source: 'catalog' }, ]; + +/** + * The duties in this compile's language. + * + * Four of the five strings on a row are natural keys pointing somewhere else — + * `item` at a catalog item, `owner` at a `sys_user`, and `name` at the duty + * every seeded task references back — so they are translated by the same map + * that produced the rows they point at. `owner` goes through `personOf` rather + * than `t` because six of these rows are owned by `ADMIN`, whose name is + * already in this compile's language and is not a dictionary entry. `reviewNote` and the self-declared + * `own.description` are prose the record page shows. + * + * `status`, `review` and `source` are select values, not prose: the zh-CN + * translations bundle renders them, and translating the stored value here + * would break every filter and dataset that names one. + */ +export const DUTIES: readonly DemoDuty[] = DUTIES_EN.map((duty) => ({ + ...duty, + name: t(duty.name), + item: duty.item === null ? null : t(duty.item), + owner: personOf(duty.owner), + ...(duty.reviewNote === undefined ? {} : { reviewNote: t(duty.reviewNote) }), + ...(duty.own === undefined + ? {} + : { + own: { + ...duty.own, + ...(duty.own.description === undefined ? {} : { description: t(duty.own.description) }), + }, + }), +})); diff --git a/src/data/demo-history.ts b/src/data/demo-history.ts index b99229d..b73011f 100644 --- a/src/data/demo-history.ts +++ b/src/data/demo-history.ts @@ -3,6 +3,7 @@ import { periodBounds, periodKeyFor, visibleFromFor } from '../functions/period.js'; import { planDispatch, type DispatchDuty, type DutySkip, type TaskDraft } from '../jobs/dispatch.plan.js'; +import { t } from './demo-locale.js'; import { timezoneOf, unitOf } from './demo-org.js'; import { DUTIES, cadenceOf, catalogItem, type DemoCatalogItem } from './demo-catalog.js'; @@ -173,6 +174,17 @@ const iso = (instant: Date): string => new Date(Math.min(instant.getTime(), NOW. * oldest one"), never by date, so they survive the calendar moving under them. */ +/** + * The tables below address an occurrence by DUTY NAME, and a duty name is a + * display string — so in a non-English compile the keys have to be translated + * with the duties they name, or every lookup here misses and the fixture + * quietly loses its late rows, its stalled rows, its skip and its + * cancellation. Written as one helper rather than a `t()` per key so a new + * entry cannot be added without it. + */ +const byDutyName = (table: Readonly>): Readonly> => + Object.fromEntries(Object.entries(table).map(([duty, value]) => [t(duty), value])); + /** * Still open, and past due. The **Late** view. * @@ -182,19 +194,19 @@ const iso = (instant: Date): string => new Date(Math.min(instant.getTime(), NOW. * lateness reports a failure that has already happened, stagnation catches one * that has not. */ -const LATE_MOST_RECENT: Readonly> = { +const LATE_MOST_RECENT: Readonly> = byDutyName({ 'Emissions return — Northgate': 'open', 'Toolbox talk record — Line B': 'in_progress', 'Line safety walk — Riverside': 'open', // The overlap: late AND untouched since the day it was dispatched. 'Calibration verification — Lab 1': 'open', -}; +}); /** How long ago each actively-chased late row was last touched. */ const CHASED_DAYS_AGO = [2, 6, 10] as const; /** Untouched since dispatch as well as late — the fourth Late row above. */ -const STALLED_LATE = 'Calibration verification — Lab 1'; +const STALLED_LATE = t('Calibration verification — Lab 1'); /** * Open, NOT yet due, and untouched since dispatch. The **Not moving** view @@ -208,21 +220,21 @@ const STALLED_LATE = 'Calibration verification — Lab 1'; const STALLED_IN_FLIGHT: readonly string[] = [ 'Site environmental audit — Northgate', 'Contractor induction refresh — Northgate', -]; +].map(t); /** One skipped occurrence, with a reason that is an answer rather than "n/a". */ -const SKIPPED_MOST_RECENT = 'Line safety walk — Line A'; -const SKIP_REASON = 'Line A was down for the rebuild for the whole period — there was no line to walk.'; +const SKIPPED_MOST_RECENT = t('Line safety walk — Line A'); +const SKIP_REASON = t('Line A was down for the rebuild for the whole period — there was no line to walk.'); /** One withdrawn occurrence. Cancelled work was never owed, so no measure counts it. */ -const CANCELLED_OLDEST = 'Retained sample review — Lab 1'; +const CANCELLED_OLDEST = t('Retained sample review — Lab 1'); /** A few in-flight tasks somebody has actually started. */ const IN_PROGRESS_IN_FLIGHT: readonly string[] = [ 'Permit condition review — Northgate', 'Nonconformance log review — Northgate Quality', 'Shift handover record — Line A', -]; +].map(t); /** * The progress phrase each in-flight owner has reported (#108). @@ -249,7 +261,7 @@ const IN_FLIGHT_PROGRESS = ['in_hand', 'distributed', 'awaiting_feedback'] as co const CHASED_PROGRESS = 'awaiting_feedback' as const; /** Notes, so a record detail view is not a wall of empty fields. */ -const NOTES: Readonly> = { +const NOTES_EN: Readonly> = { 'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.', 'Calibration verification — Lab 1': 'Waiting on the reference standard to come back from the calibration house.', 'Site environmental audit — Northgate': 'Booked for the week of the shutdown so the lines are cold.', @@ -257,6 +269,11 @@ const NOTES: Readonly> = { 'Contractor induction refresh — Northgate': 'Pass list pulled from the gatehouse; fourteen to chase.', }; +/** Both halves translated: the key is a duty name, the value is prose on screen. */ +const NOTES: Readonly> = Object.fromEntries( + Object.entries(NOTES_EN).map(([duty, note]) => [t(duty), t(note)]), +); + export interface SeededTask extends Omit { status: 'open' | 'in_progress' | 'done' | 'skipped' | 'cancelled'; completed_at?: string; diff --git a/src/data/demo-locale.ts b/src/data/demo-locale.ts new file mode 100644 index 0000000..a856133 --- /dev/null +++ b/src/data/demo-locale.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The demo fixture's LANGUAGE switch — `DULY_DEMO_LOCALE`. + * + * The Chinese UI has been done for a while: nav, columns, statuses, progress + * phrases and form help all render in zh-CN. Every *record* underneath it was + * still English, so a Chinese evaluator saw a Chinese frame around English + * content — which reads as "not localised" however good the chrome is. This + * switch is what makes the records follow. + * + * ── Two variables, two different questions ──────────────────────────────── + * `DULY_DEMO_SEED` (src/data/index.ts) decides WHETHER there is a demo at all. + * This one decides WHICH LANGUAGE that demo is written in. They are read the + * same way and at the same moment — see the compile-time note below — but they + * are independent: `DULY_DEMO_LOCALE=zh-CN pnpm dev` is a perfectly coherent + * request for an empty app, and it seeds nothing. + * + * ── Read at COMPILE time, exactly like the seed gate ────────────────────── + * The seed is baked into `dist/objectstack.json`, so both variables are + * evaluated when the artifact is compiled rather than when the server starts, + * and `os dev` reuses an existing artifact rather than recompiling it. That is + * why `pnpm dev`, `pnpm demo` and `pnpm demo:zh` all pass `--compile`: every + * boot's artifact matches the variables that boot was started with. Without + * it, `pnpm demo` followed by `pnpm demo:zh` would serve the English artifact + * the previous run built and the switch would look broken. + * + * ── An unrecognised value is a HARD ERROR, not a fallback to English ────── + * `DULY_DEMO_LOCALE=zh_CN` (underscore) or `zh-cn-hans` is a typo, and the + * failure mode of a silent fallback is the worst one available here: the demo + * comes up in English, everything works, and nobody finds out until it is on a + * screen in front of a customer. So the spellings below are accepted, and + * anything else stops the compile with a message naming what it takes. Unset + * is not a typo — that is the default, and the default is English. + */ + +import { ZH_CN } from './demo-zh.js'; + +/** The environment variable that chooses the demo fixture's language. */ +export const DEMO_LOCALE_ENV_VAR = 'DULY_DEMO_LOCALE'; + +export type DemoLocale = 'en' | 'zh-CN'; + +// The one Node global this app reads. `@types/node` is deliberately not a +// dependency of a metadata package, so the single property the switch needs is +// declared narrowly and locally rather than pulling the whole Node type +// surface in for it. Module-scoped, so it shadows nothing globally. Same +// idiom, and the same reason, as the seed gate in `index.ts`. +declare const process: { env: Record }; + +/** + * The spellings each locale answers to. + * + * Case is normalised before the lookup, so `ZH-CN` and `zh-CN` are the same + * request. Everything else is refused rather than guessed at. + */ +const SPELLINGS = new Map([ + ['', 'en'], + ['en', 'en'], + ['en-us', 'en'], + ['en-gb', 'en'], + ['zh', 'zh-CN'], + ['zh-cn', 'zh-CN'], +]); + +/** Which language this compile was asked for. Unset means English. */ +export const demoLocale = (): DemoLocale => { + const raw = (process.env[DEMO_LOCALE_ENV_VAR] ?? '').trim(); + // A Map rather than an object literal: a plain object would answer + // `DULY_DEMO_LOCALE=constructor` with something off `Object.prototype`, and + // the whole point of this function is that only the listed spellings pass. + const locale = SPELLINGS.get(raw.toLowerCase()); + if (locale === undefined) { + throw new Error( + `${DEMO_LOCALE_ENV_VAR}=${JSON.stringify(raw)} is not a demo locale. ` + + `Use one of: ${[...SPELLINGS.keys()].filter(Boolean).join(', ')} — or leave it unset for English.`, + ); + } + return locale; +}; + +/** + * The language this module graph was compiled for. + * + * Read ONCE, here, so every fixture module agrees. A second read elsewhere + * would be a second answer the day the environment changed under a long-lived + * process, and the fixture's whole determinism argument rests on one clock and + * one language per compile. + */ +export const DEMO_LOCALE: DemoLocale = demoLocale(); + +// ───────────────────────────────────────────────────────────────────────── +// Translating a fixture string +// ───────────────────────────────────────────────────────────────────────── + +/** + * Every English string the fixture has asked for, in the order it asked. + * + * Recorded in BOTH locales — including English, where {@link t} does no + * lookup at all — because it is what lets `test/seed-locale.test.ts` compare + * the fixture's demand against {@link ZH_CN}'s supply in one set operation, + * from an ordinary English test run. The two directions catch different + * things: a string with no entry is a line somebody forgot to translate; an + * entry nothing asks for is an English line that has been reworded since it + * was translated (or a translation for a row that no longer exists). + */ +const REQUESTED = new Set(); + +/** + * {@link ZH_CN} as a Map, for the same reason {@link SPELLINGS} is one: a + * plain-object lookup answers `'constructor'` and `'toString'` off + * `Object.prototype`, and a lookup that can return a function where a string + * is expected is not a lookup that can be trusted to say "missing". + */ +const ZH_BY_SOURCE = new Map(Object.entries(ZH_CN)); + +/** The English strings {@link t} has been given, sorted. */ +export const requestedSourceStrings = (): readonly string[] => [...REQUESTED].sort(); + +/** + * The fixture's one translation point: hand it the English string, get the + * string this compile's locale wants. + * + * ── A missing translation THROWS, and that is the design ───────────────── + * The tempting alternative is to fall back to the English string, and it is + * the wrong one for exactly the reason this card exists: the fallback renders + * one English row in an otherwise Chinese demo, which is invisible in a test + * that only counts rows and is embarrassing on a screen. Worse, half of these + * strings are NATURAL KEYS — `duly_task.duty` resolves against + * `duly_duty.name`, `owner` against `sys_user.name` — so a fallback would not + * merely look wrong, it would silently split one obligation into two rows that + * nothing downstream could tell were meant to be the same. + * + * So an untranslated string stops the compile, in the locale that needs it, + * naming the string. In English there is nothing to look up and nothing to + * fail: `t` is the identity function, which is what keeps the English fixture + * byte-for-byte unchanged by this whole mechanism. + */ +export const t = (english: string): string => { + REQUESTED.add(english); + if (DEMO_LOCALE === 'en') return english; + const translated = ZH_BY_SOURCE.get(english); + if (translated === undefined) { + throw new Error( + `demo fixture: no ${DEMO_LOCALE} translation for ${JSON.stringify(english)} — ` + + 'add it to src/data/demo-zh.ts (every human-readable fixture string needs one).', + ); + } + return translated; +}; diff --git a/src/data/demo-org.ts b/src/data/demo-org.ts index 1fef55c..657993b 100644 --- a/src/data/demo-org.ts +++ b/src/data/demo-org.ts @@ -10,7 +10,22 @@ * cites is an internal policy number belonging to a company that does not * exist. That is a hard rule of this fixture, not a stylistic preference: a * demo seed is copied, screenshotted and pasted into decks, and a real name in - * it eventually becomes a claim about a real organisation. + * it eventually becomes a claim about a real organisation. It holds in Chinese + * too — see `demo-zh.ts`, where 安岭集团 is not a company and every 《…》 is an + * internal policy number rather than a national or industry standard. + * + * ── Every display string here follows DULY_DEMO_LOCALE ──────────────────── + * The arrays are authored in English and mapped through `t()` (`demo-locale.ts`) + * once, at the bottom of each section. Two things fall out of doing it that way + * rather than by writing the translation inline: + * + * - The English fixture is untouched by the mechanism. In `en`, `t` is the + * identity function, so these are byte-for-byte the strings they were. + * - Every REFERENCE gets translated with the thing it refers to, because the + * map is the only place a name is produced. `unit.parent`, `unit.manager` + * and `person.manager` are natural keys pointing at other rows in these same + * arrays; translating a name and forgetting one of its referents would break + * the org chart rather than merely read oddly. * * ── Why the person you log in as is IN the org chart ────────────────────── * `objectstack dev` seeds a loginable admin (`admin@objectos.ai` / `admin123`) @@ -41,14 +56,40 @@ * reference into `sys_user.name` and `owner` is `required: true`. */ +import { DEMO_LOCALE, t } from './demo-locale.js'; +import { ZH_PEOPLE } from './demo-zh.js'; + /** Reserved by RFC 2606 — a domain that cannot be registered by anyone. */ const DOMAIN = 'ardenline.example'; /** * The `sys_user.name` of the account `objectstack dev` logs you in as. * Matched by natural key; see the file header for why the row is name-only. + * + * ── Why this one string is NOT in the dictionary (#117 item 5) ──────────── + * Every other name here is fixture data: this app writes it, and nothing else + * reads it. This one names a LIVE CREDENTIAL-BEARING ACCOUNT that a different + * component mints — `@objectstack/plugin-auth`'s `maybeSeedDevAdmin`, whose + * `name` is not configurable (only `OS_SEED_ADMIN_EMAIL` / `OS_SEED_ADMIN_PASSWORD` + * are). So the Chinese spelling is not a translation the fixture may simply + * decide on; it only holds if something actually renames the account, and the + * seed's natural-key match has to land on that same row afterwards. + * + * `scripts/demo.mjs` does the rename in its priming step, before the seed + * runs, and MEASURED (2026-09-02, `@objectstack/*` 17.2.0, live `pnpm demo:zh`): + * the PATCH lands, sign-in with the same credentials still returns 200 + * afterwards, and a second boot replays the seed as a no-op against the + * renamed row. The evidence is in the PR for #117. + * + * The failure this guards is specific and silent: if the rename did not + * happen, the seed would find no `演示管理员` row, INSERT a fourteenth user, + * and hand every one of the demo account's duties, tasks and log entries to a + * person nobody can log in as — leaving My week, My duties, Sent by me and + * Work log empty on the screen the evaluator actually lands on. Which is why + * the rename is verified in the script rather than assumed, and why this + * constant is written where the reasoning is, not as a dictionary row. */ -export const ADMIN = 'Dev Admin'; +export const ADMIN: string = DEMO_LOCALE === 'zh-CN' ? '演示管理员' : 'Dev Admin'; // ───────────────────────────────────────────────────────────────────────── // Business units — three levels, as ADR-0057 D2 models them @@ -65,7 +106,7 @@ export interface DemoUnit { timezone: string; } -export const UNITS: readonly DemoUnit[] = [ +const UNITS_EN: readonly DemoUnit[] = [ { name: 'Ardenline Group', code: 'ARD', kind: 'company', parent: null, manager: 'Nadia Ilves', timezone: 'UTC' }, { name: 'Northgate Plant', code: 'NGP', kind: 'division', parent: 'Ardenline Group', manager: 'Tomas Bergh', timezone: 'Europe/Berlin' }, { name: 'Riverside Plant', code: 'RVP', kind: 'division', parent: 'Ardenline Group', manager: 'Elin Halvorsen', timezone: 'UTC' }, @@ -75,6 +116,22 @@ export const UNITS: readonly DemoUnit[] = [ { name: 'Northgate Quality', code: 'NGP-QA', kind: 'department', parent: 'Northgate Plant', manager: 'Priya Raman', timezone: 'Europe/Berlin' }, ]; +/** + * The tree in this compile's language. + * + * `code` and `timezone` are NOT translated: the first is the machine handle a + * customer's own systems join on, the second is an IANA identifier. `parent` + * and `manager` are, because they are natural keys into the rows above and + * beside them — a translated `name` with an untranslated `parent` is a tree + * with no root. + */ +export const UNITS: readonly DemoUnit[] = UNITS_EN.map((unit) => ({ + ...unit, + name: t(unit.name), + parent: unit.parent === null ? null : t(unit.parent), + manager: t(unit.manager), +})); + const UNIT_BY_NAME = new Map(UNITS.map((u) => [u.name, u])); /** The zone a unit's duties compute periods in. Unknown unit ⇒ `duly_duty.timezone`'s own default. */ @@ -89,6 +146,17 @@ export interface DemoPerson { /** `sys_user.manager_id`, by natural key. `null` only for the top of the chain. */ manager: string | null; unit: string; + /** + * `sys_user.email`, always ASCII on the reserved domain. + * + * Carried on the row rather than derived from `name` at the point of use, + * because the derivation only works on the English name: `emailOf` strips + * everything that is not `[a-z ]`, which turns 陈志远 into an empty local + * part and every Chinese person into `@ardenline.example`. The zh-CN + * mailboxes are authored as pinyin in `demo-zh.ts`; see there for why the + * address stays ASCII while the display name does not. + */ + email: string; } /** @@ -97,7 +165,7 @@ export interface DemoPerson { * terminates. `Dev Admin` is seeded separately (see the header) and is the * thirteenth participant. */ -export const PEOPLE: readonly DemoPerson[] = [ +const PEOPLE_EN: readonly Omit[] = [ { name: 'Nadia Ilves', manager: null, unit: 'Ardenline Group' }, { name: 'Tomas Bergh', manager: 'Nadia Ilves', unit: 'Northgate Plant' }, { name: 'Elin Halvorsen', manager: 'Nadia Ilves', unit: 'Riverside Plant' }, @@ -112,10 +180,42 @@ export const PEOPLE: readonly DemoPerson[] = [ { name: 'Greta Lindqvist', manager: 'Elin Halvorsen', unit: 'Riverside Plant' }, ]; -/** `firstname.lastname@ardenline.example`, deterministic from the display name. */ +/** `firstname.lastname@ardenline.example`, deterministic from the ENGLISH name. */ export const emailOf = (name: string): string => `${name.toLowerCase().replace(/[^a-z ]/g, '').split(' ').filter(Boolean).join('.')}@${DOMAIN}`; +/** + * The address a person keeps in this compile's language. + * + * English derives it from the name, as it always has. zh-CN takes the pinyin + * mailbox authored beside the Chinese name — the address stays ASCII in both, + * on the same reserved domain, which is what `test/seed.test.ts`'s "every + * seeded address is on a domain that cannot exist" reads. + */ +const addressOf = (english: string): string => + DEMO_LOCALE === 'zh-CN' ? `${ZH_PEOPLE[english]!.mailbox}@${DOMAIN}` : emailOf(english); + +/** The twelve, in this compile's language. `manager` and `unit` are natural keys, so they follow. */ +export const PEOPLE: readonly DemoPerson[] = PEOPLE_EN.map((person) => ({ + name: t(person.name), + manager: person.manager === null ? null : t(person.manager), + unit: t(person.unit), + email: addressOf(person.name), +})); + +/** + * A person's name in this compile's language. + * + * {@link ADMIN} is the one name that is deliberately NOT a dictionary entry — + * see its comment above — so it is passed through untouched while every + * fixture person goes through `t`. This exists because the alternative is a + * bare `t()` at each of the places a row's `owner` is mapped, and the day one + * of them is handed the admin the compile fails with "no zh-CN translation for + * \"Dev Admin\"", which is a confusing way to be told about a rule that is + * really about one account. + */ +export const personOf = (name: string): string => (name === ADMIN ? ADMIN : t(name)); + const UNIT_BY_PERSON = new Map(PEOPLE.map((p) => [p.name, p.unit])); /** @@ -127,7 +227,7 @@ const UNIT_BY_PERSON = new Map(PEOPLE.map((p) => [p.name, p.unit])); * is what `duly_task.business_unit` is for. */ export const unitOf = (person: string): string => - person === ADMIN ? 'Northgate Quality' : (UNIT_BY_PERSON.get(person) ?? 'Ardenline Group'); + person === ADMIN ? t('Northgate Quality') : (UNIT_BY_PERSON.get(person) ?? t('Ardenline Group')); // ───────────────────────────────────────────────────────────────────────── // Positions @@ -137,9 +237,22 @@ export const unitOf = (person: string): string => * `duly_catalog_item.position_code` is free text by design — a customer loads * their catalog before modelling positions in the platform — so these are * job-role codes, NOT the three `definePosition` names in `src/security/`. + * + * ── They are written the way a person writes them (#117 item 3) ─────────── + * They used to be `plant_compliance_officer` — a snake_case machine spelling, + * in a column the Role catalog puts on screen under a 岗位 heading. Free text + * means the app never parsed it, so nothing anywhere was reading the + * underscores; they were only ever being read by people, who do not write + * their own job title that way in either language. + * + * The values are still opaque to the app and still matched EXACTLY: the sync + * and apply actions compare `position_code` verbatim, so `Plant compliance + * officer` and `plant compliance officer` remain two different positions. + * What changed is only that the demo's three now read as job titles. A + * customer's own catalog can spell them however their HR system does. */ export const POSITIONS = { - compliance: 'plant_compliance_officer', - supervisor: 'shift_supervisor', - technician: 'quality_technician', + compliance: t('Plant compliance officer'), + supervisor: t('Shift supervisor'), + technician: t('Quality technician'), } as const; diff --git a/src/data/demo-zh.ts b/src/data/demo-zh.ts new file mode 100644 index 0000000..b5db8d8 --- /dev/null +++ b/src/data/demo-zh.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The zh-CN half of the demo fixture: every human-readable string in + * `demo-org.ts`, `demo-catalog.ts`, `demo-history.ts`, `demo-assignments.ts` + * and `log-entry.seed.ts`, keyed by the English original. + * + * ── Why a dictionary keyed on the English string, and not a second fixture ─ + * The obvious alternative — a parallel `demo-org.zh.ts`, `demo-catalog.zh.ts` + * and so on — duplicates the STRUCTURE as well as the prose: two arrays of + * catalog items, two sets of cadences, two review-state distributions, two + * copies of every comment explaining why a row is shaped the way it is. They + * would drift, and the drift would be invisible: a Chinese demo with 19 + * catalog items and an English one with 20 is not a translation bug anybody + * spots on a screen. + * + * Keying on the English string keeps ONE fixture with one shape, one history + * planner and one set of invariants, and reduces "is it translated?" to a set + * comparison a test can make. `test/seed-locale.test.ts` asserts both + * directions of it: + * + * - every string the fixture asks {@link t} for has an entry here (no line + * was forgotten), and + * - every entry here is asked for by the fixture (no entry is dead) — which + * is also what pins the English fixture byte-for-byte, because rewording + * an English line orphans its entry and goes red. + * + * ── Everything here is INVENTED — the hard rule, in Chinese too ─────────── + * 安岭集团 is not a company. 北门厂区 and 河畔厂区 are not sites. The twelve + * people do not exist, their mailboxes are on RFC 2606's reserved `.example` + * TLD, and every 《…》 reference is an internal policy number belonging to a + * company that does not exist — not a Chinese national, industry or local + * standard. A demo seed is screenshotted and pasted into decks; a real GB/T + * number in one is a claim about a real regulation, and a real company name + * is a claim about a real customer. + * + * ── Machine values are NOT here, deliberately ───────────────────────────── + * Unit codes (`ARD`, `NGP-QA`), `period_key`s, select values (`in_hand`, + * `awaiting_feedback`), timezones and frequencies are data the platform + * matches on, not prose a reader sees. They are identical in both locales; + * the zh-CN translations bundle is what renders the select values in Chinese, + * and that is a different mechanism from this file. + */ + +/** + * The twelve people, and the mailbox each one keeps. + * + * `mailbox` is the local part of the address, in pinyin — `chen.zhiyuan`, not + * `陈志远`. Two reasons, and the second is the load-bearing one: + * + * - It is what a Chinese company's directory actually looks like. + * - `sys_user.email` is matched and displayed as an identifier. Keeping it + * ASCII means the seeded address survives every place an address is typed, + * pasted, or used as a natural key, in a demo whose whole point is that it + * is being shown to somebody. + * + * The `sys_user.name` — the natural key every `owner` reference resolves + * against — IS Chinese. That is the string on screen. + */ +export const ZH_PEOPLE: Readonly> = { + 'Nadia Ilves': { name: '陈志远', mailbox: 'chen.zhiyuan' }, + 'Tomas Bergh': { name: '林建国', mailbox: 'lin.jianguo' }, + 'Elin Halvorsen': { name: '赵秀兰', mailbox: 'zhao.xiulan' }, + 'Owen Pryce': { name: '周文博', mailbox: 'zhou.wenbo' }, + 'Marek Dvorak': { name: '王海涛', mailbox: 'wang.haitao' }, + 'Priya Raman': { name: '李慧敏', mailbox: 'li.huimin' }, + 'Sami Okonkwo': { name: '孙立新', mailbox: 'sun.lixin' }, + 'Yuki Tanabe': { name: '吴佳颖', mailbox: 'wu.jiaying' }, + 'Rosa Delgado': { name: '何雨桐', mailbox: 'he.yutong' }, + 'Ibrahim Chaudhry': { name: '徐鹏程', mailbox: 'xu.pengcheng' }, + 'Ana Ferreira': { name: '郑晓芸', mailbox: 'zheng.xiaoyun' }, + 'Greta Lindqvist': { name: '冯乐言', mailbox: 'feng.leyan' }, +}; + +/** The people's display names, folded into the dictionary below. */ +const PEOPLE_NAMES: Record = Object.fromEntries( + Object.entries(ZH_PEOPLE).map(([english, person]) => [english, person.name]), +); + +/** + * Everything else. + * + * Grouped in the order a reader meets it: the org, the positions, the + * catalog, the duties instantiated from it, what happened to the tasks, the + * two assignments, and the personal work log. + * + * Duty names follow the fixture's own naming rule — the obligation, then the + * SCOPE its owner actually covers — with the English `Name — Scope` spelled + * `名称—范围` here. The scope is what keeps duty names unique, and unique is + * not decoration: `duly_task.duty` is resolved as a natural key against + * `duly_duty.name`, so two duties sharing a name would silently hang one + * person's tasks off another person's duty. + */ +export const ZH_CN: Readonly> = { + ...PEOPLE_NAMES, + + // ── Business units ────────────────────────────────────────────────────── + 'Ardenline Group': '安岭集团', + 'Northgate Plant': '北门厂区', + 'Riverside Plant': '河畔厂区', + 'Central Office': '集团总部', + 'Northgate Operations': '北门生产部', + 'Northgate Quality': '北门质量部', + + // ── Position codes (#117 item 3) ──────────────────────────────────────── + // `duly_catalog_item.position_code` is free text, and the demo used to show + // `plant_compliance_officer` in the 岗位 column — a machine spelling on a + // screen a human reads. + 'Plant compliance officer': '厂区合规专员', + 'Shift supervisor': '班组长', + 'Quality technician': '质量技术员', + + // ── Catalog items: name, then description, then the clause it discharges ─ + 'Emissions return': '排放申报', + 'Submit the site emissions figures for the month, with the meter readings they were derived from.': + '提交本月厂区排放数据,并附上据以计算的仪表读数。', + 'Group Environment Standard GE-02 §5': '《集团环境标准 GE-02》第5条', + + 'Waste transfer log review': '废物转移台账复核', + 'Check every transfer note raised last month against the carrier register; flag anything unmatched.': + '将上月开具的每一张转移联单与承运登记册逐笔核对,标出对不上的条目。', + 'Group Environment Standard GE-04 §2': '《集团环境标准 GE-04》第2条', + + 'Effluent sampling record': '废水取样记录', + 'Draw and log the weekly outfall sample. Record the result even when it is within limits.': + '每周在排放口取样并登记。即使结果在限值以内也要记录。', + 'Site Discharge Consent DC-11 cl.4': '《厂区排放许可 DC-11》第4款', + + 'Permit condition review': '许可条件复核', + 'Walk the permit conditions one by one and record, for each, the evidence that it was met this quarter.': + '逐条走查许可条件,并为每一条记录本季度已满足的证据。', + 'Group Environment Standard GE-09 §1': '《集团环境标准 GE-09》第1条', + + 'Site environmental audit': '厂区环境审核', + 'Full walk-round audit against the group environmental standard, with findings and owners.': + '对照集团环境标准做一次完整的巡查审核,列出发现项及其责任人。', + 'Group Assurance Plan AP-3 §6': '《集团保证计划 AP-3》第6条', + + 'Annual environmental statement': '年度环境报告', + "Compile the year's environmental performance into the statement the group publishes.": + '汇总全年环境绩效,形成集团对外发布的年度环境报告。', + 'Group Environment Standard GE-01 §8': '《集团环境标准 GE-01》第8条', + + 'Keep the permit register current': '保持许可台账实时更新', + 'The register reflects the permits actually in force — no expiry passes without the entry being updated. Never "done"; attested, not ticked.': + '台账要反映当前真正生效的许可——不允许任何一份到期而条目未更新。它永远不会“完成”:只做确认,不打勾。', + 'Group Environment Standard GE-09 §4': '《集团环境标准 GE-09》第4条', + + 'Shift handover record': '交接班记录', + 'Written handover for every shift change in the week: state of the line, anything left open.': + '本周每一次交接班都要留下书面记录:产线状态,以及尚未了结的事项。', + 'Works Instruction WI-120 §3': '《作业指导书 WI-120》第3条', + + 'Line safety walk': '产线安全巡查', + 'Walk the line against the safety checklist with an operator present. Log what you fixed on the spot.': + '由操作工陪同,对照安全检查表走查产线。当场整改的内容要记录下来。', + 'Site Safety Standard SS-07 §2': '《厂区安全标准 SS-07》第2条', + + 'Toolbox talk record': '班前安全讲话记录', + 'Run one toolbox talk with the shift and record who attended.': + '与本班组开展一次班前安全讲话,并记录参加人员。', + 'Site Safety Standard SS-07 §5': '《厂区安全标准 SS-07》第5条', + + 'Lifting equipment check': '起重器具检查', + 'Visual check and tag review of every sling, hoist and eyebolt on the line.': + '对产线上每一条吊带、每台葫芦、每个吊环做外观检查并复核标签。', + 'Works Instruction WI-204 §1': '《作业指导书 WI-204》第1条', + + 'Contractor induction refresh': '承包商入场培训复训', + 'Re-run the site induction for every contractor still holding a pass, and retire the passes nobody claimed.': + '为仍持有通行证的每一位承包商重做一次入场培训,并注销无人认领的通行证。', + 'Site Safety Standard SS-15 §3': '《厂区安全标准 SS-15》第3条', + + 'Answer the duty phone': '值班电话应答', + 'The out-of-hours phone is carried and answered. There is no version of this that is ever finished.': + '非工作时间的值班电话随身携带并接听。这件事没有任何一种“做完”的说法。', + 'Works Instruction WI-002 §1': '《作业指导书 WI-002》第1条', + + 'Overtime justification summary': '加班事由汇总', + 'One line per overtime shift worked: why it was needed and what it covered.': + '每一个加班班次写一行:为什么需要,做了哪些事。', + 'People Policy PP-22 cl.6': '《人事政策 PP-22》第6款', + + 'Calibration verification': '计量校准核查', + 'Verify each instrument against its reference standard and record the deviation, in range or not.': + '用标准器逐台核查仪器,并记录偏差——无论是否在允差以内。', + 'Quality Manual QM-31 §4': '《质量手册 QM-31》第4条', + + 'Retained sample review': '留样复查', + 'Inspect the retained samples due for review and dispose of anything past its retention window.': + '检查到期需要复查的留样,并处置超过留存期的样品。', + 'Quality Manual QM-18 §2': '《质量手册 QM-18》第2条', + + 'Nonconformance log review': '不合格记录复核', + 'Review every nonconformance raised last month and confirm each one has an owner and a closing date.': + '复核上月开具的每一条不合格记录,确认每条都有责任人和关闭日期。', + 'Quality Manual QM-05 §3': '《质量手册 QM-05》第3条', + + 'Cleaning verification swabs': '清洁验证涂抹检测', + 'Swab the changeover points after the weekly clean and log the plate counts.': + '每周清洁后在换型点位做涂抹取样,并登记菌落计数。', + 'Quality Manual QM-22 §7': '《质量手册 QM-22》第7条', + + 'Instrument drift check': '仪器漂移检查', + "Compare this month's calibration deviations against the last three and note any instrument trending out.": + '将本月的校准偏差与前三个月对比,记下任何出现走偏趋势的仪器。', + + 'Commissioning file handover': '试车资料移交', + 'Hand the commissioning file to operations: as-built drawings, test records, spares list, signed off.': + '向生产部门移交试车资料:竣工图、试验记录、备件清单,并完成签署。', + 'Project Standard PS-06 §5': '《项目标准 PS-06》第5条', + + // ── Duties — the catalog instantiated onto people ─────────────────────── + 'Emissions return — Northgate': '排放申报—北门厂区', + 'Waste transfer log review — Northgate': '废物转移台账复核—北门厂区', + 'Permit condition review — Northgate': '许可条件复核—北门厂区', + 'Site environmental audit — Northgate': '厂区环境审核—北门厂区', + 'Keep the permit register current — Northgate': '保持许可台账实时更新—北门厂区', + 'Annual environmental statement — Ardenline': '年度环境报告—安岭集团', + 'Answer the duty phone — Northgate Quality': '值班电话应答—北门质量部', + 'Calibration verification — Lab 1': '计量校准核查—1号实验室', + 'Retained sample review — Lab 1': '留样复查—1号实验室', + 'Nonconformance log review — Northgate Quality': '不合格记录复核—北门质量部', + 'Calibration verification — Lab 2': '计量校准核查—2号实验室', + 'Instrument drift check — Lab 2': '仪器漂移检查—2号实验室', + 'Shift handover record — Line A': '交接班记录—A线', + 'Line safety walk — Line A': '产线安全巡查—A线', + 'Line safety walk — Line B': '产线安全巡查—B线', + 'Toolbox talk record — Line B': '班前安全讲话记录—B线', + 'Contractor induction refresh — Northgate': '承包商入场培训复训—北门厂区', + 'Lifting equipment check — Line C': '起重器具检查—C线', + 'Overtime justification summary — Northgate Operations': '加班事由汇总—北门生产部', + 'Emissions return — Riverside': '排放申报—河畔厂区', + 'Permit condition review — Riverside': '许可条件复核—河畔厂区', + 'Waste transfer log review — Riverside': '废物转移台账复核—河畔厂区', + 'Line safety walk — Riverside': '产线安全巡查—河畔厂区', + 'Toolbox talk record — Riverside': '班前安全讲话记录—河畔厂区', + 'Nonconformance log review — Riverside': '不合格记录复核—河畔厂区', + 'Keep the permit register current — Ardenline': '保持许可台账实时更新—安岭集团', + 'Commissioning file handover — Riverside upgrade': '试车资料移交—河畔厂区改造', + + // ── Self-declared duties, and the cadence descriptions they carry ─────── + 'Keep up with regulator bulletins': '跟进监管通报', + "Read the month's bulletins and note anything that changes what the site owes.": + '读完本月的通报,记下其中改变厂区义务的内容。', + 'Monthly quality trend read': '每月质量趋势研读', + "Half an hour with the month's nonconformances and calibration deviations, looking for the shape rather than the individual events.": + '花半小时看本月的不合格与校准偏差,找的是整体走势,而不是单个事件。', + 'Track my own training hours': '记录本人培训学时', + 'Log the hours and what they were spent on, so the year-end return is not reconstructed from memory.': + '记下学时和用途,免得年终填报时全靠回忆拼凑。', + 'Monthly site performance note': '每月厂区运行手记', + 'A page on how the site actually ran this month — written for myself, not for a report.': + '用一页纸写下本月厂区的实际运行情况——写给自己看,不是写报告。', + + // ── The returned duty's reason (`duly_duty.review_note`) ──────────────── + 'Reading the bulletins is not the duty — the duty is recording what changed and who has to act. Rewrite the acceptance bar and send it back.': + '读通报本身不是这项职责——职责是记录改了什么、由谁去落实。请重写验收标准后再提交。', + + // ── What happened to the tasks: notes, and the one skip reason ────────── + 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.': + '3号仪表在期中更换过——数据按两个表号分开统计,两份都已附上。', + 'Waiting on the reference standard to come back from the calibration house.': + '等标准器从校准机构返回。', + 'Booked for the week of the shutdown so the lines are cold.': + '已约在停机检修那一周,届时产线处于冷态。', + 'Two of the night shift still to attend; running a repeat session.': + '夜班还有两人没参加,另安排一次补讲。', + 'Pass list pulled from the gatehouse; fourteen to chase.': + '通行证名单已从门卫处调取,还有十四人要催。', + 'Line A was down for the rebuild for the whole period — there was no line to walk.': + '整个周期 A 线都在大修停机——没有产线可巡。', + + // ── The two assignments, and the notes their tasks carry ──────────────── + 'Winter shutdown readiness check': '冬季停机检修准备检查', + 'Before the shutdown window opens, confirm your area is ready: isolations listed, spares on site, contractors booked. One line per point — no report.': + '停机窗口开始前,确认你负责的区域已准备就绪:隔离点已列出、备件已到场、承包商已预约。每项写一行即可,不必写报告。', + 'Q3 supplier certificate sweep': '三季度供应商证书清查', + 'Pull the current certificate for every approved supplier you buy from and flag any that expired during the quarter.': + '调取你所采购的每一家合格供应商的现行证书,标出本季度内已到期的。', + 'Isolations listed and countersigned. Spares are on site bar the two long-lead seals.': + '隔离点已列出并会签。备件除两件长周期密封件外均已到场。', + 'Contractor slot still to be confirmed for the Line C isolation.': + 'C 线隔离的承包商时间段还没定下来。', + 'As-builts and test records in; waiting on the spares list from the supplier.': + '竣工图和试验记录已到,等供应商的备件清单。', + + // ── The personal work log ─────────────────────────────────────────────── + 'Walked the new starter through the permit register': '带新同事过了一遍许可台账', + 'Rewrote the sampling instruction after the lab query': '因实验室提问重写了取样作业指导', + 'The old wording let two people read the hold time differently. Now it names the clock.': + '旧写法让两个人对保留时间有两种理解。现在写明了以哪个时间为准。', + 'Chased the carrier for three missing transfer notes': '向承运方催了三张缺失的转移联单', + 'Standing call with the regulator liaison': '与监管联络人的例行通话', + 'Out-of-hours callout: effluent alarm on the north outfall': '非工作时间出勤:北排放口废水报警', + 'False alarm on a blocked float. Logged with maintenance; no discharge event.': + '浮球卡阻导致的误报。已报维修登记,未发生排放事件。', + 'Drafted the shutdown environmental brief': '起草了停机检修的环境说明', + 'Sat in on the Riverside permit review to compare approaches': '旁听河畔厂区的许可复核,比较两边的做法', + 'Half a day rebuilding the meter reading spreadsheet': '花半天重做了仪表读数表格', + 'It had grown three tabs nobody owned. Now one tab, one owner.': + '它长出了三个没人负责的页签。现在一个页签、一个负责人。', + 'Recalibrated the bench balance after the move': '搬迁后重新校准了台秤', + 'Covered the goods-in checks while Ibrahim was on leave': '徐鹏程休假期间代做来料检验', + 'Traced the drift on the pH probe back to the buffer batch': '把 pH 电极的漂移追到了缓冲液批次上', + 'Buffer was out of date. Quarantined the batch and reran the affected checks.': + '缓冲液已过期。该批次已隔离,受影响的检测已重做。', + 'Wrote up the retained-sample disposal procedure': '编写了留样处置规程', + 'Lab handover meeting with the night shift': '与夜班的实验室交接会', + 'Helped operations read the swab results': '帮生产部门解读涂抹检测结果', + 'Sorted the supplier certificate folder into something findable': '把供应商证书文件夹整理成找得到东西的样子', +}; diff --git a/src/data/index.ts b/src/data/index.ts index 8feb606..335cab7 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -160,6 +160,27 @@ export const demoSeeds: Seed[] = [ /** The environment variable that asks for the demo dataset. */ export const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; +// ─── …and a second variable decides what LANGUAGE it is written in ───────── +// +// `DULY_DEMO_LOCALE` (`en` by default, `zh-CN` for a Chinese demo) is read at +// compile time exactly as the gate above is, and for exactly the same reason: +// the fixture is baked into `dist/objectstack.json`, so both are decided when +// the artifact is built rather than when the server starts. `pnpm demo:zh` +// sets it; `pnpm demo` and `pnpm dev` do not. +// +// The two are independent. This one does not turn the demo ON — a locale with +// no `DULY_DEMO_SEED` still seeds nothing — and the gate does not decide a +// language. Keeping them separate is what lets the Chinese demo be the same +// demo rather than a second one: one fixture, one history planner, one set of +// invariants, with the display strings resolved through `demo-zh.ts`. +// +// ⚠️ The READ lives in `demo-locale.ts`, not here, and that is a module-graph +// fact rather than a preference: every fixture file needs the locale, and this +// barrel imports all of them. A constant declared here would be a cycle. It is +// re-exported below so both variables are still discoverable in one place — +// which is the only reason the gate above is in this file either. +export { DEMO_LOCALE, DEMO_LOCALE_ENV_VAR, type DemoLocale } from './demo-locale.js'; + // The one Node global this app reads. `@types/node` is deliberately not a // dependency of a metadata package, so the single property the gate needs is // declared narrowly and locally rather than pulling the whole Node type diff --git a/src/data/log-entry.seed.ts b/src/data/log-entry.seed.ts index dae6177..f7e3e8d 100644 --- a/src/data/log-entry.seed.ts +++ b/src/data/log-entry.seed.ts @@ -5,7 +5,8 @@ import { defineSeed } from '@objectstack/spec/data'; import { visibleFromFor } from '../functions/period.js'; import { LogEntry } from '../objects/log-entry.object.js'; -import { ADMIN } from './demo-org.js'; +import { t } from './demo-locale.js'; +import { ADMIN, personOf } from './demo-org.js'; import { TODAY } from './demo-history.js'; /** @@ -46,7 +47,7 @@ interface DemoLogEntry { } /** Subjects are unique across the fixture — `subject` is this dataset's external id. */ -const ENTRIES: readonly DemoLogEntry[] = [ +const ENTRIES_EN: readonly DemoLogEntry[] = [ // ── The account you are logged in as ────────────────────────────────── { subject: 'Walked the new starter through the permit register', owner: ADMIN, daysAgo: 2, category: 'support', visibility: 'private' }, { subject: 'Rewrote the sampling instruction after the lab query', owner: ADMIN, daysAgo: 4, category: 'drafting', visibility: 'private', detail: 'The old wording let two people read the hold time differently. Now it names the clock.' }, @@ -67,6 +68,24 @@ const ENTRIES: readonly DemoLogEntry[] = [ { subject: 'Sorted the supplier certificate folder into something findable', owner: 'Rosa Delgado', daysAgo: 30, category: 'coordination', visibility: 'private' }, ]; +/** + * The log in this compile's language. + * + * `subject` is this dataset's external id as well as the line on screen, so it + * is translated with everything else and the key follows the display — which + * is the whole reason a replay in either locale matches its own rows rather + * than inserting fifteen more. `category` and `visibility` are select values + * the translations bundle renders. `owner` goes through `personOf`: eight of + * these entries belong to `ADMIN`, whose name is already in this compile's + * language and is deliberately not a dictionary entry. + */ +const ENTRIES: readonly DemoLogEntry[] = ENTRIES_EN.map((entry) => ({ + ...entry, + subject: t(entry.subject), + owner: personOf(entry.owner), + ...(entry.detail === undefined ? {} : { detail: t(entry.detail) }), +})); + export const logEntrySeed = defineSeed(LogEntry, { externalId: 'subject', mode: 'upsert', diff --git a/src/data/org.seed.ts b/src/data/org.seed.ts index 29412f7..92375b8 100644 --- a/src/data/org.seed.ts +++ b/src/data/org.seed.ts @@ -2,7 +2,7 @@ import type { Seed } from '@objectstack/spec/data'; -import { ADMIN, PEOPLE, UNITS, emailOf } from './demo-org.js'; +import { ADMIN, PEOPLE, UNITS } from './demo-org.js'; /** * The org: `sys_business_unit`, `sys_user`, and the membership junction @@ -119,7 +119,10 @@ export const userSeed: Seed = { { name: ADMIN }, ...PEOPLE.map((person) => ({ name: person.name, - email: emailOf(person.name), + // Carried on the row rather than derived here: the derivation only works + // on an English name (see `demo-org.ts`), and in zh-CN the display name + // is Chinese while the address stays ASCII pinyin. + email: person.email, manager_id: person.manager, })), ], diff --git a/test/demo-locale.test.ts b/test/demo-locale.test.ts new file mode 100644 index 0000000..1b0e374 --- /dev/null +++ b/test/demo-locale.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; + +import { DEMO_LOCALE_ENV_VAR, demoLocale, requestedSourceStrings } from '../src/data/demo-locale.js'; +import { ZH_CN, ZH_PEOPLE } from '../src/data/demo-zh.js'; + +// Importing the barrel evaluates every fixture module, which is what puts the +// fixture's whole demand for translations into `requestedSourceStrings()`. +// Named as a side-effecting import so nobody "cleans it up". +import '../src/data/index.js'; + +/** + * The demo fixture's locale switch, and the dictionary behind it. + * + * `test/seed-locale.test.ts` boots the app in each language and reads the rows + * back — that is the suite that proves a Chinese demo works. This one is about + * the mechanism itself, and it runs in ENGLISH, which is the interesting part: + * two of the three failures a translated fixture can have are visible without + * ever compiling the Chinese one. + */ + +// ─────────────────────────────────────────────────────────────────────────── +describe('DULY_DEMO_LOCALE', () => { + const localeFor = (value: string | undefined): (() => string) => { + vi.stubEnv(DEMO_LOCALE_ENV_VAR, value ?? ''); + return () => demoLocale(); + }; + + it.each([ + ['', 'en'], + ['en', 'en'], + ['en-GB', 'en'], + ['zh', 'zh-CN'], + ['zh-CN', 'zh-CN'], + ['ZH-cn', 'zh-CN'], + [' zh-CN ', 'zh-CN'], + ])('%j asks for %s', (value, expected) => { + expect(localeFor(value)()).toBe(expected); + vi.unstubAllEnvs(); + }); + + it.each(['zh_CN', 'zh-Hans', 'chinese', 'cn', 'de'])( + '%j is refused rather than quietly answered in English', + (value) => { + // The reason this is a throw and not a fallback: a typo that falls back + // brings the demo up in English, working perfectly, and nobody finds out + // until it is on a screen in front of a customer. The message has to name + // what it does accept, because the person reading it has just typed a + // spelling that looked right. + expect(localeFor(value)).toThrow(new RegExp(`${DEMO_LOCALE_ENV_VAR}.*is not a demo locale`)); + expect(localeFor(value)).toThrow(/zh-cn/); + vi.unstubAllEnvs(); + }, + ); + + it('is unset by default, and unset means English', () => { + // The default matters as much as the switch: `pnpm dev` and `pnpm demo` + // pass nothing, and an English demo is what they have always produced. + expect(demoLocale()).toBe('en'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the dictionary covers the fixture exactly', () => { + /** + * Both directions of one set comparison, and each direction catches a + * different real defect: + * + * - **A string with no entry** is a fixture line somebody forgot to + * translate. In a zh-CN compile it throws — see `t()` — so this test is + * how it is found from an ordinary English run, before anyone builds the + * Chinese demo. + * - **An entry nothing asks for** is a translation for a row that no longer + * exists, or, far more often, an English line that has been REWORDED + * since it was translated. That second reading is what pins the English + * fixture: #117 says this card must not quietly change the English demo, + * and a reworded description orphans its entry and lands here. + */ + const requested = new Set(requestedSourceStrings()); + const supplied = new Set(Object.keys(ZH_CN)); + + it('every string the fixture asks for has a zh-CN translation', () => { + const missing = [...requested].filter((source) => !supplied.has(source)).sort(); + expect(missing, 'fixture strings with no entry in src/data/demo-zh.ts').toEqual([]); + }); + + it('and every translation is asked for by the fixture', () => { + const dead = [...supplied].filter((source) => !requested.has(source)).sort(); + expect(dead, 'entries in src/data/demo-zh.ts that nothing asks for').toEqual([]); + }); + + it('over a real fixture, so neither direction passes vacuously', () => { + // Units, people, positions, 20 catalog items with their descriptions and + // references, 31 duties, the notes, the assignments and the work log. + expect(requested.size).toBeGreaterThan(120); + expect(supplied.size).toBe(requested.size); + }); + + it('translates every one of them into something actually Chinese', () => { + // A dictionary entry that repeats its English key would satisfy both + // directions above and translate nothing. + const HAN = /[㐀-䶿一-鿿豈-﫿]/u; + const untranslated = Object.entries(ZH_CN) + .filter(([, value]) => !HAN.test(value)) + .map(([source]) => source); + expect(untranslated).toEqual([]); + }); + + it('and gives every one of the twelve people a distinct name and mailbox', () => { + const people = Object.values(ZH_PEOPLE); + expect(people.length).toBe(12); + // `sys_user.name` is the natural key every `owner` reference resolves + // against, matched with `limit: 1`. Two people sharing a name would not + // error — one person's tasks would attach to the other, permanently. + expect(new Set(people.map((person) => person.name)).size).toBe(12); + expect(new Set(people.map((person) => person.mailbox)).size).toBe(12); + for (const person of people) { + expect(person.mailbox, `${person.name}'s mailbox`).toMatch(/^[a-z]+\.[a-z]+$/); + } + }); +}); diff --git a/test/demo-script.test.ts b/test/demo-script.test.ts new file mode 100644 index 0000000..6fd0f82 --- /dev/null +++ b/test/demo-script.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it, vi } from 'vitest'; + +/** + * `scripts/demo.mjs` and the fixture have to agree about one string, and + * nothing in the type system can hold them to it. + * + * The script is plain `.mjs` that runs BEFORE anything is compiled — it + * sequences two `objectstack dev` boots — and the fixture is TypeScript baked + * into the artifact the second boot compiles. So `ADMIN` in + * `src/data/demo-org.ts` and `ADMIN_NAME_BY_LOCALE` in the script are two + * copies of the same fact, and this file is what keeps them equal. + * + * ── What their drifting looks like, which is why it is worth a test ────── + * The seed's `sys_user` dataset is keyed on `name`. The script renames the + * live admin account in its priming step; the seed then matches that row and + * skips it. Change one spelling and not the other and the loader matches + * nothing, INSERTS a second admin, and hands the demo account's duties, tasks, + * assignment and work log to a person nobody can log in as. Nothing errors: + * the seed reports success, the app boots, and My week / My duties / Sent by + * me / Work log are empty on the first screen an evaluator opens. + * + * `package.json` is read the same way, for the same reason: `pnpm demo:zh` is + * the documented entry point and it is a string in a JSON file, so nothing + * else would notice it being renamed or losing its variable. + */ + +const read = (file: string): string => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); + +const demoScript = read('scripts/demo.mjs'); +const packageJson = JSON.parse(read('package.json')) as { scripts?: Record }; + +/** The fixture's `ADMIN`, re-evaluated for a given locale. */ +const adminFor = async (locale: string): Promise => { + vi.resetModules(); + vi.stubEnv('DULY_DEMO_LOCALE', locale); + const { ADMIN } = await import('../src/data/demo-org.js'); + vi.unstubAllEnvs(); + return ADMIN; +}; + +describe('scripts/demo.mjs agrees with the fixture', () => { + it('knows the name the fixture gives the admin in each locale', async () => { + // Both directions of the pair the rename depends on. `Dev Admin` is the + // name `@objectstack/plugin-auth` mints and the one English keeps; the + // Chinese one only exists because the script puts it there. + expect(await adminFor('')).toBe('Dev Admin'); + expect(await adminFor('zh-CN')).toBe('演示管理员'); + + expect(demoScript, 'the script must carry the English admin name').toContain("'Dev Admin'"); + expect(demoScript, 'the script must carry the Chinese admin name').toContain("'演示管理员'"); + }); + + it('maps the same locale spellings the fixture accepts', () => { + // A spelling the fixture takes but the script does not would rename the + // account for the wrong language — the exact drift above, arriving through + // an alias rather than through an edit. + for (const spelling of ['zh', 'zh-cn']) { + expect(demoScript).toContain(`['${spelling}', '演示管理员']`); + } + for (const spelling of ['en', 'en-us', 'en-gb']) { + expect(demoScript).toContain(`['${spelling}', 'Dev Admin']`); + } + }); + + it('passes the locale to BOTH boots and deletes only the seed gate', () => { + // The priming boot deliberately runs with the demo OFF, and it deletes + // exactly one variable to do it. Deleting the locale alongside it — the + // obvious "symmetry" edit — would leave the priming step renaming the + // account for a language it could not see. + expect(demoScript).toContain('delete env[DEMO_SEED_ENV_VAR]'); + expect(demoScript).not.toContain('delete env[DEMO_LOCALE_ENV_VAR]'); + expect(demoScript).toContain('DULY_DEMO_LOCALE'); + }); +}); + +describe('package.json ships the entry point the README documents', () => { + it('pnpm demo:zh sets the locale and runs the same script', () => { + expect(packageJson.scripts?.['demo:zh']).toBe('DULY_DEMO_LOCALE=zh-CN node scripts/demo.mjs'); + // The same script, so the two demos cannot diverge in how they boot. + expect(packageJson.scripts?.demo).toBe('node scripts/demo.mjs'); + }); + + it('and the README tells an evaluator it exists', () => { + // The demo table is the first thing anybody reads; a command that only + // exists in package.json is a command nobody runs. + expect(read('README.md')).toContain('pnpm demo:zh'); + }); +}); diff --git a/test/seed-locale.test.ts b/test/seed-locale.test.ts new file mode 100644 index 0000000..adfc940 --- /dev/null +++ b/test/seed-locale.test.ts @@ -0,0 +1,413 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; + +/** + * The demo fixture in both languages, asserted against TWO REAL BOOTED + * KERNELS — one per locale — with the declarative seeder actually running. + * + * `test/seed.test.ts` is the suite that proves the demo lands and that every + * view has something in it; it does that in English, which is the source + * language and the default. This file asks the three questions that only + * appear once there are two languages: + * + * 1. **Is the Chinese demo the SAME demo?** Same objects, same row counts, + * same statuses. A zh-CN fixture with 19 catalog items where the English + * one has 20 is not a translation bug anybody notices on a screen — the + * app looks fine, and one obligation has silently stopped existing. + * 2. **Did anything stay in English?** Every display string a zh-CN boot puts + * in the database has to carry at least one Han character. This is the + * assertion that catches a fixture line somebody forgot to translate, + * HERE, rather than in a demo in front of a customer — which is the whole + * reason the card asked for it. + * 3. **Is the English demo still exactly what it was?** The Chinese half is + * a new audience, not a licence to reword the English one. The names and + * subjects are pinned literally below, and the dictionary-coverage test + * pins the prose the same way (an English line that is reworded orphans + * its translation and goes red). + * + * ── Why the strings are read back from the DATABASE, not from the fixture ── + * Same reason as `test/seed.test.ts`: `src/data/demo-*.ts` is plain TypeScript + * that would satisfy any assertion made about it whether or not a row ever + * reached the database. Half of these strings are NATURAL KEYS — `duly_task.duty` + * resolves against `duly_duty.name`, `owner` against `sys_user.name` — so the + * failure a translation can cause is not "reads oddly", it is a reference that + * resolves to nothing and a row refused, or worse, resolved to the wrong row. + * Only the loader can be asked about that. + */ + +const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; +const DEMO_LOCALE_ENV_VAR = 'DULY_DEMO_LOCALE'; + +const SYSTEM = { isSystem: true } as const; + +/** Every object the demo writes into, in the order the barrel lists them. */ +const SEEDED_OBJECTS = [ + 'sys_business_unit', + 'sys_user', + 'sys_business_unit_member', + 'duly_catalog_item', + 'duly_duty', + 'duly_assignment', + 'duly_task', + 'duly_log_entry', +] as const; + +type Row = Record; + +interface Booted { + kernel: any; + rows: Record; +} + +/** + * Boot the app once, with the demo on and the given locale, and read every + * seeded object back. + * + * `vi.resetModules()` before the dynamic import is what makes a second locale + * possible at all: both `DULY_DEMO_SEED` and `DULY_DEMO_LOCALE` are read at + * module-evaluation time (the fixture is baked into the compiled artifact, so + * they are compile-time decisions), and a cached module graph would hand back + * the previous locale's fixture while reporting success. + */ +const boot = async (locale: string): Promise => { + vi.resetModules(); + vi.stubEnv(DEMO_SEED_ENV_VAR, '1'); + vi.stubEnv(DEMO_LOCALE_ENV_VAR, locale); + + const stack = (await import('../objectstack.config.js')).default as unknown as Record; + expect( + (stack.data as unknown[] | undefined)?.length ?? 0, + `this suite boots the demo, so ${DEMO_SEED_ENV_VAR} must be set before the config is imported`, + ).toBeGreaterThan(0); + + const { plugins } = await createStandaloneStack({ + databaseDriver: 'memory', + skipSeedData: true, + // Same guard as `test/seed.test.ts`: point the artifact lookup at a path + // that cannot exist, or a local `pnpm build` leaves `dist/objectstack.json` + // where the kernel loads metadata — objects, hooks AND the compiled seed — + // from the last BUILD rather than from the config imported above. In this + // suite that would be worse than stale: the built artifact carries exactly + // one locale, so both boots would read the same one and agree perfectly. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + const kernel = new ObjectKernel(); + for (const plugin of plugins) await kernel.use(plugin); + await kernel.use(new AppPlugin(stack as any, undefined, { skipSeedData: false })); + await kernel.bootstrap(); + + // `any`, as in `test/seed.test.ts`: the kernel's service registry is keyed by + // name and returns `unknown`, and narrowing it here would be this suite + // describing a platform surface it does not own. + const data = kernel.getService('data') as any; + const all = async (object: string): Promise => + ((await data.find(object, {}, { context: SYSTEM })) ?? []) as Row[]; + + // The inline seed is raced against a budget rather than awaited by + // bootstrap, so wait for the LAST dataset in the barrel to have landed. + const deadline = Date.now() + 120_000; + for (;;) { + const logs = (await all('duly_log_entry')).length; + if (logs >= 15) break; + if (Date.now() > deadline) throw new Error(`${locale}: seed did not settle — ${logs} log entries after 120s`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const rows: Record = {}; + for (const object of SEEDED_OBJECTS) rows[object] = await all(object); + return { kernel, rows }; +}; + +let en: Booted; +let zh: Booted; + +beforeAll(async () => { + en = await boot('en'); + zh = await boot('zh-CN'); +}, 300_000); + +afterAll(async () => { + await en?.kernel?.shutdown?.(); + await zh?.kernel?.shutdown?.(); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +// ─────────────────────────────────────────────────────────────────────────── +/** Any CJK ideograph, including the extension blocks a rarer surname needs. */ +const HAN = /[㐀-䶿一-鿿豈-﫿]/u; + +/** The fields of each object that a person READS. Machine values are not here. */ +const DISPLAY_FIELDS: Readonly> = { + sys_business_unit: ['name'], + sys_user: ['name'], + duly_catalog_item: ['name', 'description', 'regulation_ref', 'position_code'], + duly_duty: ['name', 'description', 'review_note'], + duly_assignment: ['subject', 'description'], + duly_task: ['subject', 'note', 'skip_reason'], + duly_log_entry: ['subject', 'detail'], +}; + +/** `[object.field on row N, value]` for every non-blank display string. */ +const displayStrings = (booted: Booted): Array<[string, string]> => { + const out: Array<[string, string]> = []; + for (const [object, fields] of Object.entries(DISPLAY_FIELDS)) { + for (const row of booted.rows[object] ?? []) { + for (const field of fields) { + const value = row[field]; + if (typeof value === 'string' && value.trim() !== '') out.push([`${object}.${field}`, value]); + } + } + } + return out; +}; + +// ─────────────────────────────────────────────────────────────────────────── +describe('the two locales are the same demo', () => { + it('boots into two independent databases', async () => { + // Stated first because every comparison below is meaningless without it: + // two kernels sharing one in-memory store would make the second boot see + // the first one's rows, and "same counts" would be trivially true of a + // fixture that had been seeded twice. + const enDuties = en.rows.duly_duty!.map((duty) => String(duty.name)); + const zhDuties = zh.rows.duly_duty!.map((duty) => String(duty.name)); + expect(enDuties.some((name) => HAN.test(name)), 'a Chinese duty in the English database').toBe(false); + expect(zhDuties.every((name) => HAN.test(name)), 'an English duty in the Chinese database').toBe(true); + }); + + it('writes the same number of rows into every object', () => { + // The failure this catches: a translation that does not resolve as a + // natural key. `duly_task.owner` is required, so an owner that matches no + // `sys_user.name` does not drop a field — it refuses the whole row, and + // the seed still reports success. + for (const object of SEEDED_OBJECTS) { + expect(zh.rows[object]!.length, `${object} row count differs between locales`).toBe( + en.rows[object]!.length, + ); + } + // And the fixture is really populated, so "equal" cannot be met by two + // empty databases. + expect(en.rows.duly_task!.length).toBeGreaterThan(100); + }); + + it('and the same shape of history — statuses, calibers, review states', () => { + const tally = (booted: Booted, object: string, field: string): Record => { + const out: Record = {}; + for (const row of booted.rows[object] ?? []) { + const key = String(row[field]); + out[key] = (out[key] ?? 0) + 1; + } + return out; + }; + // Select values are machine data: the translations bundle renders them, so + // a fixture that "translated" one would break every filter naming it. + expect(tally(zh, 'duly_task', 'status')).toEqual(tally(en, 'duly_task', 'status')); + expect(tally(zh, 'duly_task', 'source')).toEqual(tally(en, 'duly_task', 'source')); + expect(tally(zh, 'duly_duty', 'review_status')).toEqual(tally(en, 'duly_duty', 'review_status')); + expect(tally(zh, 'duly_duty', 'form')).toEqual(tally(en, 'duly_duty', 'form')); + expect(tally(zh, 'duly_catalog_item', 'frequency')).toEqual(tally(en, 'duly_catalog_item', 'frequency')); + expect(tally(zh, 'duly_log_entry', 'category')).toEqual(tally(en, 'duly_log_entry', 'category')); + // Period keys are computed by the engine and must not vary with language. + expect(tally(zh, 'duly_task', 'period_key')).toEqual(tally(en, 'duly_task', 'period_key')); + }); + + it('keeps the machine values identical, language by language', () => { + const codes = (booted: Booted) => booted.rows.sys_business_unit!.map((u) => String(u.code)).sort(); + expect(codes(zh)).toEqual(codes(en)); + const zones = (booted: Booted) => booted.rows.duly_duty!.map((d) => String(d.timezone)).sort(); + expect(zones(zh)).toEqual(zones(en)); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('nothing is left in English in a zh-CN demo', () => { + it('every display string a Chinese boot writes carries a Han character', () => { + const strings = displayStrings(zh); + // A real population, or an empty one would pass vacuously — and exactly as + // many strings as the English boot, which is the assertion that actually + // holds it down: a column that stopped being written, or a display field + // renamed out from under the list above, would shrink one side only. + expect(strings.length).toBeGreaterThan(300); + expect(strings.length, 'the two locales write a different number of display strings').toBe( + displayStrings(en).length, + ); + const untranslated = strings.filter(([, value]) => !HAN.test(value)); + // Named rather than counted: the point of this test is to say WHICH line + // was missed, because the symptom otherwise is one English row on a + // Chinese screen — which reads as a styling quirk, not a missing string. + expect(untranslated).toEqual([]); + }); + + it('and the English boot has none of them, so the check is really about language', () => { + // The mirror image. Without it, a regex that matched everything — or a + // display-field list naming columns that do not exist — would pass above + // and prove nothing. + const strings = displayStrings(en); + expect(strings.length).toBeGreaterThan(300); + expect(strings.filter(([, value]) => HAN.test(value))).toEqual([]); + }); + + it('but the mailboxes stay ASCII, on a domain that cannot exist', () => { + // `sys_user.email` is an identifier that gets typed, pasted and matched + // on. The display name is Chinese; the address is pinyin. RFC 2606 holds + // in both locales — see `test/seed.test.ts` for why that is a hard rule. + const addressed = zh.rows.sys_user!.filter((user) => user.email); + expect(addressed.length).toBe(12); + for (const user of addressed) { + expect(String(user.email), `${user.name}'s address`).toMatch(/^[a-z]+\.[a-z]+@ardenline\.example$/); + } + // Distinct, or two people share a mailbox. + expect(new Set(addressed.map((user) => String(user.email))).size).toBe(12); + }); + + it('names no real company, person, site or regulation', () => { + // The fixture's hard rule, restated for the strings this card adds. A demo + // seed is screenshotted into decks; a real GB/T number in one is a claim + // about a real regulation. Every reference the Chinese catalog cites is an + // internal document belonging to a company that does not exist, and they + // are spelled as such — 《…》 wrapping an invented internal code. + const references = zh.rows + .duly_catalog_item!.map((item) => item.regulation_ref) + .filter((ref): ref is string => typeof ref === 'string'); + expect(references.length).toBeGreaterThan(15); + for (const reference of references) { + expect(reference, 'a catalog reference must be an internal document').toMatch(/^《.+》第\d+[条款]$/u); + // The spellings a real Chinese standard would carry. None of these can + // appear in an invented internal policy number. + expect(reference).not.toMatch(/GB|ISO|HJ\/T|国标/); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the English demo is byte-for-byte what it was', () => { + /** + * Pinned literally, not snapshotted to a file: a `.snap` is updated by + * `vitest -u` without anybody reading the diff, and the whole point of this + * block is that a change to the English demo has to be a deliberate, + * reviewable edit. These are the strings an evaluator reads on the first + * screen and the ones every deck screenshot carries. + */ + const names = (object: string, field: string): string[] => + en.rows[object]!.map((row) => String(row[field])).sort(); + + it('the org chart', () => { + expect(names('sys_business_unit', 'name')).toEqual([ + 'Ardenline Group', + 'Central Office', + 'Northgate Operations', + 'Northgate Plant', + 'Northgate Quality', + 'Riverside Plant', + ]); + expect(names('sys_user', 'name')).toEqual([ + 'Ana Ferreira', + 'Dev Admin', + 'Elin Halvorsen', + 'Greta Lindqvist', + 'Ibrahim Chaudhry', + 'Marek Dvorak', + 'Nadia Ilves', + 'Owen Pryce', + 'Priya Raman', + 'Rosa Delgado', + 'Sami Okonkwo', + 'Tomas Bergh', + 'Yuki Tanabe', + ]); + }); + + it('the role catalog, and the three positions it hangs off', () => { + expect(names('duly_catalog_item', 'name')).toEqual([ + 'Annual environmental statement', + 'Answer the duty phone', + 'Calibration verification', + 'Cleaning verification swabs', + 'Commissioning file handover', + 'Contractor induction refresh', + 'Effluent sampling record', + 'Emissions return', + 'Instrument drift check', + 'Keep the permit register current', + 'Lifting equipment check', + 'Line safety walk', + 'Nonconformance log review', + 'Overtime justification summary', + 'Permit condition review', + 'Retained sample review', + 'Shift handover record', + 'Site environmental audit', + 'Toolbox talk record', + 'Waste transfer log review', + ]); + // Readable in English too (#117 item 3) — they used to be + // `plant_compliance_officer`, a machine spelling in a column people read. + expect([...new Set(names('duly_catalog_item', 'position_code'))].sort()).toEqual([ + 'Plant compliance officer', + 'Quality technician', + 'Shift supervisor', + ]); + }); + + it('the duties, whose names are also the natural key every task resolves against', () => { + expect(names('duly_duty', 'name')).toEqual([ + 'Annual environmental statement — Ardenline', + 'Answer the duty phone — Northgate Quality', + 'Calibration verification — Lab 1', + 'Calibration verification — Lab 2', + 'Commissioning file handover — Riverside upgrade', + 'Contractor induction refresh — Northgate', + 'Emissions return — Northgate', + 'Emissions return — Riverside', + 'Instrument drift check — Lab 2', + 'Keep the permit register current — Ardenline', + 'Keep the permit register current — Northgate', + 'Keep up with regulator bulletins', + 'Lifting equipment check — Line C', + 'Line safety walk — Line A', + 'Line safety walk — Line B', + 'Line safety walk — Riverside', + 'Monthly quality trend read', + 'Monthly site performance note', + 'Nonconformance log review — Northgate Quality', + 'Nonconformance log review — Riverside', + 'Overtime justification summary — Northgate Operations', + 'Permit condition review — Northgate', + 'Permit condition review — Riverside', + 'Retained sample review — Lab 1', + 'Shift handover record — Line A', + 'Site environmental audit — Northgate', + 'Toolbox talk record — Line B', + 'Toolbox talk record — Riverside', + 'Track my own training hours', + 'Waste transfer log review — Northgate', + 'Waste transfer log review — Riverside', + ]); + }); + + it('the two assignments and the personal work log', () => { + expect(names('duly_assignment', 'subject')).toEqual([ + 'Q3 supplier certificate sweep', + 'Winter shutdown readiness check', + ]); + expect(names('duly_log_entry', 'subject')).toEqual([ + 'Chased the carrier for three missing transfer notes', + 'Covered the goods-in checks while Ibrahim was on leave', + 'Drafted the shutdown environmental brief', + 'Half a day rebuilding the meter reading spreadsheet', + 'Helped operations read the swab results', + 'Lab handover meeting with the night shift', + 'Out-of-hours callout: effluent alarm on the north outfall', + 'Recalibrated the bench balance after the move', + 'Rewrote the sampling instruction after the lab query', + 'Sat in on the Riverside permit review to compare approaches', + 'Sorted the supplier certificate folder into something findable', + 'Standing call with the regulator liaison', + 'Traced the drift on the pH probe back to the buffer batch', + 'Walked the new starter through the permit register', + 'Wrote up the retained-sample disposal procedure', + ]); + }); +});