diff --git a/.changeset/lucky-pugs-shave.md b/.changeset/lucky-pugs-shave.md new file mode 100644 index 0000000000..9f19fccdba --- /dev/null +++ b/.changeset/lucky-pugs-shave.md @@ -0,0 +1,28 @@ +--- +"@object-ui/app-shell": patch +--- + +metadata-admin: name the offending key when only one union member ever read the value + +A union with no discriminant reports its failure as one collapsed issue, and the +member diagnostics that would name the problem are buried inside it. PR #3677 +started unpacking those for `config.columns` by reading the value's own content, +but deliberately declined every union where some member had rejected the value's +type outright — which left `config.sort` (`string | ColumnSort[]`) collapsed even +though only one of its two members had read the value at all. + +When exactly one member accepted the value's type, naming it is a fact rather +than a preference: it is the only member whose complaint can be about what the +author wrote. So `sort: [{ field: 'n', order: 'bogus' }]` now reports +`config.sort.0.order` with the spec's own `expected one of "asc" | "desc"` +instead of `config.sort` / `Invalid input`, and the same holds for a sort row +that is not an object, a `columns[].summary` written as a bad enum string, a form +`sections[].fields[]` entry missing its `field`, and an array `filter[].value` +whose offending element is now addressed directly. + +Where two or more members read the value, or where none did, nothing changes: +the previous message is kept rather than inventing a preference between members +that objected equally. Both gates — create and edit — continue to report +identically, and validation verdicts are untouched: the accept/reject decision is +still made by the one gate, and this only changes how an already-failed draft is +presented. diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.ts index 1d7c121166..a34534c817 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.ts @@ -117,7 +117,7 @@ function viewSchemaForDraft(item: ZodLikeSchema, container: ZodLikeSchema): ZodL } /** - * ── Union-failure diagnostics for the `view` gates (objectui#3606, #3626) ── + * ── Union-failure diagnostics for the `view` gates (objectui#3606, #3626, #3678) ── * * PRESENTATION ONLY. Nothing below can change a verdict: it runs strictly * inside the final issue→`SchemaFormIssue` mapping, after `ok` has already been @@ -125,9 +125,11 @@ function viewSchemaForDraft(item: ZodLikeSchema, container: ZodLikeSchema): ZodL * on create) and after every issue filter has run. Same input set, same `ok`; * only the rendered `path`/`message` move. * - * Two rules live here, one per union depth. This first block is the ROOT rule - * (#3606, edit gate only — neither authoring schema has a union at its root); - * the NESTED rule for `config.columns` follows below (#3626, both gates). + * Three rules live here. This first block is the ROOT rule (#3606, edit gate + * only — neither authoring schema has a union at its root); the two NESTED + * rules follow below — the content rule for `config.columns` (#3626) and the + * sole-candidate rule (#3678). The two nested rules read disjoint cells of one + * partition; `nestedUnionMemberIndex` states and proves that. * * The edit gate is `z.preprocess(stripViewConsoleDecorations, z.union([…]))`. * Zod reports a union failure as a SINGLE root issue — `code: 'invalid_union'`, @@ -207,7 +209,12 @@ function viewUnionMemberIndex(draft: unknown): number { * its own relative root) is not a candidate: it never looked at the contents, * so contents cannot be evidence for it. That single categorical test — asked * of each group on its own, never group-vs-group — is what keeps `sort`, - * `filter[].value`, `gantt.tooltipFields[]` and the rest untouched. + * `filter[].value`, `gantt.tooltipFields[]` and the rest out of THIS rule. + * (It kept them collapsed entirely until #3678, which reads the same test as a + * census and answers the unions where it leaves exactly one member standing. + * The narrowing above is unchanged and still load-bearing: delete the + * `groups.some(memberRejectedNodeType)` line and the content rule elects the + * plain-`string` member for `sort: ['name']` again.) * * Boundaries, all measured rather than assumed: * @@ -235,8 +242,20 @@ const ARRAY_VARIANT_MEMBERS = { ofStrings: 0, ofObjects: 1 } as const; /** * Did this member reject the value's TYPE at the union node itself, without - * ever looking at its contents? Asked of one group in isolation — it filters - * which unions the content rule may speak about, it does not pick a winner. + * ever looking at its contents? Asked of one group in isolation — never + * group-vs-group — so it is a categorical fact about one member, not a ranking. + * + * This single test is the basis of BOTH nested rules (see + * `nestedUnionMemberIndex`): #3626 uses it to decide which unions the content + * rule may speak about, #3678 counts it to find a union with exactly one + * candidate. A member that answers `true` here never read the value, so nothing + * about the value can be evidence for or against it. + * + * Measured caveat, deliberately kept: only `invalid_type` counts. An enum + * member handed an object answers `invalid_value`, not `invalid_type` — so + * `columns[0].summary` (`enum | {type, field}`) reads as TWO candidates for an + * object value and stays in the content rule's cell, which declines it. That is + * why the `summary` descent boundary #3626 pinned is unchanged by #3678. */ function memberRejectedNodeType(group: ZodLikeIssue[]): boolean { return group.some((i) => i.code === 'invalid_type' && (i.path ?? []).length === 0); @@ -274,6 +293,97 @@ function arrayVariantMemberIndex(groups: ZodLikeIssue[][], value: unknown): numb return null; } +/** + * ── NESTED unions: the sole-candidate rule (objectui#3678) ── + * + * #3626's narrowing left a gap, and #3678 is that gap: when the categorical + * test above leaves EXACTLY ONE member standing, naming it is not a heuristic + * and not a preference — it is the only member that ever read the value, so it + * is the only member whose complaint can be about what the author wrote. The + * other members objected to the value's TYPE and stopped there; showing their + * complaints would describe a shape the author never chose. + * + * `config.sort` (`string | ColumnSort[]`) is the case that motivated it. For + * `sort: [{field: 'n', order: 'bogus'}]` the plain-`string` member rejects the + * array outright and the `ColumnSort[]` member reports + * `[0].order` / `Invalid option: expected one of "asc"|"desc"` — the spec's own + * guided message (#4001), which until now was thrown away and rendered as + * `config.sort` / `Invalid input`. + * + * This rule does NOT index members positionally: the index is derived from the + * census of the groups the failing gate itself produced, so a spec-side reorder + * of a union's members cannot mis-select. What a spec change CAN do is move a + * union between cells — adding a third `sort` member that also accepts arrays + * would take the census from one candidate to two and collapse the node back to + * `Invalid input`. That is why the #3678 anchors pin exact paths + messages: + * the loss shows up as a red test rather than as a quietly worse message. + * + * Reach, measured over the `view` family rather than assumed — this is WIDER + * than issue #3678 estimated. #3678's "范围" paragraph expected only + * scalar-or-array two-member unions such as `sort` to be reachable, and read + * `filter[].value` / `sections[].fields[]` as rejecting wholesale. They do + * reject wholesale for a scalar value, but not for an ARRAY value, where the + * array member is the sole candidate: + * + * `filter[0].value: [{}]` → `config.filter.0.value.0` (was `…value`) + * `sections[0].fields: [{}]` → `config.sections.0.fields.0.field` + * `columns[0].summary: 'bogus'` → same path, now the enum's option list + * + * All three are strict improvements — a nearer path, or a named expectation + * instead of `Invalid input` — and each is pinned below. Two of them supersede + * NARROWING pins #3626 wrote (see the test file's #3678 block); those pins were + * asserting that no rule spoke there, which is exactly what this rule changes. + */ +function soleTypeAcceptingMember(groups: ZodLikeIssue[][]): number | null { + let sole: number | null = null; + for (let i = 0; i < groups.length; i++) { + if (memberRejectedNodeType(groups[i])) continue; + if (sole !== null) return null; // two or more candidates — ambiguous, not ours + sole = i; + } + return sole; // stays `null` when every member rejected the type +} + +/** + * The two NESTED rules, and the relationship between them. + * + * Census the members with `memberRejectedNodeType` and let `k` be how many + * ACCEPTED the value's type. That single number partitions every nested union + * into three cells, and the two rules live in different ones: + * + * k === 1 → #3678 names the sole candidate. + * k === groups.length → every member read the value, so the value's own + * (i.e. k === 0 rejected) CONTENT decides — #3626's `array | array` + * rule, which declines unless the union really is + * of that shape. + * otherwise (k === 0, or → no rule. Nothing about the value distinguishes + * 0 < k < groups.length the members, and #3626 already ruled that + * with k !== 1) inventing a preference is not ours to do. + * + * So they cannot both want to select: `k === 1` and `k === groups.length` + * coincide only at `groups.length === 1`, a one-member "union" the schema does + * not produce — and even there both rules agree on index 0, since #3626's rule + * requires `groups.length === 2` and returns `null`. The content rule is NOT a + * special case of the sole-candidate rule and the sole-candidate rule is NOT a + * fallback for it; they answer different questions in disjoint cells. #3678's + * dispatch asked for this to be measured before implementing, and it was: the + * census in the test block below covers every nested union shape the `view` + * family produces, and no shape lands in two cells. + * + * Order below is therefore unobservable while both guards hold — and it is + * written content-first ON PURPOSE, so that #3626's narrowing guard stays the + * thing that fails when it is removed. Deleting + * `if (groups.some(memberRejectedNodeType)) return null;` from + * `arrayVariantMemberIndex` still resurrects the exact `sort` mis-selection + * #3626 measured, and the #3678 sort anchor goes red on it. Hoisting that guard + * into this function would have made it dead code. + */ +function nestedUnionMemberIndex(groups: ZodLikeIssue[][], value: unknown): number | null { + const byContent = arrayVariantMemberIndex(groups, value); + if (byContent !== null) return byContent; + return soleTypeAcceptingMember(groups); +} + /** * Pick the union member whose issues should be shown for one `invalid_union`, * or `null` for "nothing better than the union node's own message". @@ -294,7 +404,7 @@ function selectViewUnionGroup( const index = absPath.length === 0 ? viewUnionMemberIndex(draft) - : arrayVariantMemberIndex(groups, valueAtPath(draft, absPath)); + : nestedUnionMemberIndex(groups, valueAtPath(draft, absPath)); if (index === null) return null; const group = groups[index]; if (!Array.isArray(group) || group.length === 0) return null; @@ -312,12 +422,20 @@ function selectViewUnionGroup( * finite tree and only ever yields a non-empty group, so this terminates and a * rejected draft always renders at least one issue. * - * There is no depth counter: the array-variant rule declines every union whose - * node value is not an array, which is what actually bounds the descent. In the - * measured schema that is exactly one nested level — e.g. a bad - * `columns[0].summary` (`enum | {type, field}`, an object value) stops there and - * keeps its own message, now correctly addressed to `config.columns.0.summary` - * rather than to `config.columns`. + * There is no depth counter, and #3678 is why there must not be one: the + * descent is bounded by the rules having nothing to say, not by a level count. + * A union whose members ALL rejected the value's type ends it — nothing + * distinguishes them — and so does one where two or more members read the value + * but the content rule declines. Measured examples of each bound: + * + * - `columns[0].summary` (`enum | {type, field}`) handed an OBJECT: both + * members read it, the content rule declines a non-array, descent stops with + * Zod's own message now addressed to `config.columns.0.summary`. + * - `filter[0].value: [{}]` descends TWO levels — the array member is the sole + * candidate, and the element union beneath it is rejected by every member — + * ending at `config.filter.0.value.0`. Before #3678 the claim here was "in + * the measured schema that is exactly one nested level"; that was true only + * while the content rule was the only nested rule, and it is now false. */ function expandViewIssues( issues: ZodLikeIssue[], diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts index e414e2d288..ab765fa0fc 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts @@ -412,15 +412,37 @@ describe('view nested union — `config.columns` diagnostics (objectui#3626)', ( // received array" — true, and the wrong thing to tell someone who correctly // wrote an array. A member that rejected the node's TYPE outright never // looked at the contents, so the contents are not evidence for it, and the - // union is left alone. Remove that guard and this goes red. + // CONTENT rule is left out of it. Remove that guard and this goes red. + // + // RE-PINNED BY #3678, and the claim above is unchanged: what this test + // guards is that the plain-`string` member is never the one selected. The + // expected value moved because a DIFFERENT rule — the sole-candidate rule — + // now speaks here, and it selects the OTHER member, the one that actually + // read the array. The forbidden mis-selection is asserted directly below so + // the guard cannot pass vacuously: if #3626's narrowing guard is deleted, + // the content rule elects `string[]` first and both assertions go red. const issues = await bothGates({ ...AUTHORABLE_ITEM, config: { ...(AUTHORABLE_ITEM.config as Record), sort: ['name'] }, }); - expect(issues).toEqual([{ path: 'config.sort', message: 'Invalid input' }]); + expect(issues).toEqual([ + { path: 'config.sort.0', message: 'Invalid input: expected object, received string' }, + ]); + expect(issues.map((i) => i.message).join('\n')).not.toContain( + 'expected string, received array', + ); }); it('does NOT answer for a filter value union (five members, not two)', async () => { + // The CONTENT rule needs exactly two members, and this union has five, so + // it still declines — the claim this test was written for. + // + // RE-PINNED BY #3678: for an ARRAY value, four of the five members reject + // the type outright and the array member is the sole candidate, so the + // sole-candidate rule descends one level to the offending element. It then + // STOPS: the element's own union (`string | number`) is rejected by every + // member, nothing distinguishes them, and Zod's message is kept. The result + // is a nearer path with the same message — `…value.0` rather than `…value`. const issues = await bothGates({ ...AUTHORABLE_ITEM, config: { @@ -428,7 +450,7 @@ describe('view nested union — `config.columns` diagnostics (objectui#3626)', ( filter: [{ field: 'f', operator: 'in', value: [{}] }], }, }); - expect(issues).toEqual([{ path: 'config.filter.0.value', message: 'Invalid input' }]); + expect(issues).toEqual([{ path: 'config.filter.0.value.0', message: 'Invalid input' }]); }); // ── Boundaries: what the content cannot elect ──────────────────────────── @@ -508,3 +530,288 @@ describe('view verdict parity — `columns` bodies (objectui#3626)', () => { }); } }); + +/** + * ── NESTED union diagnostics: the SOLE-CANDIDATE rule (objectui#3678) ── + * + * #3626 narrowed its content rule so it could not speak for `config.sort`, and + * said what that left behind: when a union's members are censused by the + * categorical test — did this member reject the value's TYPE at the node + * itself, without ever reading it? — and EXACTLY ONE member is left standing, + * naming that member is not a guess. It is the only member that read the value, + * so it is the only one whose complaint can be about what the author wrote. + * + * Measured before implementing, on @objectstack/spec 17.0.0-rc.5: + * + * sort: [{field:'n', order:'bogus'}] member[0] `string` rejected the type + * member[1] `ColumnSort[]` [0].order Invalid option + * + * so the diagnostic that reached the user was `config.sort` / `Invalid input` + * while the spec's own guided message sat one level down, unread. + * + * ── How this rule relates to #3626's content rule ──────────────────────────── + * + * They are DISJOINT, not layered, and the dispatch for #3678 asked for that to + * be measured rather than asserted. Let `k` be how many members accepted the + * value's type. `k` partitions every nested union: + * + * k === 1 → #3678 names the sole candidate. + * k === members.length → every member read the value, so the value's own + * CONTENT decides (#3626), which further declines + * unless the union is `array | array`. + * otherwise → no rule; the union keeps Zod's message. + * + * `k === 1` and `k === members.length` can only coincide at a one-member union, + * which this schema does not produce — and #3626's rule requires exactly two + * members anyway. The census below walks every nested-union shape the `view` + * family produces and pins that no shape lands in two cells, so there is no + * priority question to answer. + * + * Three kinds of claim are pinned here: + * + * - CANARY — the sole candidate's exact `path` + `message`. This rule does not + * index members positionally, so a reorder cannot hurt it; what CAN hurt it + * is a spec change to the member SET (a third `sort` member that also accepts + * arrays would take `k` from 1 to 2 and collapse the node back). These turn + * red on that instead of quietly reverting to `Invalid input`. + * - DISJOINTNESS — the two rules never both select, asserted over the census. + * - MAINTAINED — the cells where this rule must stay silent (`k === 0`, and + * `k >= 2` where the content rule declines) still read exactly as #3626 left + * them. + */ +describe('view nested union — the sole-candidate rule (objectui#3678)', () => { + const AUTHORABLE_ITEM: Record = { ...STORED_ITEM }; + delete AUTHORABLE_ITEM.isPinned; + + const withConfig = (extra: Record) => ({ + ...AUTHORABLE_ITEM, + config: { ...(AUTHORABLE_ITEM.config as Record), ...extra }, + }); + + /** Every case here must read identically through both gates. */ + const bothGates = async (body: unknown) => { + const created = await validateMetadataDraft('view', body, undefined, { mode: 'create' }); + const edited = await validateMetadataDraft('view', body, undefined, EDIT); + expect(created.ok).toBe(false); + expect(edited.ok).toBe(false); + expect(edited.issues).toEqual(created.issues); + return created.issues; + }; + + // ── CANARY ─────────────────────────────────────────────────────────────── + + it('CANARY: a bad `order` on a sort row reports the key and the allowed options', async () => { + // THE case #3678 was filed on. Before: `config.sort` / `Invalid input`. + const issues = await bothGates(withConfig({ sort: [{ field: 'n', order: 'bogus' }] })); + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('config.sort.0.order'); + expect(issues[0].message).toContain('Invalid option'); + expect(issues[0].message).toContain('"asc"'); + expect(issues[0].message).toContain('"desc"'); + }); + + it('CANARY: a non-object element in a sort list is addressed to that element', async () => { + // The `string` member rejected the array outright, so `ColumnSort[]` is the + // sole candidate and index 0 is where it broke. Measured, against the + // dispatch's guess that BOTH members would reject `[42]`: they do not — the + // array member accepts the type and complains about the element. + const issues = await bothGates(withConfig({ sort: [42] })); + expect(issues).toEqual([ + { path: 'config.sort.0', message: 'Invalid input: expected object, received number' }, + ]); + }); + + it('CANARY: every issue of the sole candidate is shown, and only that member’s', async () => { + // `sort: [{}]` is missing `field` AND has no valid `order`. Both belong to + // the selected member; the rejected member's "expected string, received + // array" is not among them. + const issues = await bothGates(withConfig({ sort: [{}] })); + expect(issues.map((i) => i.path)).toEqual(['config.sort.0.field', 'config.sort.0.order']); + const messages = issues.map((i) => i.message).join('\n'); + // Positive anchor FIRST — a collapsed `Invalid input` contains no forbidden + // substring either, so the negative alone would pass vacuously (#3606's + // lesson, and #3626 hit the same trap). + expect(messages).toContain('expected string, received undefined'); + expect(messages).not.toContain('expected string, received array'); + }); + + it('CANARY: the same union under the aggregated container reports `list.sort.…`', async () => { + // Prefix composition on the other route: top-level on create, one level + // down inside the selected root member on edit. + const issues = await bothGates({ + ...CONTAINER, + list: { type: 'grid', columns: ['name'], sort: [{ field: 'n', order: 'bogus' }] }, + }); + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('list.sort.0.order'); + expect(issues[0].message).toContain('Invalid option'); + }); + + it('CANARY: a scalar union member left standing names the options it wanted', async () => { + // `columns[0].summary` is `enum | {type, field}`. Handed a STRING the object + // member rejects the type, leaving the enum as sole candidate — so the path + // is unchanged and the MESSAGE stops being `Invalid input`. (Handed an + // OBJECT, k is 2 and this stays collapsed — pinned in the #3626 block.) + const issues = await bothGates(withConfig({ columns: [{ field: 'a', summary: 'bogus' }] })); + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('config.columns.0.summary'); + expect(issues[0].message).toContain('Invalid option'); + expect(issues[0].message).toContain('"count"'); + }); + + it('descends more than one level when each level has a sole candidate', async () => { + // `sections[].fields[]` is `string | FormField`. This is the reach #3678's + // own scope paragraph did not expect: it read this union as rejecting + // wholesale, which it does for a scalar element but not for an object one. + const issues = await bothGates({ + ...AUTHORABLE_ITEM, + viewKind: 'form', + config: { type: 'simple', sections: [{ fields: [{}] }] }, + }); + expect(issues).toEqual([ + { + path: 'config.sections.0.fields.0.field', + message: 'Invalid input: expected string, received undefined', + }, + ]); + }); + + // ── MAINTAINED: the cells this rule must stay out of ───────────────────── + + it('k === 0 — every member rejected the type, so the collapse is kept', async () => { + // `sort: 42` is neither a string nor an array. Nothing distinguishes the + // members, and #3626 already ruled that inventing a preference is not ours. + expect(await bothGates(withConfig({ sort: 42 }))).toEqual([ + { path: 'config.sort', message: 'Invalid input' }, + ]); + }); + + it('k >= 2 — two members read the value, so this rule stays silent', async () => { + // `columns: [42]` — BOTH members accepted the array type, so there is no + // sole candidate; the content rule then declines because `42` elects + // neither variant, and the node keeps its own message. This is the + // uniqueness requirement doing its job: drop it and take "the first member + // that accepted" instead, and this reports `config.columns.0` / + // "expected string, received number" — a preference nobody expressed. + expect(await bothGates(withConfig({ columns: [42] }))).toEqual([ + { path: 'config.columns', message: 'Invalid input' }, + ]); + }); + + it('every nested union shape lands in exactly ONE rule’s cell — census', async () => { + // The `k` partition is an argument about the code; this is the measurement + // that stands behind it. For each shape: the nested union under test + // (`unionAt`), where the diagnostic actually lands (`reportedAt`), and which + // cell that implies. + // + // The cells are observable from outside, but NOT — as this test first + // assumed and was corrected by running it — through the path alone. A rule + // that spoke shows up as a nearer path OR as a named message, and each can + // happen without the other: + // + // selected member's issue is DEEPER → path moves (`sort` → `…sort.0.order`) + // selected member's issue is AT the → path stays, message stops being + // union node (a scalar member) `Invalid input` (`summary: 'bogus'`) + // descent ends in a `none` cell → path moves, message stays + // one level further down (`filter[].value: [{}]`) + // + // So the honest test of "did a rule speak" is the disjunction, and `none` + // is exactly its negation: the address is still the union node AND the + // message is still the node's own `Invalid input`. + // + // What makes this a disjointness measurement rather than a list: the two + // rules would collide only if a k=2 union were also selected as a sole + // candidate. The `columns` rows are the k=2 population — three of them, two + // where the content rule speaks and one where nothing does — and none of + // them reports the "first member that accepted" address (`config.columns.0` + // / "expected string, received number" for `[42]`) that a collision would + // produce. The `k >= 2` test above pins that single case directly. + const CENSUS: Array<{ + label: string; + body: unknown; + unionAt: string; + reportedAt: string; + cell: 'sole' | 'content' | 'none'; + collapsedMessage: boolean; + }> = [ + { label: 'sort: bad order', body: withConfig({ sort: [{ field: 'n', order: 'bogus' }] }), unionAt: 'config.sort', reportedAt: 'config.sort.0.order', cell: 'sole', collapsedMessage: false }, + { label: 'sort: of field names', body: withConfig({ sort: ['name'] }), unionAt: 'config.sort', reportedAt: 'config.sort.0', cell: 'sole', collapsedMessage: false }, + { label: 'sort: of numbers', body: withConfig({ sort: [42] }), unionAt: 'config.sort', reportedAt: 'config.sort.0', cell: 'sole', collapsedMessage: false }, + { label: 'sort: a bare number', body: withConfig({ sort: 42 }), unionAt: 'config.sort', reportedAt: 'config.sort', cell: 'none', collapsedMessage: true }, + { label: 'columns: bad key type', body: withConfig({ columns: [{ field: 123 }] }), unionAt: 'config.columns', reportedAt: 'config.columns.0.field', cell: 'content', collapsedMessage: false }, + { label: 'columns: stray element', body: withConfig({ columns: ['a', 42] }), unionAt: 'config.columns', reportedAt: 'config.columns.1', cell: 'content', collapsedMessage: false }, + { label: 'columns: elected by nothing', body: withConfig({ columns: [42] }), unionAt: 'config.columns', reportedAt: 'config.columns', cell: 'none', collapsedMessage: true }, + { label: 'columns: not an array', body: withConfig({ columns: 'nope' }), unionAt: 'config.columns', reportedAt: 'config.columns', cell: 'none', collapsedMessage: true }, + { label: 'summary: a bad enum string', body: withConfig({ columns: [{ field: 'a', summary: 'bogus' }] }), unionAt: 'config.columns.0.summary', reportedAt: 'config.columns.0.summary', cell: 'sole', collapsedMessage: false }, + { label: 'summary: an object', body: withConfig({ columns: [{ field: 'a', summary: { type: 'bogus' } }] }), unionAt: 'config.columns.0.summary', reportedAt: 'config.columns.0.summary', cell: 'none', collapsedMessage: true }, + { label: 'filter value: an array', body: withConfig({ filter: [{ field: 'f', operator: 'in', value: [{}] }] }), unionAt: 'config.filter.0.value', reportedAt: 'config.filter.0.value.0', cell: 'sole', collapsedMessage: true }, + { label: 'filter value: an object', body: withConfig({ filter: [{ field: 'f', operator: 'equals', value: {} }] }), unionAt: 'config.filter.0.value', reportedAt: 'config.filter.0.value', cell: 'none', collapsedMessage: true }, + ]; + for (const c of CENSUS) { + const issues = await bothGates(c.body); + expect(issues.map((i) => i.path), c.label).toEqual([c.reportedAt]); + expect(issues[0].message === 'Invalid input', c.label).toBe(c.collapsedMessage); + // The address never leaves the union node's subtree, whatever spoke. + expect( + c.reportedAt === c.unionAt || c.reportedAt.startsWith(`${c.unionAt}.`), + c.label, + ).toBe(true); + const spoke = c.reportedAt !== c.unionAt || issues[0].message !== 'Invalid input'; + expect(spoke, c.label).toBe(c.cell !== 'none'); + } + }); + + it('a rule speaking is NOT the same as the message improving — both directions', async () => { + // Two rows of the census move in only one of the two observables each, and + // asserting the wrong one is how a pin passes for a reason that is not the + // reason it claims. Written out so the next reader does not re-derive it. + + // Path moves, message does NOT. The sole-candidate rule named the element + // that broke the list, but the element's own union is rejected by every + // member, so the descent ends in a `none` cell and `Invalid input` is all + // there is left to show. + expect( + await bothGates(withConfig({ filter: [{ field: 'f', operator: 'in', value: [{}] }] })), + ).toEqual([{ path: 'config.filter.0.value.0', message: 'Invalid input' }]); + + // Message moves, path does NOT. The sole candidate is the ENUM member, + // whose issue sits AT the union node, so the address is unchanged and the + // entire gain is that the user is told which options exist. + const summary = await bothGates(withConfig({ columns: [{ field: 'a', summary: 'bogus' }] })); + expect(summary.map((i) => i.path)).toEqual(['config.columns.0.summary']); + expect(summary[0].message).not.toBe('Invalid input'); + expect(summary[0].message).toContain('Invalid option'); + }); +}); + +/** + * PARITY, sole-candidate layer — the verdict cannot move, for the structural + * reason #3606 and #3626 already pinned: the expansion runs inside the + * issue→form-issue mapping, downstream of `ok`. Green before this change and + * green after; that is the point of it, not a weakness of it. + */ +describe('view verdict parity — sole-candidate bodies (objectui#3678)', () => { + const withConfig = (extra: Record) => ({ + ...STORED_ITEM, + config: { ...(STORED_ITEM.config as Record), ...extra }, + }); + + const CASES: Array<{ label: string; body: unknown; ok: boolean }> = [ + { label: 'sort as a field name', body: withConfig({ sort: 'name' }), ok: true }, + { label: 'sort as an empty array', body: withConfig({ sort: [] }), ok: true }, + { label: 'sort as column sorts', body: withConfig({ sort: [{ field: 'n', order: 'asc' }] }), ok: true }, + { label: 'sort with a bad order', body: withConfig({ sort: [{ field: 'n', order: 'bogus' }] }), ok: false }, + { label: 'sort of field names', body: withConfig({ sort: ['name'] }), ok: false }, + { label: 'sort of numbers', body: withConfig({ sort: [42] }), ok: false }, + { label: 'sort as a number', body: withConfig({ sort: 42 }), ok: false }, + { label: 'summary as a bad enum', body: withConfig({ columns: [{ field: 'a', summary: 'bogus' }] }), ok: false }, + ]; + + for (const c of CASES) { + it(`${c.label}: edit=${c.ok ? 'ok' : 'not ok'}`, async () => { + const edited = await validateMetadataDraft('view', c.body, undefined, EDIT); + expect(edited.ok, `edit: ${JSON.stringify(edited.issues)}`).toBe(c.ok); + expect(edited.issues.length === 0).toBe(c.ok); + }); + } +});