diff --git a/.changeset/measure-analytics-starvation-paths-7696.md b/.changeset/measure-analytics-starvation-paths-7696.md new file mode 100644 index 0000000000..a7ee5c9e2c --- /dev/null +++ b/.changeset/measure-analytics-starvation-paths-7696.md @@ -0,0 +1,7 @@ +--- +--- + +Test-only, plus a constructed boot fixture: measures which of the five ways the +analytics option-label net starves produces objectui#7696's reported symptom — a +chart legend in English while the neighbouring list view of the same field is +correct. No published behaviour changes; no production file is touched. diff --git a/packages/plugin-dashboard/demo/starvation-7696.html b/packages/plugin-dashboard/demo/starvation-7696.html new file mode 100644 index 0000000000..08471b262e --- /dev/null +++ b/packages/plugin-dashboard/demo/starvation-7696.html @@ -0,0 +1,41 @@ + + + + + + objectui#7696 — analytics starvation paths + + + +
+ + + diff --git a/packages/plugin-dashboard/demo/starvation-7696.tsx b/packages/plugin-dashboard/demo/starvation-7696.tsx new file mode 100644 index 0000000000..eb8648c388 --- /dev/null +++ b/packages/plugin-dashboard/demo/starvation-7696.tsx @@ -0,0 +1,249 @@ +/** + * objectui#7696 — a CONSTRUCTED BOOT for the five analytics starvation paths. + * + * pnpm --dir packages/plugin-dashboard exec vite demo --port 5197 --strictPort + * open http://localhost:5197/starvation-7696.html?path=baseline + * + * `?path=` selects the starvation: `baseline` · `1` · `2` · `3a` · `3b` · `4` · `5`. + * + * ## What this adds over the jsdom pin + * + * `DatasetWidget.starvationFingerprints-7696.test.tsx` reads the categories the + * widget hands the chart renderer, because jsdom lays out no SVG and paints no + * legend — so the step from "these are the categories" to "this is what the + * legend SAYS" is an argument there, not a reading. This boot closes exactly + * that step: the real `@object-ui/plugin-charts` renderer is mounted in a real + * browser, so the legend text below is painted by the chart library from the + * same categories, and can be read off the DOM. + * + * Everything else is deliberately the SAME construction as the pin — same + * fixture, same two channels, same four fingerprint axes — so the two readings + * are comparable rather than merely consistent. + * + * ⛔ No production code is changed by this file. It mounts the shipped + * components and starves their inputs. + */ +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import '@object-ui/components/style.css'; +// Registers the REAL `chart` component — the renderer that paints the legend. +import '@object-ui/plugin-charts'; +import { I18nProvider, useSafeFieldLabel } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { SelectCellRenderer } from '@object-ui/fields'; +import { DatasetWidget } from '../src/DatasetWidget'; + +/* ───────────────────────── the card's fixture, verbatim ──────────────────── */ + +const FORM_OPTIONS = [ + { value: 'one_off', label: 'One-off' }, + { value: 'recurring', label: 'Recurring' }, + { value: 'standing', label: 'Standing' }, +]; + +const DUTY = { name: 'duly_duty', fields: { form: { type: 'select', options: FORM_OPTIONS } } }; +const DUTY_NO_OPTIONS = { name: 'duly_duty', fields: { form: { type: 'select' } } }; + +/** `duly.fields` is what makes the namespace DISCOVERABLE (`getAppNamespaces`). */ +const ZH_BUNDLE = { + zh: { + duly: { + fields: { duly_duty: { form: '形式' } }, + fieldOptions: { + duly_duty: { form: { one_off: '一次性', recurring: '周期性', standing: '常设' } }, + }, + }, + }, +}; + +/** The SAME translations, with the only discoverable key removed — path (4). */ +const ZH_BUNDLE_UNDISCOVERABLE = { + zh: { + duly: { + fieldOptions: { + duly_duty: { form: { one_off: '一次性', recurring: '周期性', standing: '常设' } }, + }, + }, + }, +}; + +const PIE_WIDGET = { + type: 'pie', + dataset: 'duly_duty_register', + dimensions: ['form'], + values: ['duty_count'], + chartConfig: { showLegend: true }, +}; + +/** `rows[].form` server-resolved to the authored ENGLISH label (ADR-0021). */ +const datasetResult = (omitObject: boolean) => ({ + rows: [ + { form: 'One-off', duty_count: 5 }, + { form: 'Recurring', duty_count: 3 }, + { form: 'Standing', duty_count: 2 }, + ], + fields: [ + { name: 'form', type: 'string', label: '形式' }, + { name: 'duty_count', type: 'number', label: '数量' }, + ], + ...(omitObject ? {} : { object: 'duly_duty' }), + dimensionFields: { form: 'form' }, + drillRawRows: [{ form: 'one_off' }, { form: 'recurring' }, { form: 'standing' }], +}); + +/* ─────────────────────────────── the paths ───────────────────────────────── */ + +interface PathSpec { + label: string; + /** The document the CHART's channel serves; `null` serves 401 — path (2). */ + chartDoc: unknown | null; + /** The document the LIST's channel serves. */ + listDoc: unknown; + /** Options declared on the list's COLUMN, which the grid prefers. */ + listColumnOptions?: typeof FORM_OPTIONS; + omitObject: boolean; + resources: Record; + provider: boolean; +} + +const PATHS: Record = { + baseline: { + label: 'BASELINE — healthy install', + chartDoc: DUTY, listDoc: DUTY, omitObject: false, resources: ZH_BUNDLE, provider: true, + }, + '1': { + label: 'PATH (1) — the analytics response omits `object`', + chartDoc: DUTY, listDoc: DUTY, omitObject: true, resources: ZH_BUNDLE, provider: true, + }, + '2': { + label: 'PATH (2) — the chart’s metadata read is unauthorized (401)', + chartDoc: null, listDoc: DUTY, omitObject: false, resources: ZH_BUNDLE, provider: true, + }, + '3a': { + label: 'PATH (3a) — no inline options; the list reads the object document', + chartDoc: DUTY_NO_OPTIONS, listDoc: DUTY_NO_OPTIONS, omitObject: false, + resources: ZH_BUNDLE, provider: true, + }, + '3b': { + label: 'PATH (3b) — no inline options, but the list COLUMN declares its own', + chartDoc: DUTY_NO_OPTIONS, listDoc: DUTY_NO_OPTIONS, listColumnOptions: FORM_OPTIONS, + omitObject: false, resources: ZH_BUNDLE, provider: true, + }, + '4': { + label: 'CONTROL (4) — the app namespace is not discoverable', + chartDoc: DUTY, listDoc: DUTY, omitObject: false, + resources: ZH_BUNDLE_UNDISCOVERABLE, provider: true, + }, + '5': { + label: 'CONTROL (5) — no I18nProvider in scope', + chartDoc: DUTY, listDoc: DUTY, omitObject: false, resources: ZH_BUNDLE, provider: false, + }, +}; + +const selected = new URLSearchParams(window.location.search).get('path') ?? 'baseline'; +const spec = PATHS[selected] ?? PATHS.baseline; + +/* ───────────────────────────── the two channels ──────────────────────────── */ + +/** + * The CHART's channel: the global `fetch`, which is what the analytics net + * falls back to when the host installed no `apiFetch` (objectui#4121). Patched + * for `/api/v1/meta/object/*` only; everything else passes through to Vite. + */ +const metaReads: string[] = []; +const realFetch = window.fetch.bind(window); +window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(typeof input === 'string' ? input : (input as Request).url ?? input); + const m = /\/api\/v1\/meta\/object\/(.+)$/.exec(url); + if (!m) return realFetch(input as RequestInfo, init); + if (spec.chartDoc === null) { + metaReads.push(`${decodeURIComponent(m[1])} -> 401`); + return new Response('unauthorized', { status: 401, headers: { 'content-type': 'text/plain' } }); + } + metaReads.push(`${decodeURIComponent(m[1])} -> 200`); + return new Response(JSON.stringify({ item: spec.chartDoc }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}) as typeof window.fetch; + +/** The LIST's channel: the DataSource's own authenticated metadata read. */ +const dataSource = { + queryDataset: async () => datasetResult(spec.omitObject), + getObjectSchema: async () => spec.listDoc, +}; + +/* ──────────────────────────── the page under test ────────────────────────── */ + +const LIST_ROW_VALUE = 'recurring'; + +/** The neighbouring list view's cell: `translateOptions` then the cell renderer. */ +function NeighbouringListCell() { + const { translateOptions } = useSafeFieldLabel(); + const objectOptions = (spec.listDoc as any)?.fields?.form?.options; + const rawOptions = spec.listColumnOptions || objectOptions; + const options = Array.isArray(rawOptions) && rawOptions.length > 0 + ? translateOptions('duly_duty', 'form', rawOptions as any) + : []; + return ( +
+ +
+ ); +} + +/** An app-bundle-scoped string — the channel the widget title resolves through. */ +function BundleChromeProbe() { + const { fieldLabel } = useSafeFieldLabel(); + return {fieldLabel('duly_duty', 'form', 'Form')}; +} + +function MetaReadTrace() { + const [, force] = React.useState(0); + React.useEffect(() => { + const id = window.setInterval(() => force((n) => n + 1), 200); + return () => window.clearInterval(id); + }, []); + return {metaReads.length === 0 ? '(none)' : metaReads.join(', ')}; +} + +function Page() { + const page = ( + // No `apiFetch` on the provider — the host of objectui#4121's report had + // none either, which is why the net fell back to the global `fetch`. + +
+

{spec.label}

+
+
+ Dashboard chart — 职责登记 / duly_duty_register +
+
+ +
+
+
+
+ Neighbouring list view — the `form` cell of a `recurring` row +
+ +
+
+
app-bundle chrome:
+
chart metadata channel:
+
+
+
+ ); + return spec.provider ? ( + + {page} + + ) : ( + page + ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.starvationFingerprints-7696.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.starvationFingerprints-7696.test.tsx new file mode 100644 index 0000000000..ae29282e80 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.starvationFingerprints-7696.test.tsx @@ -0,0 +1,522 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7696 — WHICH of the five starvation paths produces the REPORTED + * symptom. A measurement, not a fix: no production file changes. + * + * ## Why this file exists + * + * The card's original claim — that a LOCAL select dimension on a CHART widget + * was a 2x2 cell nobody covered — was falsified and the card was deliberately + * kept OPEN, because the SYMPTOM was not falsified: a console really does paint + * `One-off / Recurring / Standing` in a pie legend under a Chinese title, + * description and axis labels, with the neighbouring LIST VIEW of the same + * field rendering the translation correctly. Nothing in the relabel path + * explains that, so the English has to come from one of the five ways this + * best-effort net starves. Triage re-scoped the card to: decide which one. + * + * Every path below is driven. The two that were nominated as likely are NOT + * driven alone — a reading that only ever exercises its own hypothesis cannot + * fail, so the other three are driven too, as negative controls. + * + * ## The instrument: a FINGERPRINT, not a verdict + * + * The reporter could observe four things at once, and it is the COMBINATION + * that discriminates — every one of the five paths turns the chart English, so + * the chart alone distinguishes nothing: + * + * | axis | what it reads | + * |----------------|----------------------------------------------------------| + * | `chart` | the pie categories the chart renderer receives | + * | `list` | the neighbouring list view's cell for the same field | + * | `serverChrome` | chrome the SERVER resolved: a series label off `fields[]` | + * | `bundleChrome` | an app-bundle-scoped string (the widget title's channel) | + * | `metaReads` | what the chart's metadata channel asked for, and got | + * + * `serverChrome` and `bundleChrome` are separated deliberately: the reporter + * saw Chinese chrome, and the chrome has TWO independent sources. The axis and + * series labels come off the analytics response (the card measured + * `fields[].label` switching with `accept-language` while `rows[]` did not, so + * the locale reaches the endpoint). The title and description come out of the + * app's i18n bundle. A path that kills the bundle kills the second and not the + * first, and that is precisely what excludes it from the report. + * + * `bundleChrome` is read through `fieldLabel` rather than through a dashboard + * title. Same channel, one resolver over: EVERY resolver in `useObjectLabel` + * funnels through the same `resolve` helper, which is gated on + * `getAppNamespaces()`. So `fieldLabel` stands in for the whole app-bundle + * surface of the page — when it degrades, the widget title degrades with it, + * and when it holds, the title holds. + * + * ## The fixture is the card's own, verbatim + * + * `duly_duty_register` on `duly_duty`, one LOCAL `form` dimension (a select + * with three options), a pie with `showLegend: true`, and rows arriving + * SERVER-RESOLVED to the object's authored ENGLISH labels (ADR-0021) — the + * exact bytes the card measured off `POST /api/v1/analytics/dataset/query`, + * with `fields[].label` already Chinese in the same payload. + * + * ## The neighbouring LIST leg, and why it is built this way + * + * The list is modelled as the two steps `ObjectGrid` takes, in order, with the + * same inputs: it reads the object document through the DataSource + * (`getObjectSchema`), runs that field's `options` through + * `useSafeFieldLabel().translateOptions`, and renders the value through + * `SelectCellRenderer` — which is the renderer the field registry resolves + * `select` to, and which reads `option.label` and nothing else. + * + * Two properties of that wiring are load-bearing here and both are real: + * + * 1. **The list's metadata read is a DIFFERENT CHANNEL from the chart's.** The + * list asks the DataSource, which carries the session credential in its own + * authenticated fetch wrapper; the analytics net asks + * `SchemaRendererContext.apiFetch ?? fetch` — falling back to the GLOBAL + * fetch when no host installed one. That asymmetry is objectui#4121's + * reported shape and it is what path (2) is. + * 2. **Both channels terminate on the SAME document**, `GET + * /api/v1/meta/object/`. So a document that carries no inline + * `options` starves BOTH — unless the list's own column declares options, + * which the grid prefers over the object document. Path (3) is measured in + * both of those sub-cases below, because they do not give the same answer. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, cleanup, waitFor, act } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider, useSafeFieldLabel } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { SelectCellRenderer } from '@object-ui/fields'; +import { DatasetWidget } from '../DatasetWidget'; + +/* ───────────────────────── the card's fixture, verbatim ──────────────────── */ + +const FORM_OPTIONS = [ + { value: 'one_off', label: 'One-off' }, + { value: 'recurring', label: 'Recurring' }, + { value: 'standing', label: 'Standing' }, +]; + +/** The object document as a healthy install serves it. */ +const DUTY = { name: 'duly_duty', fields: { form: { type: 'select', options: FORM_OPTIONS } } }; +/** The SAME document with the option LIST absent — path (3)'s whole change. */ +const DUTY_NO_OPTIONS = { name: 'duly_duty', fields: { form: { type: 'select' } } }; + +/** + * The app bundle. `duly.fields` is what makes `duly` DISCOVERABLE — see + * `getAppNamespaces`, which keeps a top-level key only when it carries one of + * `objects` / `fields` / `apps` / `dashboards` / `pages` / `reports` / + * `globalActions`. `fieldOptions` is NOT in that list, which is the whole of + * path (4): a bundle can carry every option translation and still be invisible. + */ +const ZH_BUNDLE = { + zh: { + duly: { + fields: { duly_duty: { form: '形式' } }, + fieldOptions: { + duly_duty: { form: { one_off: '一次性', recurring: '周期性', standing: '常设' } }, + }, + }, + }, +}; + +/** Path (4): the SAME translations with the discoverable key removed. */ +const ZH_BUNDLE_UNDISCOVERABLE = { + zh: { + duly: { + fieldOptions: { + duly_duty: { form: { one_off: '一次性', recurring: '周期性', standing: '常设' } }, + }, + }, + }, +}; + +const PIE_WIDGET = { + type: 'pie', + dataset: 'duly_duty_register', + dimensions: ['form'], + values: ['duty_count'], + chartConfig: { showLegend: true }, +}; + +/** + * The analytics payload the card measured: `rows[].form` carries the AUTHORED + * ENGLISH label (server-resolved, ADR-0021) while `fields[].label` is already + * Chinese in the same response — the two facts the card pinned. + * + * @param omitObject drop `object` from the result — path (1). + */ +const datasetResult = (omitObject = false) => ({ + rows: [ + { form: 'One-off', duty_count: 5 }, + { form: 'Recurring', duty_count: 3 }, + { form: 'Standing', duty_count: 2 }, + ], + fields: [ + // `type: 'string'` — what a select dimension is on the wire. + { name: 'form', type: 'string', label: '形式' }, + { name: 'duty_count', type: 'number', label: '数量' }, + ], + ...(omitObject ? {} : { object: 'duly_duty' }), + dimensionFields: { form: 'form' }, + drillRawRows: [{ form: 'one_off' }, { form: 'recurring' }, { form: 'standing' }], +}); + +/* ───────────────────────────── the two channels ──────────────────────────── */ + +/** What the chart's metadata read did, recorded per render. */ +interface MetaChannel { + /** Object names the chart's channel asked for. */ + requested: string[]; + /** How each of those resolved, in order. */ + outcomes: string[]; +} + +/** + * Install the CHART's channel: the global `fetch`, which is what the analytics + * net falls back to when the host installed no `apiFetch` (objectui#4121). + * + * @param doc the document this channel serves, or `null` to serve `401`. + */ +function installChartChannel(doc: unknown | null): MetaChannel { + const channel: MetaChannel = { requested: [], outcomes: [] }; + global.fetch = vi.fn(async (input: unknown) => { + const url = String(input); + const m = /\/api\/v1\/meta\/object\/(.+)$/.exec(url); + channel.requested.push(m ? decodeURIComponent(m[1]) : url); + if (doc === null) { + channel.outcomes.push('401'); + // An unauthorized read is a RESPONSE, not a throw — the body is not JSON, + // so the net's `.catch(() => null)` yields a null schema and the walk + // resolves nothing. Best-effort by construction: no error surfaces. + return { ok: false, status: 401, json: async () => { throw new Error('not json'); } }; + } + channel.outcomes.push('200'); + return { ok: true, status: 200, json: async () => ({ item: doc }) }; + }) as any; + return channel; +} + +/** + * The LIST's channel: the DataSource's own authenticated metadata read. Kept + * separate from the chart's on purpose — that separation IS path (2). + */ +const listChannel = (doc: unknown) => ({ + queryDataset: vi.fn(async () => datasetResult()), + getObjectSchema: vi.fn(async () => doc), +}); + +/* ──────────────────────────── the page under test ────────────────────────── */ + +/** The value the neighbouring list view renders — the card's `recurring` row. */ +const LIST_ROW_VALUE = 'recurring'; + +/** + * The neighbouring list view's cell, as `ObjectGrid` builds it: the field's + * `options` (its COLUMN's, when the column declares them, else the object + * document's) run through `translateOptions`, then `SelectCellRenderer`. + */ +function NeighbouringListCell({ + doc, + columnOptions, +}: { + doc: unknown; + columnOptions?: Array<{ value: string; label: string }>; +}) { + const { translateOptions } = useSafeFieldLabel(); + const objectOptions = (doc as any)?.fields?.form?.options; + const rawOptions = columnOptions || objectOptions; + const options = Array.isArray(rawOptions) && rawOptions.length > 0 + ? translateOptions('duly_duty', 'form', rawOptions as any) + : []; + return ( +
+ +
+ ); +} + +/** An app-bundle-scoped string — the channel the widget title resolves through. */ +function BundleChromeProbe() { + const { fieldLabel } = useSafeFieldLabel(); + return {fieldLabel('duly_duty', 'form', 'Form')}; +} + +/** Capture what the widget hands the chart renderer (jsdom lays out no SVG). */ +let capturedChartProps: any = null; +beforeAll(() => { + ComponentRegistry.register('chart', (props: any) => { + capturedChartProps = props; + return null; + }); +}); + +afterEach(() => { + cleanup(); + capturedChartProps = null; + vi.restoreAllMocks(); +}); + +interface PageOptions { + /** The document the CHART's channel serves; `null` serves 401 — path (2). */ + chartDoc?: unknown | null; + /** The document the LIST's channel serves. */ + listDoc?: unknown; + /** Options declared on the list's COLUMN, which the grid prefers. */ + listColumnOptions?: Array<{ value: string; label: string }>; + /** Drop `object` from the analytics response — path (1). */ + omitObject?: boolean; + /** The bundle to mount; omit `provider` to mount none — path (5). */ + resources?: Record; + /** Mount an `I18nProvider` at all. */ + provider?: boolean; +} + +function renderPage(opts: PageOptions) { + const { + chartDoc = DUTY, + listDoc = DUTY, + listColumnOptions, + omitObject = false, + resources = ZH_BUNDLE, + provider = true, + } = opts; + + const channel = installChartChannel(chartDoc); + const ds: any = listChannel(listDoc); + ds.queryDataset = vi.fn(async () => datasetResult(omitObject)); + + const page = ( + // No `apiFetch` on the provider — the host of objectui#4121's report had + // none either, which is exactly why the net fell back to global `fetch`. + + + + + + ); + + const view = render( + provider ? ( + + {page} + + ) : ( + page + ), + ); + return { view, channel }; +} + +/* ─────────────────────────────── the fingerprint ─────────────────────────── */ + +interface Fingerprint { + chart: string[]; + list: string; + serverChrome: string; + bundleChrome: string; + metaReads: string[]; + metaOutcomes: string[]; +} + +/** + * The pie's categories, as the chart renderer receives them. A pie legend is + * painted from exactly this: the pie branch keys its per-slice config off the + * x-axis field and hands it to the legend, so the legend text IS this string. + */ +const categories = () => (capturedChartProps?.schema?.data ?? []).map((r: any) => r.form); + +/** + * The SERVER-resolved chrome on the chart itself: the measure's series label, + * which is `fields[].label` off the analytics response verbatim. That is the + * field the card measured switching with `accept-language` while `rows[].form` + * stayed byte-identical — i.e. the proof the locale reaches the endpoint, and + * the same class of string as the Chinese axis labels the reporter saw. + */ +const serverChromeLabel = () => String(capturedChartProps?.schema?.series?.[0]?.label ?? ''); + +/** + * Drain every pending microtask and timer round, inside `act`, so the metadata + * effect's `setMeta` and the memo below it have both landed before anything is + * read. + * + * ⚠ This is NOT a `waitFor` on the expected value, and the difference is the + * whole reason it exists. The widget paints TWICE: once with the rows exactly + * as the server sent them, then again once the metadata read resolves. A + * `waitFor` on "three categories exist" is satisfied by the FIRST paint, so a + * starved path and a healthy one are indistinguishable at that moment and the + * green is a coin flip under load — measured, as a BASELINE that reddened once + * inside a batch while passing in isolation. A `waitFor` on the expected value + * would fix the race by telling the instrument the answer, which is the other + * thing this file must not do. Nothing here is unbounded — `fetch` is a mock + * resolving an already-settled promise — so a fixed number of flush rounds is + * deterministic rather than a timeout in disguise. + */ +async function settle(): Promise { + for (let i = 0; i < 6; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function fingerprint(container: HTMLElement, channel: MetaChannel): Promise { + await waitFor(() => expect(capturedChartProps).not.toBeNull()); + await settle(); + return { + chart: categories(), + list: (container.querySelector('[data-testid="list-cell"]')?.textContent ?? '').trim(), + serverChrome: serverChromeLabel(), + bundleChrome: (container.querySelector('[data-testid="bundle-chrome"]')?.textContent ?? '').trim(), + metaReads: [...channel.requested], + metaOutcomes: [...channel.outcomes], + }; +} + +const CHINESE = /[一-鿿]/; +const isChinese = (s: string) => CHINESE.test(s); + +/* ────────────────────────────────── the runs ─────────────────────────────── */ + +describe('objectui#7696 — which starvation path produces the reported symptom', () => { + it('BASELINE — a healthy install translates BOTH surfaces', async () => { + const { view, channel } = renderPage({}); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['一次性', '周期性', '常设']); + expect(fp.list).toBe('周期性'); + expect(fp.serverChrome).toBe('数量'); + expect(fp.bundleChrome).toBe('形式'); + expect(fp.metaReads).toEqual(['duly_duty']); + expect(fp.metaOutcomes).toEqual(['200']); + // Without this leg every case below would be consistent with a fixture that + // simply cannot translate anything. + }); + + it('PATH (1) — the response omits `object`: chart English, list correct, NO read issued', async () => { + const { view, channel } = renderPage({ omitObject: true }); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['One-off', 'Recurring', 'Standing']); + // The neighbouring list never consults the analytics response. + expect(fp.list).toBe('周期性'); + expect(fp.serverChrome).toBe('数量'); + expect(fp.bundleChrome).toBe('形式'); + // THE discriminator for this path: the net bails BEFORE reading, so the + // metadata channel is never touched at all. + expect(fp.metaReads).toEqual([]); + }); + + it('PATH (2) — the metadata read is unauthorized: chart English, list correct, read ATTEMPTED and refused', async () => { + const { view, channel } = renderPage({ chartDoc: null, listDoc: DUTY }); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['One-off', 'Recurring', 'Standing']); + // The list asked a DIFFERENT, authenticated channel and got the document. + expect(fp.list).toBe('周期性'); + expect(fp.serverChrome).toBe('数量'); + expect(fp.bundleChrome).toBe('形式'); + // THE discriminator against path (1): the read WAS issued, and refused. + expect(fp.metaReads).toEqual(['duly_duty']); + expect(fp.metaOutcomes).toEqual(['401']); + }); + + it('PATH (3a) — no inline options, list reads the object document: BOTH surfaces go English', async () => { + // Both channels serve the same document, because in a real install they are + // the same endpoint. This is the default list wiring. + const { view, channel } = renderPage({ chartDoc: DUTY_NO_OPTIONS, listDoc: DUTY_NO_OPTIONS }); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['One-off', 'Recurring', 'Standing']); + // ⚠ NOT the reported symptom. With no option list to match `recurring` + // against, the cell renderer humanizes the stored value instead. + expect(fp.list).toBe('Recurring'); + expect(isChinese(fp.list)).toBe(false); + expect(fp.serverChrome).toBe('数量'); + expect(fp.bundleChrome).toBe('形式'); + expect(fp.metaReads).toEqual(['duly_duty']); + expect(fp.metaOutcomes).toEqual(['200']); + }); + + it('PATH (3b) — no inline options, but the list COLUMN declares its own: chart English, list correct', async () => { + // The grid prefers a column-declared option list over the object document. + // So (3) reproduces the reported symptom only in installs whose list view + // carries that override — which is a property of the VIEW, not of the net. + const { view, channel } = renderPage({ + chartDoc: DUTY_NO_OPTIONS, + listDoc: DUTY_NO_OPTIONS, + listColumnOptions: FORM_OPTIONS, + }); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['One-off', 'Recurring', 'Standing']); + expect(fp.list).toBe('周期性'); + expect(fp.serverChrome).toBe('数量'); + expect(fp.bundleChrome).toBe('形式'); + expect(fp.metaReads).toEqual(['duly_duty']); + }); + + it('NEGATIVE CONTROL (4) — the app namespace is not discoverable: the CHROME goes English too', async () => { + const { view, channel } = renderPage({ resources: ZH_BUNDLE_UNDISCOVERABLE }); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['One-off', 'Recurring', 'Standing']); + // Both surfaces starve — they share one resolver and one namespace gate. + expect(fp.list).toBe('Recurring'); + // The axis label still arrives translated: it came off the RESPONSE, not + // the bundle. That is why the two chrome axes are recorded separately. + expect(fp.serverChrome).toBe('数量'); + // THE discriminator: the app-bundle chrome degrades to the authored string, + // and the reporter's title and description were Chinese. + expect(fp.bundleChrome).toBe('Form'); + // The bundle HAS every option translation — it is simply unreachable. + expect(ZH_BUNDLE_UNDISCOVERABLE.zh.duly.fieldOptions.duly_duty.form.recurring).toBe('周期性'); + }); + + it('NEGATIVE CONTROL (5) — no I18nProvider in scope: the CHROME goes English too', async () => { + const { view, channel } = renderPage({ provider: false }); + const fp = await fingerprint(view.container, channel); + + expect(fp.chart).toEqual(['One-off', 'Recurring', 'Standing']); + expect(fp.list).toBe('Recurring'); + expect(fp.serverChrome).toBe('数量'); + expect(fp.bundleChrome).toBe('Form'); + }); + + /** + * The decision. Stated as a comparison over the recorded fingerprints rather + * than as a claim about the reporter's install, which is not reachable from + * here: what is decided is which paths CAN produce what was reported, and + * which are excluded by it. + */ + it('DECISION — the reported fingerprint admits (1), (2) and (3b), and EXCLUDES (3a), (4) and (5)', async () => { + const runs: Array<[string, PageOptions]> = [ + ['1', { omitObject: true }], + ['2', { chartDoc: null }], + ['3a', { chartDoc: DUTY_NO_OPTIONS, listDoc: DUTY_NO_OPTIONS }], + ['3b', { chartDoc: DUTY_NO_OPTIONS, listDoc: DUTY_NO_OPTIONS, listColumnOptions: FORM_OPTIONS }], + ['4', { resources: ZH_BUNDLE_UNDISCOVERABLE }], + ['5', { provider: false }], + ]; + + const matches: string[] = []; + for (const [name, opts] of runs) { + const { view, channel } = renderPage(opts); + const fp = await fingerprint(view.container, channel); + // The reporter, in four readings: an English chart, a correct list, and + // chrome that is Chinese from BOTH of its sources. + const isReported = + !fp.chart.some(isChinese) && + isChinese(fp.list) && + isChinese(fp.serverChrome) && + isChinese(fp.bundleChrome); + if (isReported) matches.push(name); + cleanup(); + capturedChartProps = null; + } + + expect(matches).toEqual(['1', '2', '3b']); + }); +});