From cc8968f43fe749f12f4b7a72071db4678a05a219 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:41:14 +0000 Subject: [PATCH 1/3] fix(spec): the page-name route is canonical for a region-level `page:header` `translatePage` stops reading the id route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region level. That route was live and PREFERRED there, while the CLI extractor deliberately offers nothing under it for the same component -- so every key read through it was a key no tooling ever offered, counted or reported, and the header's `title` had two addresses while its `subtitle` had one. The gate mirrors the extractor's emission exception in `collectExpectedEntries` (same shape, opposite verb), so the failure pair `walkAddressedPageComponents` exists to prevent cannot reopen from the resolver side. A `page:header` nested inside a container is unchanged: the page-name route does not reach it, so it stays id-only. The walk's `addressed` arbitration is untouched -- a region-level header's id still claims its entry and still blocks a nested namesake, which is what the extractor does too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../test/platform-page-i18n-parity.test.ts | 160 ++++++++++++++++-- .../spec/src/system/i18n-resolver.test.ts | 26 ++- packages/spec/src/system/i18n-resolver.ts | 45 ++++- packages/spec/src/system/translation.zod.ts | 18 +- 4 files changed, 226 insertions(+), 23 deletions(-) diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 6c7eb87b7f..ac4ec32de8 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -352,14 +352,18 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { // Both directions, named separately so a failure says WHICH half broke. expect({ offeredButIgnored: [...offered].filter((id) => !applied.has(id)).sort() }) .toEqual({ offeredButIgnored: [] }); - // The ONE standing exception, pre-dating this card and deliberate: a - // region-level `page:header`'s copy is offered under `pages.PAGE.title` / - // `.subtitle` instead, because emitting it here too would offer one string - // under two keys. The resolver still honours the id route for it, so it - // shows up as applied-not-offered — listed explicitly rather than filtered - // out of the fixture, so the exception stays visible and bounded to one id. + // NO standing exception any more. This list used to read `['hdr']` — a + // region-level `page:header` carrying an id, whose copy the extractor + // deliberately offers under `pages.PAGE.title` / `.subtitle` while the + // resolver went on honouring the id route for it and PREFERRING it. That + // was the second half of the failure pair `walkAddressedPageComponents` + // exists to prevent (the resolver reading an id the extractor omits), and + // the 2026-09-06 ruling (decision batch #58) closed it by making the + // page-name route canonical for this component. Empty in BOTH directions + // is now the invariant; an entry reappearing here is a regression, not an + // exception to document. expect({ appliedButNotOffered: [...applied].filter((id) => !offered.has(id)).sort() }) - .toEqual({ appliedButNotOffered: ['hdr'] }); + .toEqual({ appliedButNotOffered: [] }); }); it('pins the two sets by name, so a shape that stops being reachable is visible', () => { @@ -369,11 +373,141 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { ]); // `card_body_child`, `card_footer_child`, `tab_child` and `slot_child` are // absent from BOTH sides — the shapes `translatePage` does not descend. + // `hdr` — the region-level `page:header` — is absent from BOTH sides since + // the ruling. `nested_header` stays: a `page:header` inside a container is + // reached by the id route only, so the id key is the only key it has. expect([...idsResolverApplies(page)].sort()).toEqual([ - 'card', 'hdr', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric', + 'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric', ]); }); + // ── The ruled invariant, pinned directly (decision batch #58, 2026-09-06) ── + // + // Maintainer 「同意」, option 1, verbatim: "The page-name route is canonical + // for a region-level `page:header`. `translatePage` stops reading the id + // route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region + // level; a `page:header` nested inside a container stays id-only, as its doc + // already says. One component, one address — `title` and `subtitle` now + // follow the same rule." + // + // The set comparisons above measure `title` alone, because that is the key + // both fixtures carry. This case walks the WHOLE shared key list and asserts + // BOTH verbs of the failure pair on one component, in one place: the id key + // is neither OFFERED nor READ. It also pins the three things that must NOT + // move with it — the page-name route still translates the header, a nested + // `page:header` is still id-addressed, and a bundle carrying both routes + // resolves to the page-name one — so a regression that simply stops + // translating region-level headers cannot pass here either. + const headerRoutePage = (): Record => ({ + name: 'header_route_page', + regions: [ + { + name: 'top', + components: [ + // Region level, WITH an id — the shape the card measured (hotcrm's + // five headers all carry one). + { id: 'home_header', type: 'page:header', properties: { title: 'Sales Home', subtitle: 'Welcome back' } }, + ], + }, + { + name: 'main', + components: [ + { + id: 'wrap', + type: 'page:card', + properties: { + title: 'Wrap', + children: [ + // Nested — the page-name route does not reach it, so the id + // route is the ONLY route it has. Unchanged by the ruling. + { id: 'inner_header', type: 'page:header', properties: { title: 'Inner header' } }, + ], + }, + }, + ], + }, + ], + }); + + /** Every key of the shared list under one id, sentinel-valued. */ + const everyKeyFor = (id: string): Record => + Object.fromEntries(PAGE_COMPONENT_COPY_KEYS.map((k) => [k, `ID::${id}::${k}`])); + + it('neither offers nor reads the id key for a region-level `page:header` (batch #58)', () => { + const page = headerRoutePage(); + + // Half one — the extractor offers NOTHING under `components.home_header`, + // for any key of the shared list. + expect(componentRows(page).filter((r) => r.key.startsWith('home_header.'))).toEqual([]); + + // Half two — the resolver reads nothing there either. The bundle offers + // every key of the shared list under that id; a sentinel reaching the + // output would mean the id route is still live for this component. + const idBundle = { + en: { + pages: { + header_route_page: { + components: { home_header: everyKeyFor('home_header'), wrap: everyKeyFor('wrap') }, + }, + }, + }, + } as any; + const viaId = translatePage(page as any, idBundle, { locale: 'en' }); + const header = viaId.regions[0].components[0]; + expect(header.properties).toEqual({ title: 'Sales Home', subtitle: 'Welcome back' }); + expect(header.label).toBeUndefined(); + // Positive control for that empty: the SAME bundle shape does reach a + // component the id route serves, so the two assertions above are a reading + // and not an inert fixture. + expect(viaId.regions[1].components[0].properties.title).toEqual('ID::wrap::title'); + + // Half three — the page-name route still translates the header. The fix is + // "one address", not "no address". + const nameBundle = { + en: { pages: { header_route_page: { title: 'BY-NAME', subtitle: 'SUB-BY-NAME' } } }, + } as any; + const viaName = translatePage(page as any, nameBundle, { locale: 'en' }); + expect(viaName.regions[0].components[0].properties) + .toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' }); + // ...and it stops at region level: the nested header keeps its literal. + expect(viaName.regions[1].components[0].properties.children[0].properties.title) + .toEqual('Inner header'); + + // Half four — a nested `page:header` is still reached, by the id route. + const nestedBundle = { + en: { + pages: { + header_route_page: { title: 'BY-NAME', components: { inner_header: { title: 'ID::inner' } } }, + }, + }, + } as any; + const viaNested = translatePage(page as any, nestedBundle, { locale: 'en' }); + expect(viaNested.regions[1].components[0].properties.children[0].properties.title) + .toEqual('ID::inner'); + // The extractor offers that nested id, which is the other half of "stays + // id-only" — the two sides agree about it as much as about the region one. + expect(componentRows(page).filter((r) => r.key === 'inner_header.title')) + .toEqual([{ key: 'inner_header.title', value: 'Inner header' }]); + + // Half five — the card's Leg A shape 3, inverted. Both routes present: + // BEFORE the ruling the id route won here ("ZH-by-ID"); the page-name + // route is canonical now, so the components key is inert on this component + // and the bundle that overrode a header title through it falls back. + const bothBundle = { + en: { + pages: { + header_route_page: { + title: 'BY-NAME', + subtitle: 'SUB-BY-NAME', + components: { home_header: { title: 'BY-ID' } }, + }, + }, + }, + } as any; + expect(translatePage(page as any, bothBundle, { locale: 'en' }).regions[0].components[0].properties) + .toEqual({ title: 'BY-NAME', subtitle: 'SUB-BY-NAME' }); + }); + it('carries the whole shared key list down into nesting, label either/or included', () => { const rows = componentRows(walkParityPage()); // `label` authored at the component's top level, the same either/or @@ -414,7 +548,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { .toEqual([{ key: 'twice.title', value: 'First nested wins' }]); // A region-level `page:header` emits nothing here, but its id still BLOCKS // a nested namesake — the resolver counts it as region-level, so offering - // the nested one would be a key the resolver ignores. + // the nested one would be a key the resolver ignores. The ruling changed + // what the resolver READS for that id, not who OWNS it: the arbitration is + // a property of the document and both consumers still decide it the same + // way, which is why this half is unchanged. expect(rows.filter((r) => r.key.startsWith('hdr_id.'))).toEqual([]); // And the resolver agrees about which component the entry lands on. @@ -432,7 +569,10 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { const translated = translatePage(page as any, bundle, { locale: 'en' }); const region = translated.regions[0].components; expect(region[0].properties.title).toEqual('S'); - expect(region[1].properties.title).toEqual('H'); + // `hdr_id` is the region-level `page:header`: the bundle's `H` under + // `components.hdr_id.title` is NOT read, and this page has no + // `pages.walk_collision_page.title` either, so the authored literal stands. + expect(region[1].properties.title).toEqual('Header holds this id'); const nested = region[2].properties.children; expect(nested.map((c: any) => c.properties.title)).toEqual([ 'Nested namesake loses', diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 7ddb91759c..38213cb02f 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1368,7 +1368,20 @@ describe('translatePage', () => { expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.'); }); - it('lets the id-addressed route win over the page-name route on a header that has an id', () => { + // Ruled 2026-09-06 (maintainer, verbatim 「同意」, decision batch #58, + // option 1): "The page-name route is canonical for a region-level + // `page:header`. `translatePage` stops reading the id route + // (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region + // level; a `page:header` nested inside a container stays id-only, as its + // doc already says. One component, one address — `title` and `subtitle` + // now follow the same rule." + // + // This test previously asserted the OPPOSITE (`title` resolving to + // `快速新建`, the id route beating the page-name one). It is inverted, not + // deleted, because the inversion IS the behaviour change the ruling + // records: the id route was live for this component and preferred, and a + // bundle that used it now falls back to the page-name key. + it('reads a region-level `page:header` by page name only, id or no id (batch #58)', () => { const doc = { name: 'sales_home_page', regions: [{ @@ -1384,10 +1397,15 @@ describe('translatePage', () => { }], }; const out = translatePage(doc, homeBundle, { locale: 'zh-CN' }); - // `components.quick_create.title` is more specific than `pages..label`. - expect(out.regions[0].components[0].properties.title).toBe('快速新建'); - // The page-name route still supplies what the id route did not. + // `components.quick_create.title` (`快速新建`) is NOT read here — the + // page-name route is, falling back to `pages..label` for `title`. + expect(out.regions[0].components[0].properties.title).toBe('销售看板'); expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来'); + // Control: the very same bundle entry still reaches the component that + // actually owns it, so the assertion above is about the header's route + // and not about a bundle that stopped resolving. + expect(byId(translatePage(homePage(), homeBundle, { locale: 'zh-CN' }), 'quick_create') + .properties.title).toBe('快速新建'); }); it('does not mutate the input page', () => { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 4c95369c62..ca37a54b82 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1663,12 +1663,25 @@ export function walkAddressedPageComponents( * `pages..label` so translators need not repeat a string that is normally * identical to the page's nav label. * + * **One component, one address.** A REGION-LEVEL `page:header` is addressed by + * page name and by nothing else: the id route + * (`pages..components..*`) is NOT read for it, even when it carries + * an id (ruled 2026-09-06, decision batch #58 — the page-name route is + * canonical). It used to be read there and to WIN, which put the header's + * `title` under two addresses while its `subtitle` — not in + * {@link PAGE_COMPONENT_COPY_KEYS} — only ever had one; the ruling makes the + * two keys of one component follow the same rule. It is also the half of the + * failure pair {@link walkAddressedPageComponents} exists to prevent that was + * still open: the CLI extractor deliberately offers nothing under + * `components.` for this component, so every key read here was a key no + * tooling ever offered, counted or reported. + * * Every OTHER component is addressed by its own `id` through * `pages..components.` (#6080), which overlays that component's * `properties` — the page half of what `dashboards..widgets.` has - * always given dashboards. Because the id route is the more specific of the - * two, it wins wherever both could apply (a `page:header` that does carry an - * id). + * always given dashboards. That includes a `page:header` NESTED in a + * container, which the page-name route does not reach (below) and which is + * therefore id-only. * * Components nested in a container's declared `properties.children` array are * visited too, recursively (#12961, ruled 2026-08-29). This REVERSES the @@ -1740,8 +1753,24 @@ export function translatePage( // within one call `lookupPageComponentCopy` is a pure function of the id // (bundle, page name and options are fixed), so the walk's claim-on-first- // sighting selects the same component a claim-on-resolved-lookup would. + // + // The one component this route does NOT serve is a REGION-LEVEL + // `page:header`: its copy is addressed by page name below, and reading + // `components.` for it too would give one string two addresses. The + // condition is written to MIRROR the extractor's emission exception in + // `collectExpectedEntries` (`packages/cli`) — same shape, opposite verb — + // so the pair the shared walk exists to prevent cannot reopen from this + // side. `nested` keeps a `page:header` inside a container on the id route, + // which is the only route that reaches it. + // + // The walk's `addressed` arbitration is deliberately NOT touched: a + // region-level `page:header`'s id still CLAIMS its bundle entry and still + // blocks a nested namesake. Which component owns an id is a property of + // the document, decided identically for every consumer of the walk; only + // whether this consumer READS the entry changes here, and the extractor + // blocks the namesake the same way. let copy: Partial> | undefined; - if (addressed) { + if (addressed && (nested || component.type !== PAGE_HEADER_COMPONENT)) { copy = lookupPageComponentCopy(bundle, name, id as string, opts); } @@ -1774,9 +1803,11 @@ export function translatePage( ...next, properties: { ...next.properties, - // The id-addressed copy above is more specific — do not overwrite what - // it already resolved for this header. - ...(headerTitle !== undefined && copy?.title === undefined ? { title: headerTitle } : {}), + // No `copy?.title` guard: the id route is not read for a region-level + // `page:header` at all, so there is nothing here to defer to. A guard + // that can never fire is a phantom check — it would read as "the id + // route still wins sometimes", which is exactly what the ruling ended. + ...(headerTitle !== undefined ? { title: headerTitle } : {}), ...(headerSubtitle !== undefined ? { subtitle: headerSubtitle } : {}), }, }; diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 0dd61b1d00..b116d3f5fa 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -835,6 +835,16 @@ const translationDataShape = () => ({ * because `page:header` instances carry no stable `id`; the page name is the * only addressable identifier for them. Every other component does have one, * which is what `components` addresses — see its own note. + * + * **One component, one address.** For a REGION-LEVEL `page:header` the two + * keys above are the ONLY route, even when the component happens to carry an + * `id`: `translatePage` does not read `components.` for it (ruled + * 2026-09-06, decision batch #58 — the page-name route is canonical). Before + * that ruling the id route was read here and WON, so a bundle could address + * one header `title` two ways while `subtitle` — excluded from `components` + * for exactly this reason, see its note below — only ever had one. A + * `page:header` NESTED inside a container is a different component: the + * page-name route does not reach it, so it stays id-only. */ pages: z.record(z.string(), strictObject({ surface: 'this page translation', @@ -868,7 +878,7 @@ const translationDataShape = () => ({ * * | key | declared by | * |:---|:---| - * | `title` | `page:card`, `record:related_list` (and `page:header`, see below) | + * | `title` | `page:card`, `record:related_list` (and a NESTED `page:header` — a region-level one is page-name-addressed, see the `subtitle` note below) | * | `label` | `page:tabs`, `page:accordion`, `record:details`, `record:related_list`, `record:path`, `element:button`, `element:record_picker`, `element:text_input` | * | `description` | `element:text_input` | * | `placeholder` | `element:record_picker`, `element:text_input` | @@ -896,7 +906,11 @@ const translationDataShape = () => ({ * - **`subtitle` is not here** — `page:header` is its only declarer, and * that component is addressed by page name above. Declaring it in both * places would give one string two spellings, which is how the - * dashboards/pages asymmetry started. + * dashboards/pages asymmetry started. The 2026-09-06 ruling settled the + * same question for its sibling `title` in the same direction: a + * region-level `page:header` is read by page name only, so the component + * now has ONE address for both of its keys rather than two for one of + * them. * - **`content` is not here** — `element:text`'s one authored string is * declared `content: I18nLabelSchema` (`ui/component.zod.ts`), so it is * localizable at its own authoring site, and adding it to this face would From 59d91d1230b96182d61ff6da06b81cdd80d87007 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:22:00 +0000 Subject: [PATCH 2/3] docs(spec): drop the stale "the id wins" sentence and add the changeset The visitor's lead comment still described the id route as beating the page-name route for a header that carries an id -- the sentence the gate below it now contradicts. Rewritten to say what the code does. Changeset: `minor` on `@objectstack/spec`, stating the behaviour change and the FROM -> TO mapping for a bundle that used the components key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../page-header-page-name-route-canonical.md | 40 +++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 15 ++++--- 2 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 .changeset/page-header-page-name-route-canonical.md diff --git a/.changeset/page-header-page-name-route-canonical.md b/.changeset/page-header-page-name-route-canonical.md new file mode 100644 index 0000000000..c393593b3a --- /dev/null +++ b/.changeset/page-header-page-name-route-canonical.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +--- + +`translatePage` now reads a region-level `page:header` by **page name only**. The id route +(`pages..components..*`) is no longer read for that component, even when it carries an `id`. + +**Behaviour change, stated plainly:** a bundle that overrode a region-level header's title through +`pages..components..title` now falls back to `pages..title` (which itself falls back to +`pages..label`). The components key still parses — nothing is removed from `TranslationBundleSchema` — it is +simply no longer the address for this one component. + +``` +FROM pages..components..title # region-level page:header — no longer read +TO pages..title # …and .subtitle for the subtitle +``` + +Fix in one line: move the string from the components entry up to the page's own `title` key, and delete the +components entry for that header id. `os i18n extract` has always offered exactly the `TO` key, so a bundle +generated or checked by the CLI already writes it. + +**Blast radius, as measured on the card (inherited, not re-measured here):** HotCRM found **zero** such overrides — +all five of its region-level headers carry ids and none writes the components key. + +**Why.** Both sides were deliberate and they disagreed. The extractor skips a region-level `page:header` on purpose +(its copy is offered under `pages..title` / `.subtitle`, and emitting it twice would put one string under two +keys); the resolver read the id route on purpose (the more specific route wins). Together they produced the exact +failure `walkAddressedPageComponents` was extracted to prevent — the resolver reading an id the extractor omits — so +the key an author reached for won silently while the key the tooling reported as translated lost. The maintainer +ruled (2026-09-06, decision batch #58, verbatim 「同意」) that the page-name route is canonical: one component, one +address. `title` and `subtitle` now follow the same rule, closing the asymmetry where `title` had two addresses and +`subtitle` — never in `PAGE_COMPONENT_COPY_KEYS` — had one. + +Unchanged: a `page:header` **nested** inside a container is reached by the id route only, as it always has been; and +a region-level header's id still claims its bundle entry and still blocks a nested namesake, which is what the +extractor does too. + +Not `major`: nothing an author can write is removed or renamed. `pages..components.` remains a declared, +parsing, resolving address for every other component — including a nested `page:header` — so there is no key to +tombstone and no ADR-0087 conversion to register. Recorded here so the choice is checkable rather than assumed. diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index ca37a54b82..8162eca7d4 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1745,14 +1745,13 @@ export function translatePage( // `children` after the visitor returns, so the overlay never contends with // the descent for a key (`children` is not a copy key). const regions = walkAddressedPageComponents(doc, (component, { nested, id, addressed }) => { - // Per-component copy (#6080) — addressed by the component's own id, so it - // is strictly more specific than the page-name route below and is applied - // first. A `page:header` that DOES carry an id can therefore be translated - // either way, and the id wins. `addressed` carries the ruled collision - // arbitration (#12961), so a looked-up entry is this component's alone; - // within one call `lookupPageComponentCopy` is a pure function of the id - // (bundle, page name and options are fixed), so the walk's claim-on-first- - // sighting selects the same component a claim-on-resolved-lookup would. + // Per-component copy (#6080) — addressed by the component's own id, and + // applied before the page-name route below. `addressed` carries the ruled + // collision arbitration (#12961), so a looked-up entry is this component's + // alone; within one call `lookupPageComponentCopy` is a pure function of + // the id (bundle, page name and options are fixed), so the walk's + // claim-on-first-sighting selects the same component a + // claim-on-resolved-lookup would. // // The one component this route does NOT serve is a REGION-LEVEL // `page:header`: its copy is addressed by page name below, and reading From 07d4cd435767058f8baef669a70971ce374bccf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:13:05 +0000 Subject: [PATCH 3/3] test(spec): pin that the id route is NOT read for a region-level page:header The batch #58 pin in i18n-resolver.test.ts stayed green with the gate reverted to `if (addressed)`: homeBundle carries pages.sales_home_page.label, so the page-name overlay wins for `title` whether or not the id route was read. The test pinned precedence (page name wins when both routes are present) while its title claimed "page name only" - a pin that cannot fail on the regression it names. Split into two tests. The precedence pin keeps its body under a title that says what it measures. A new pin hands translatePage an id-only bundle (no pages.PAGE.label / title / subtitle), so the page-name overlay has nothing to win with and only the id route could move the header title, and asserts the authored title survives. Two controls keep the empty reading honest: the same id-only bundle translates the component that owns the id, and a nested page:header with that id stays id-addressed. Only the test file changes; the resolver, extractor, cli parity pin and changeset are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../spec/src/system/i18n-resolver.test.ts | 93 ++++++++++++++----- 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 38213cb02f..afbb5525fa 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1376,29 +1376,44 @@ describe('translatePage', () => { // doc already says. One component, one address — `title` and `subtitle` // now follow the same rule." // - // This test previously asserted the OPPOSITE (`title` resolving to - // `快速新建`, the id route beating the page-name one). It is inverted, not - // deleted, because the inversion IS the behaviour change the ruling - // records: the id route was live for this component and preferred, and a - // bundle that used it now falls back to the page-name key. - it('reads a region-level `page:header` by page name only, id or no id (batch #58)', () => { - const doc = { - name: 'sales_home_page', - regions: [{ - name: 'header', - // `properties` widened to the open bag it is: the overlay adds keys - // the literal does not spell out, and inferring it as `{title}` alone - // would make reading the result a type error. - components: [{ - type: 'page:header', - id: 'quick_create', - properties: { title: 'Sales Home' } as Record, - }], + // The ruling has two halves, and one bundle cannot measure both — so two + // pins. `homeBundle` carries `pages.sales_home_page.label`, and the + // page-name overlay is applied AFTER the id route and wins for `title` + // whether or not the id route was read: against that bundle the header's + // `title` reads `销售看板` on a resolver that still reads the id route. + // That is a real invariant (precedence when both routes are present) and + // the first pin names it as exactly that — it is NOT evidence that the id + // route is closed. The second pin hands the resolver an id-ONLY bundle — + // no `pages..label`, no `pages..title` — so the page-name + // overlay has nothing to win with and only the id route could move the + // title. Measured with the gate reverted to `if (addressed)`: the first + // pin stays green, the second goes red. + // + // A region-level `page:header` that carries an id. `properties` is + // widened to the open bag it is: the overlay adds keys the literal does + // not spell out, and inferring it as `{title}` alone would make reading + // the result a type error. + const regionHeaderWithId = () => ({ + name: 'sales_home_page', + regions: [{ + name: 'header', + components: [{ + type: 'page:header', + id: 'quick_create', + properties: { title: 'Sales Home' } as Record, }], - }; - const out = translatePage(doc, homeBundle, { locale: 'zh-CN' }); - // `components.quick_create.title` (`快速新建`) is NOT read here — the - // page-name route is, falling back to `pages..label` for `title`. + }], + }); + + // Previously this asserted the OPPOSITE (`title` resolving to `快速新建`, + // the id route beating the page-name one). Inverted, not deleted, because + // the inversion IS the behaviour change the ruling records: the id route + // was live for this component and preferred, and a bundle that used it + // now falls back to the page-name key. + it('prefers the page-name route over the id route for a region-level `page:header` when a bundle carries both (batch #58)', () => { + const out = translatePage(regionHeaderWithId(), homeBundle, { locale: 'zh-CN' }); + // `components.quick_create.title` (`快速新建`) does not win here — the + // page-name route does, falling back to `pages..label` for `title`. expect(out.regions[0].components[0].properties.title).toBe('销售看板'); expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来'); // Control: the very same bundle entry still reaches the component that @@ -1408,6 +1423,40 @@ describe('translatePage', () => { .properties.title).toBe('快速新建'); }); + it('does not read the id route for a region-level `page:header` — an id-only bundle leaves its authored title alone (batch #58)', () => { + // No `pages..label` / `title` / `subtitle`: the page-name route + // resolves nothing for this page, so a translated title on the header + // could only have come from `components.quick_create.title`. + const idOnly: TranslationBundle = { + 'zh-CN': { pages: { sales_home_page: { components: { quick_create: { title: '快速新建' } } } } }, + }; + const out = translatePage(regionHeaderWithId(), idOnly, { locale: 'zh-CN' }); + expect(out.regions[0].components[0].properties.title).toBe('Sales Home'); + // Control 1: the same id-only bundle DOES translate the component that + // owns the id, so the authored title above is the header's id route + // being closed and not a bundle nothing can resolve. + expect(byId(translatePage(homePage(), idOnly, { locale: 'zh-CN' }), 'quick_create') + .properties.title).toBe('快速新建'); + // Control 2: a `page:header` NESTED in a container stays id-addressed — + // the ruling closes the id route at region level only, and the id route + // is the only one that reaches a nested header. + const nestedDoc = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [{ + type: 'page:card', + id: 'wrapper', + properties: { + children: [{ type: 'page:header', id: 'quick_create', properties: { title: 'Sales Home' } as Record }], + }, + }], + }], + }; + const nested = translatePage(nestedDoc, idOnly, { locale: 'zh-CN' }); + expect((nested.regions[0].components[0].properties.children as any[])[0].properties.title).toBe('快速新建'); + }); + it('does not mutate the input page', () => { const doc = homePage(); const snapshot = JSON.parse(JSON.stringify(doc));