From 9443c1dcecec7438044625664f3046bc792c153b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:42:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20=E4=B8=89=E4=B8=AA=E6=89=8B?= =?UTF-8?q?=E5=86=99=20unrecognized=5Fkeys=20error=20map=20=E6=8A=8A?= =?UTF-8?q?=E5=A4=84=E6=96=B9=E6=8E=92=E5=88=B0=E8=AF=B4=E6=98=8E=E5=8F=A5?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=20(#6416)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `strictVisibilityError` (shared/visibility.ts)、`strictWidgetAnalyticsError` (ui/dashboard.zod.ts)、`strictTenancyError` (data/object.zod.ts) 都是独立的 `$ZodErrorMap`,既不经过 `strictUnknownKeyError`(所以 #5955 的修法够不到), 也不在 #5593 迁移的 44 个直接调用点里。三者都复现了 #5955 针对的形状:一句 不可操作的说明句夹在「哪个键错了」与修复它的处方之间 —— 在多个消费方的单行 渲染上(`os validate` 的 `• where: message`、CI 日志、会主动抹平换行的 `validateFlowTriggerReadiness`),处方被推出作者真正会读的那一段。 现在统一为 PR #6375 为共享模板落地的顺序:前置语(哪个键错了)→ 全部修复 通道(`visibleWhen` 别名指针;ADR-0021 dataset / objectui 隔离 / #5022 drill 三个分支;`tenancy` 的逐键 tombstone bullets)→ 说明句放最后。没有删除任何 文本,也没有让任何句子变成有条件的:七个消息变体在长度与字符多重集上与原拼接 逐字节相同。接受面不动 —— 这些 map 只塑造一个已经失败的 parse 的文本, `visibility.ts` 的别名表原样未改。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --- .../handwritten-errmap-fix-before-history.md | 9 ++ packages/spec/src/data/object.test.ts | 67 +++++++++++++++ packages/spec/src/data/object.zod.ts | 26 +++++- packages/spec/src/shared/visibility.ts | 30 +++++-- packages/spec/src/ui/dashboard.test.ts | 85 +++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 38 +++++++-- packages/spec/src/ui/view.test.ts | 70 +++++++++++++++ 7 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 .changeset/handwritten-errmap-fix-before-history.md diff --git a/.changeset/handwritten-errmap-fix-before-history.md b/.changeset/handwritten-errmap-fix-before-history.md new file mode 100644 index 0000000000..2b6ce0f230 --- /dev/null +++ b/.changeset/handwritten-errmap-fix-before-history.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +--- + +Reorder the three hand-written `unrecognized_keys` error maps so the fix is read before the explanation (#6416, applying #5955's ruling). + +`strictVisibilityError` (`shared/visibility.ts`), `strictWidgetAnalyticsError` (`ui/dashboard.zod.ts`) and `strictTenancyError` (`data/object.zod.ts`) are independent `$ZodErrorMap` functions rather than `strictUnknownKeyError` call sites, so #5955's reorder of the shared template did not reach them and #5593's `strictObject` migration cannot either. Each reproduced the exact shape #5955 was filed against: a non-actionable explanatory sentence sitting between the offending key and the prescription that fixes it, which on the single-line renders several consumers use (`os validate`'s `• where: message`, CI logs, `validateFlowTriggerReadiness`) pushed the fix out of the part an author actually reads. + +Every message now emits front matter (which key is wrong) → every fix channel (the `visibleWhen` alias pointer; the ADR-0021 dataset / objectui-quarantine / #5022 drill branches; the per-key `tenancy` tombstone bullets) → the explanatory sentence last. Nothing is deleted and nothing becomes conditional — each sentence is still emitted verbatim, once per message, and all seven message variants are byte-identical in length and character multiset to their previous spelling. No input changes acceptance: these maps only shape the text of an already-failing parse, and the `visibility.ts` alias tables are untouched. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 933aa01f3b..feb8434fb3 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1521,6 +1521,73 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () }); }); +/** + * Message ORDER on `strictTenancyError` (#6416, applying #5955's ruling). + * + * A hand-written `$ZodErrorMap`: it never calls `strictUnknownKeyError`, so + * #5955's reorder of the shared template did not reach it, and it is not one of + * the 44 direct call sites #5593 migrates to `strictObject` either. Its + * explanatory sentence is the standing two-modes explainer, and its FIX channel + * is the per-key ` • ` bullets built just above it — the tombstone that tells + * an upgrading author what to write instead. Those bullets used to sit BEHIND + * ~160 characters of standing background, which is past the front of the + * single-line renders several consumers use (`os validate`'s `• where: message`, + * CI logs). + * + * ORDER pins, not presence checks. Every `toContain` in the block above stays + * green under either order — that is exactly why they cannot carry this fact. + */ +describe('strictTenancyError message order — bullets before the explainer (#6416)', () => { + const EXPLAINER = + 'The two supported tenancy modes are: database-per-tenant = environment-level ' + + 'deployment (no object config); row-level isolation = `tenancy.enabled` + ' + + '`tenancy.tenantField`.'; + + const messageFor = (body: Record) => { + const res = TenancyConfigSchema.safeParse({ enabled: true, ...body }); + expect(res.success).toBe(false); + const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + return unknown!.message; + }; + + it('names the wrong key first, then the tombstone bullet, then the explainer', () => { + const m = messageFor({ strategy: 'isolated' }); + // 1. which key is wrong — and nothing before it + expect(m.startsWith('Unrecognized key(s) on `tenancy`: `strategy`.\n')).toBe(true); + // 2. the fix channel: the per-key bullet, on the line right after + expect(m).toContain('\n • `tenancy.strategy` was removed from @objectstack/spec after v15.0'); + // 3. the explainer, verbatim, last — moved, never dropped + expect(m.indexOf('Delete the key.')).toBeLessThan(m.indexOf(EXPLAINER)); + expect(m.endsWith(` ${EXPLAINER}`)).toBe(true); + }); + + it('keeps EVERY per-key bullet ahead of the explainer, not just the first', () => { + // One issue names every offending key, so the explainer is a per-MESSAGE + // sentence: a reorder that put it after the first bullet would bury the rest. + const m = messageFor({ strategy: 'isolated', crossTenantAccess: true, tenantfield: 'org_id' }); + for (const bullet of [ + '`tenancy.strategy` was removed', + '`tenancy.crossTenantAccess` was removed', + '`tenantfield` is not a `tenancy` key.', + ]) { + expect(m).toContain(bullet); + expect(m.indexOf(bullet), bullet).toBeLessThan(m.indexOf(EXPLAINER)); + } + expect(m.split(EXPLAINER)).toHaveLength(2); + expect(m.endsWith(` ${EXPLAINER}`)).toBe(true); + }); + + it('is a full-message pin for the plain unknown-key case', () => { + // Any stray separator, dropped newline or duplicated clause fails here. + expect(messageFor({ tenantfield: 'org_id' })).toBe( + 'Unrecognized key(s) on `tenancy`: `tenantfield`.\n' + + ' • `tenantfield` is not a `tenancy` key. ' + + EXPLAINER, + ); + }); +}); + describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-0066)', () => { it('is true only for an explicit tenancy.enabled === false', () => { expect(isTenancyDisabled({ name: 'sys_license', tenancy: { enabled: false } })).toBe(true); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 9b68752a58..7b7002456e 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -421,6 +421,24 @@ const TENANCY_RETIRED_KEY_GUIDANCE: Record = { * `strategy`/`crossTenantAccess` or a typo — is a loud, *fixable* parse error * instead of a silent strip (#1535), and a retired key's error carries its * upgrade prescription. Every other issue code defers to zod's default. + * + * ## Message order: the fix comes before the explainer (#5955 / #6416) + * + * ```text + * Unrecognized key(s) on `tenancy`: `k1`. ← which key is wrong + * • {per-key tombstone / "not a `tenancy` key"} ← the fix + * The two supported tenancy modes are: … ← the standing explainer + * ``` + * + * Same emission order the shared `strictUnknownKeyError` template took in + * #5955 — bullets first, the surface-level sentence appended to the last one. + * A hand-written `$ZodErrorMap` is reachable by neither that fix nor #5593's + * `strictObject` migration, so #6416 applies the ruling here directly. The + * two-modes explainer used to sit between the key statement and the bullets, + * which on the single-line renders several consumers use (`os validate`'s + * `• where: message`, CI logs) buried each key's actual prescription behind + * ~160 characters of standing background. Nothing is dropped: the explainer is + * still emitted verbatim, just last. */ const strictTenancyError: z.core.$ZodErrorMap = (issue) => { if (issue.code !== 'unrecognized_keys') return undefined; @@ -429,11 +447,11 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => { TENANCY_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a \`tenancy\` key.`, ); return ( - `Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}. ` + - 'The two supported tenancy modes are: database-per-tenant = environment-level ' + + `Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}.\n` + + lines.map((l) => ` • ${l}`).join('\n') + + ' The two supported tenancy modes are: database-per-tenant = environment-level ' + 'deployment (no object config); row-level isolation = `tenancy.enabled` + ' + - '`tenancy.tenantField`.\n' + - lines.map((l) => ` • ${l}`).join('\n') + '`tenancy.tenantField`.' ); }; diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts index f2050e4b9b..028cd48d40 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -90,22 +90,42 @@ function looksLikeVisibilityKey(key: string): boolean { * .strict() * .transform(normalizeVisibleWhen) * ``` + * + * ## Message order: the fix comes before the history (#5955 / #6416) + * + * ```text + * Unrecognized key(s) on this view/page schema: `k1`. ← which key is wrong + * [ If this is the conditional-visibility predicate … ] ← the fix + * Before ADR-0089 D3a these were dropped silently … ← why it used to be silent + * ``` + * + * This map is a hand-written `$ZodErrorMap`, so #5955's fix to the shared + * `strictUnknownKeyError` template could not reach it and #5593's + * `strictObject` migration cannot either — #6416 applies the same ruling here. + * The history sentence used to sit between the key statement and the alias + * pointer, which is the position several consumers render on ONE line + * (`os validate`'s `• where: message`, CI logs, and + * `validateFlowTriggerReadiness`, which flattens the newlines): an author — + * often an AI — reads the front of that line and acts on it, so the canonical + * key has to be there. Nothing is dropped and nothing is conditional; the + * sentence is still emitted verbatim, just last. */ export const strictVisibilityError: z.core.$ZodErrorMap = (issue) => { if (issue.code !== 'unrecognized_keys') return undefined; const keys = (issue as { keys?: readonly string[] }).keys ?? []; const list = keys.map((k) => `\`${k}\``).join(', '); - const base = - `Unrecognized key(s) on this view/page schema: ${list}. ` + + const front = `Unrecognized key(s) on this view/page schema: ${list}.`; + const history = `Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ` + `a mis-layered or stale key is now a loud parse error.`; if (keys.some(looksLikeVisibilityKey)) { return ( - base + + front + ' If this is the conditional-visibility predicate, the canonical key is ' + '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' + - 'component) are still accepted as deprecated aliases.' + 'component) are still accepted as deprecated aliases. ' + + history ); } - return base; + return `${front} ${history}`; }; diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index fdf056adb2..395fa84b5a 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -125,6 +125,91 @@ describe('DashboardWidgetSchema (dataset-bound)', () => { }); }); +/** + * Message ORDER on `strictWidgetAnalyticsError` (#6416, applying #5955's ruling). + * + * This map is a hand-written `$ZodErrorMap`, so neither #5955 (which moved the + * history sentence to the end inside the shared `strictUnknownKeyError`) nor + * #5593 (which migrates the direct call sites to `strictObject`) reached it. It + * carried the same defect: a ~150-char history sentence sitting BETWEEN the + * offending key and whichever of the three prescription branches fixes it — + * the ADR-0021 dataset migration, the objectui `component`/`data` quarantine, + * and the #5022 drill near-key answer — pushing all three past the front of the + * single-line renders several consumers use. + * + * ORDER pins, not presence checks: the reorder deletes nothing, so every + * `toContain` in the block above stays green either way. An edit that folds the + * sentence back into the middle passes all of them and fails here. + */ +describe('strictWidgetAnalyticsError message order — fix before history (#6416)', () => { + const HISTORY = + 'Undeclared top-level keys were dropped silently before strict validation, ' + + 'shipping inert metadata; a stale or mis-layered key is now a loud parse error.'; + + const base = { id: 'w1', type: 'metric', dataset: 'sales', values: ['revenue'] }; + const messageFor = (extra: Record) => { + const res = DashboardWidgetSchema.safeParse({ ...base, ...extra } as any); + expect(res.success).toBe(false); + const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + return unknown!.message; + }; + + const orderPin = (label: string, extra: Record, key: string, fix: string) => { + it(label, () => { + const m = messageFor(extra); + // 1. which key is wrong — and nothing before it + expect(m.startsWith(`Unrecognized key(s) on this dashboard widget: \`${key}\`.`)).toBe(true); + // 2. the fix, ahead of the history + expect(m).toContain(fix); + expect(m.indexOf(fix)).toBeLessThan(m.indexOf(HISTORY)); + // 3. the history sentence, verbatim, last — moved, never dropped + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + }); + }; + + orderPin( + 'legacy inline-analytics branch: the ADR-0021 dataset prescription comes first', + { categoryField: 'stage' }, + 'categoryField', + 'The pre-ADR-0021 inline analytics shape', + ); + + orderPin( + 'quarantine branch: the objectui-internal verdict comes first', + { component: {} }, + 'component', + '`component` and inline `data` are objectui-internal renderer capabilities', + ); + + orderPin( + 'drill branch (#5022): the "AUTOMATIC" answer comes first', + { drillDown: { enabled: true } }, + 'drillDown', + 'Drill-through on a dashboard is AUTOMATIC and not configurable per widget', + ); + + it('keeps the whole drill answer ahead of the history, not just its opening', () => { + // The #5022 branch is the longest of the three; its two "where the real + // drills live" pointers are the actionable part and must not slip behind. + const m = messageFor({ drillDown: { enabled: true } }); + expect(m.indexOf('`ChartDrillDownSchema`')).toBeLessThan(m.indexOf(HISTORY)); + expect(m.indexOf('`ReportSchema.drilldown` (ADR-0021 D2, on by default).')) + .toBeLessThan(m.indexOf(HISTORY)); + }); + + it('is unchanged in SHAPE when no branch matches — full-message pin', () => { + expect(messageFor({ colourVariant: 'blue' })) + .toBe(`Unrecognized key(s) on this dashboard widget: \`colourVariant\`. ${HISTORY}`); + }); + + it('emits the history exactly once, whatever the key count', () => { + const m = messageFor({ categoryField: 'stage', alsoWrong: 1, andThis: 2 }); + expect(m.split(HISTORY)).toHaveLength(2); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + }); +}); + describe('DashboardSchema', () => { it('parses a dataset-bound dashboard', () => { const d = DashboardSchema.parse({ diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 07f86da503..55c725c472 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -141,29 +141,48 @@ const QUARANTINED_WIDGET_KEYS = new Set(['component', 'data']); * objectui-internal prop it points the author at the ADR-0021 dataset shape * (and `options` for renderer-specific extras). Mirrors `strictVisibilityError` * (ADR-0089 D3a); every other issue code defers to zod's default. + * + * ## Message order: the fix comes before the history (#5955 / #6416) + * + * ```text + * Unrecognized key(s) on this dashboard widget: `k1`. ← which key is wrong + * [ one of the three prescription branches ] ← the fix + * Undeclared top-level keys were dropped silently … ← why it used to be silent + * ``` + * + * Hand-written `$ZodErrorMap`s were out of reach of both #5955 (which moved the + * sentence inside the shared `strictUnknownKeyError`) and #5593 (which migrates + * the direct call sites to `strictObject`); #6416 applies the same ruling here. + * The history sentence used to sit between the key statement and the branch that + * fixes it, pushing every prescription — the ADR-0021 dataset migration, the + * objectui quarantine, the #5022 drill answer — past character ~220 of a message + * several consumers render on ONE line. Nothing is dropped or made conditional: + * the sentence is still emitted verbatim, just last. */ const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => { if (issue.code !== 'unrecognized_keys') return undefined; const keys = (issue as { keys?: readonly string[] }).keys ?? []; const list = keys.map((k) => `\`${k}\``).join(', '); - const base = - `Unrecognized key(s) on this dashboard widget: ${list}. ` + + const front = `Unrecognized key(s) on this dashboard widget: ${list}.`; + const history = `Undeclared top-level keys were dropped silently before strict validation, ` + `shipping inert metadata; a stale or mis-layered key is now a loud parse error.`; if (keys.some((k) => LEGACY_WIDGET_ANALYTICS_KEYS.has(k))) { return ( - base + + front + ' The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' + '`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' + 'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' + - 'settings belong under `options`.' + 'settings belong under `options`. ' + + history ); } if (keys.some((k) => QUARANTINED_WIDGET_KEYS.has(k))) { return ( - base + + front + ' `component` and inline `data` are objectui-internal renderer capabilities, ' + - 'not part of the author-facing dashboard spec (framework#3251).' + 'not part of the author-facing dashboard spec (framework#3251). ' + + history ); } // #5022 — the drill near-key, in all three spellings an author reaches for. @@ -175,7 +194,7 @@ const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => { // between two real keys on two other surfaces. if (keys.some((k) => k === 'drillDown' || k === 'drilldown' || k === 'drill')) { return ( - base + + front + ' Drill-through on a dashboard is AUTOMATIC and not configurable per widget: ' + 'a dataset-bound widget derives the drill target and filter from the dataset row ' + 'that was clicked, and a `table`/`pivot` widget is the one to reach for when you ' + @@ -183,10 +202,11 @@ const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => { 'The two configurable drills live elsewhere and neither is a widget key: ' + '`drillDown` (camelCase, a config object) is the react-tier `` ' + 'prop — `ChartDrillDownSchema`; `drilldown` (all lowercase, a boolean) is ' + - '`ReportSchema.drilldown` (ADR-0021 D2, on by default).' + '`ReportSchema.drilldown` (ADR-0021 D2, on by default). ' + + history ); } - return base; + return `${front} ${history}`; }; /** diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 3aace18a05..448b87f926 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2953,6 +2953,76 @@ describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', () }); }); +/** + * Message ORDER on `strictVisibilityError` (#6416, applying #5955's ruling). + * + * The map in `shared/visibility.ts` is a hand-written `$ZodErrorMap`: it never + * calls `strictUnknownKeyError`, so #5955's reorder of the shared template did + * not reach it, and it is not one of the 44 direct call sites #5593 migrates to + * `strictObject` either. It had the same defect the ruling was filed against — + * a ~120-char history sentence sitting BETWEEN the offending key and the + * canonical-key pointer that fixes it, which is past the front of the single- + * line renders several consumers use (`os validate`'s `• where: message`, CI + * logs, and `validateFlowTriggerReadiness`, which flattens the newlines). + * + * These are ORDER pins, not presence checks. The reorder deletes nothing, so + * every existing `toContain` in the block above stays green either way; a + * future edit that folds the sentence back into the middle passes all of them + * and fails here. + */ +describe('strictVisibilityError message order — fix before history (#6416)', () => { + const HISTORY = + 'Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ' + + 'a mis-layered or stale key is now a loud parse error.'; + const PRESCRIPTION = 'the canonical key is `visibleWhen` (ADR-0089)'; + + const messageFor = (body: Record) => { + const res = FormFieldSchema.safeParse({ field: 'state', ...body }); + expect(res.success).toBe(false); + const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + return unknown!.message; + }; + + it('names the wrong key first, then the alias pointer, then the history', () => { + const m = messageFor({ visibleWhenn: 'record.a == 1' }); + // 1. which key is wrong — and nothing before it + expect(m.startsWith('Unrecognized key(s) on this view/page schema: `visibleWhenn`.')).toBe(true); + // 2. the fix, immediately after it — this is the whole point of the reorder + expect(m).toContain('`visibleWhenn`. If this is the conditional-visibility predicate'); + // 3. the history sentence, verbatim, last — moved, never dropped + expect(m).toContain(PRESCRIPTION); + expect(m.indexOf(PRESCRIPTION)).toBeLessThan(m.indexOf(HISTORY)); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + }); + + it('still emits the whole alias table after the reorder — nothing was trimmed', () => { + // The prescription names all three spellings; a reorder that quietly lost + // one of them would still satisfy the order assertions above. + const m = messageFor({ visibleWhenn: 'record.a == 1' }); + expect(m).toContain('`visibleOn` (view form)'); + expect(m).toContain('`visibility` (page component) are still accepted as deprecated aliases.'); + }); + + it('is unchanged in SHAPE when there is no visibility hint to offer', () => { + // No prescription branch — the sentence follows the key statement directly, + // exactly as it always did. Full-message pin, so a stray separator or a + // duplicated clause fails here. + const res = FormSectionSchema.safeParse({ label: 'S', fields: [], bogusKey: true }); + expect(res.success).toBe(false); + const m = res.error!.issues.find((i) => i.code === 'unrecognized_keys')!.message; + expect(m).toBe(`Unrecognized key(s) on this view/page schema: \`bogusKey\`. ${HISTORY}`); + }); + + it('emits the history exactly once, whatever the key count', () => { + // One `unrecognized_keys` issue names every offending key, so "last" is a + // well-defined position: the sentence is appended to that one message once. + const m = messageFor({ visibleWhenn: 'record.a == 1', alsoWrong: 2, andThis: 3 }); + expect(m.split(HISTORY)).toHaveLength(2); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + }); +}); + /** * ObjectUI Studio derives the View inspector's authoring JSONSchema from * ViewSchema via `z.toJSONSchema` (objectui#2561). FormFieldSchema is a