|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#7741] The runtime write door refuses an inline view config that carries no |
| 5 | + * object binding — maintainer ruling 2026-08-12, direction B. |
| 6 | + * |
| 7 | + * ## What QA run #7695 measured |
| 8 | + * |
| 9 | + * `PUT /api/v1/meta/view/<name>` with `{ name, type: 'grid', columns: […], |
| 10 | + * data: {…} }` — a single view's config written where the container belongs — |
| 11 | + * returned 200 in BOTH draft and active mode, published clean, and read back |
| 12 | + * with `_diagnostics: { valid: true }`. Meanwhile `expandViewContainer(...)` |
| 13 | + * returned `[]` and `GET /meta/view?object=…` omitted the row: registered, |
| 14 | + * reported valid, renders nothing. The same body handed to `defineView()` |
| 15 | + * throws the located "Wrap it: `defineView({ list: { … } })`" guidance — the |
| 16 | + * two doors disagreed in front of the same author. |
| 17 | + * |
| 18 | + * ## The ruled fix, and why the binding is a PAIR |
| 19 | + * |
| 20 | + * The inline (flattened overlay) arms of `ViewMetadataSchema` now REQUIRE |
| 21 | + * `object` + `viewKind`. Both, because that pair is what the object-bound read |
| 22 | + * paths actually filter on — measured, not assumed: |
| 23 | + * `packages/rest/src/rest-server.ts` (`GET /meta/view?object=` → |
| 24 | + * `v.viewKind && v.object === obj`) and |
| 25 | + * `packages/metadata/src/metadata-manager.ts` (`getViewsByObject()`, same |
| 26 | + * predicate). Requiring `object` alone would refuse the card's repro and then |
| 27 | + * instruct the author into a SECOND dead row — bound by `object`, still |
| 28 | + * invisible to the switcher for want of `viewKind`. |
| 29 | + * |
| 30 | + * ## Draft and active alike |
| 31 | + * |
| 32 | + * The ruling: 「draft 与 active 同样适用 …… 现在没有这个证据,不预留」. The pin |
| 33 | + * lives at the schema layer on purpose: `getMetadataTypeSchema('view')` is the |
| 34 | + * single entry `saveMetaItem` validates against for BOTH `mode: 'draft'` and |
| 35 | + * `mode: 'publish'` saves (ADR-0005 §Validation), so there is no draft-shaped |
| 36 | + * side door for the schema to miss. Whether some transport skips validation |
| 37 | + * entirely is a transport question a spec test cannot see. |
| 38 | + * |
| 39 | + * ## Reverse verification — direction decided before it was run |
| 40 | + * |
| 41 | + * Expected direction: restoring the two fields to `.optional()` turns the |
| 42 | + * card's repro body GREEN again through this same door (acceptance direction, |
| 43 | + * the plain before/after) while `defineView` keeps throwing — re-opening |
| 44 | + * exactly the two-door disagreement above. Observed on this branch (temporary |
| 45 | + * `git checkout origin/main -- src/ui/view.zod.ts` after committing the fix): |
| 46 | + * the repro parses `success: true` on the old schema, `success: false` on the |
| 47 | + * new one; no inverted or count-shaped surprises. |
| 48 | + */ |
| 49 | + |
| 50 | +import { describe, it, expect } from 'vitest'; |
| 51 | +import { z } from 'zod'; |
| 52 | +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; |
| 53 | +import { ViewMetadataSchema, diagnoseViewMetadata, defineView } from './view.zod'; |
| 54 | + |
| 55 | +/** The card's exact repro body (#7741 / QA run #7695), verbatim shape. */ |
| 56 | +const REPRO = { |
| 57 | + name: 'qa_dead_view', |
| 58 | + type: 'grid', |
| 59 | + columns: ['title', 'status'], |
| 60 | + data: { provider: 'object', object: 'showcase_task' }, |
| 61 | +}; |
| 62 | + |
| 63 | +/** The same inline config, properly bound. */ |
| 64 | +const BOUND = { ...REPRO, object: 'showcase_task', viewKind: 'list' }; |
| 65 | + |
| 66 | +const door = () => getMetadataTypeSchema('view')!; |
| 67 | + |
| 68 | +function refusalText(body: unknown): string { |
| 69 | + const r = door().safeParse(body); |
| 70 | + expect(r.success, `expected the door to REFUSE ${JSON.stringify(body)}`).toBe(false); |
| 71 | + return JSON.stringify((r as { error?: { issues?: unknown } }).error?.issues ?? []); |
| 72 | +} |
| 73 | + |
| 74 | +describe('[#7741] the runtime write door refuses the unbound inline view config', () => { |
| 75 | + it('REFUSES the card\'s exact repro body through getMetadataTypeSchema(\'view\')', () => { |
| 76 | + const r = door().safeParse(REPRO); |
| 77 | + expect(r.success).toBe(false); |
| 78 | + }); |
| 79 | + |
| 80 | + it('the refusal is LOCATED: the claimed inline arm carries it at `object` / `viewKind`', () => { |
| 81 | + const r = door().safeParse(REPRO); |
| 82 | + expect(r.success).toBe(false); |
| 83 | + if (r.success) return; |
| 84 | + const root = r.error.issues[0] as unknown as { |
| 85 | + code: string; |
| 86 | + errors: Array<Array<{ path: PropertyKey[]; message: string }>>; |
| 87 | + }; |
| 88 | + // The union envelope other consumers key on (ADR-0112 at the issue level). |
| 89 | + expect(root.code).toBe('invalid_union'); |
| 90 | + expect(root.errors).toHaveLength(4); |
| 91 | + // Branch 2 is the listOverlay the body claims (`type: 'grid'`); #7510's |
| 92 | + // focusing mutes the other three, so the author reads ONLY the binding |
| 93 | + // prescription, at the paths of the keys to add. |
| 94 | + const claimed = root.errors[2]!; |
| 95 | + expect(claimed.map((i) => i.path)).toEqual([['object'], ['viewKind']]); |
| 96 | + for (const index of [0, 1, 3]) { |
| 97 | + expect(root.errors[index]).toHaveLength(1); |
| 98 | + expect(root.errors[index]![0]!.message).toContain('this body reads as `listOverlay`'); |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | + it('…naming the offending shape and both halves of the remedy (bind, record, or wrap)', () => { |
| 103 | + const text = refusalText(REPRO); |
| 104 | + // The offending shape, by name. |
| 105 | + expect(text).toContain('inline view config'); |
| 106 | + // The binding remedy, with the measured reason a binding is required. |
| 107 | + expect(text).toContain('names no `object`'); |
| 108 | + expect(text).toContain('GET /meta/view?object='); |
| 109 | + expect(text).toContain('add `object:'); |
| 110 | + // The record alternative. |
| 111 | + expect(text).toContain('{ name, object, viewKind, config: { … } }'); |
| 112 | + // The wrap remedy — the SAME prose family `defineView` throws for this |
| 113 | + // body (the ruling: the write door reuses the build path's guidance). |
| 114 | + expect(text).toContain('Wrap it: `defineView({ list: { type, data, columns, … } })`'); |
| 115 | + expect(text).toContain('defineView({ listViews: { my_view: { … } } })'); |
| 116 | + }); |
| 117 | + |
| 118 | + it('the guidance is the same prose family the build door throws for the repro', () => { |
| 119 | + // The contrast the card measured in step 5 of its reproduction: same body, |
| 120 | + // `defineView` door. Both doors now speak the wrap remedy. |
| 121 | + expect(() => defineView(REPRO as never)).toThrow(/defineView\(\{ list: \{ type, data, columns/); |
| 122 | + }); |
| 123 | + |
| 124 | + it('a HALF binding is refused too — `object` alone or `viewKind` alone', () => { |
| 125 | + // `object` without `viewKind` is precisely the second dead row the pair |
| 126 | + // requirement exists to prevent (invisible to the switcher's |
| 127 | + // `v.viewKind && v.object === obj` filter). |
| 128 | + expect(refusalText({ ...REPRO, object: 'showcase_task' })).toContain('names no `viewKind`'); |
| 129 | + expect(refusalText({ ...REPRO, viewKind: 'list' })).toContain('names no `object`'); |
| 130 | + }); |
| 131 | + |
| 132 | + it('the form-family inline arm refuses the same way', () => { |
| 133 | + const text = refusalText({ name: 'qa_dead_form', type: 'wizard' }); |
| 134 | + expect(text).toContain('names no `object`'); |
| 135 | + }); |
| 136 | + |
| 137 | + it('diagnoseViewMetadata names the inline branch, so consumers render the binding guidance', () => { |
| 138 | + const d = diagnoseViewMetadata(REPRO); |
| 139 | + expect(d.success).toBe(false); |
| 140 | + if (d.success) return; |
| 141 | + expect(d.branch).toBe('listOverlay'); |
| 142 | + expect(d.issues.map((i) => i.path)).toEqual([['object'], ['viewKind']]); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +describe('[#7741] what the door still accepts, byte for byte', () => { |
| 147 | + it('ACCEPTS the properly bound inline body — and adds nothing to it', () => { |
| 148 | + const r = door().safeParse(BOUND); |
| 149 | + expect(r.success, JSON.stringify((r as { error?: { issues?: unknown } }).error?.issues)).toBe(true); |
| 150 | + if (!r.success) return; |
| 151 | + expect(r.data).toEqual(BOUND); |
| 152 | + }); |
| 153 | + |
| 154 | + it('ACCEPTS the ViewItem record arm byte-identically', () => { |
| 155 | + const record = { |
| 156 | + name: 'showcase_task.all', |
| 157 | + object: 'showcase_task', |
| 158 | + viewKind: 'list', |
| 159 | + config: { type: 'grid', columns: ['title'], data: { provider: 'object', object: 'showcase_task' } }, |
| 160 | + }; |
| 161 | + const r = door().safeParse(record); |
| 162 | + expect(r.success).toBe(true); |
| 163 | + if (!r.success) return; |
| 164 | + expect(r.data).toEqual(record); |
| 165 | + }); |
| 166 | + |
| 167 | + it('ACCEPTS the container arm byte-identically — #6391\'s union membership is intact', () => { |
| 168 | + const container = { |
| 169 | + object: 'showcase_task', |
| 170 | + list: { type: 'grid', columns: ['title'], data: { provider: 'object', object: 'showcase_task' } }, |
| 171 | + }; |
| 172 | + const r = door().safeParse(container); |
| 173 | + expect(r.success).toBe(true); |
| 174 | + if (!r.success) return; |
| 175 | + expect(r.data).toEqual(container); |
| 176 | + }); |
| 177 | + |
| 178 | + it('ACCEPTS the post-normalize personalization PUT (identity inherited from the shadowed entry)', () => { |
| 179 | + // What `normalizeViewMetadata` + `viewIdentityPatch` (#2555) actually hand |
| 180 | + // this schema for a console column-sort PUT on a real view. |
| 181 | + const r = door().safeParse({ |
| 182 | + type: 'grid', |
| 183 | + data: { provider: 'object', object: 'showcase_task' }, |
| 184 | + columns: ['title'], |
| 185 | + sort: [{ field: 'estimate_hours', order: 'desc' }], |
| 186 | + name: 'showcase_task.default', |
| 187 | + viewKind: 'list', |
| 188 | + object: 'showcase_task', |
| 189 | + label: 'All Tasks', |
| 190 | + }); |
| 191 | + expect(r.success).toBe(true); |
| 192 | + }); |
| 193 | +}); |
| 194 | + |
| 195 | +describe('[#7741] the emitted contract declares what it enforces', () => { |
| 196 | + it('the inline arms\' JSON Schema marks `object` and `viewKind` required, in both io directions', () => { |
| 197 | + // Studio's SchemaForm is generated from this — declared = enforced means |
| 198 | + // an AI author is TOLD the binding is required before the 422 says so. |
| 199 | + for (const io of ['output', 'input'] as const) { |
| 200 | + const json = z.toJSONSchema(door(), { unrepresentable: 'any', io }) as { |
| 201 | + anyOf?: Array<{ required?: string[] }>; |
| 202 | + }; |
| 203 | + expect(json.anyOf).toHaveLength(4); |
| 204 | + for (const member of [json.anyOf![2]!, json.anyOf![3]!]) { |
| 205 | + expect(member.required ?? []).toEqual(expect.arrayContaining(['object', 'viewKind'])); |
| 206 | + } |
| 207 | + } |
| 208 | + }); |
| 209 | + |
| 210 | + it('ViewMetadataSchema and the kernel registry entry are one schema — both modes, one verdict', () => { |
| 211 | + // `saveMetaItem` resolves BOTH draft and publish saves through |
| 212 | + // `getMetadataTypeSchema('view')`; pinning the identity here is the |
| 213 | + // schema-level half of "draft 与 active 同样适用". |
| 214 | + expect(door()).toBe(ViewMetadataSchema); |
| 215 | + expect(ViewMetadataSchema.safeParse(REPRO).success).toBe(false); |
| 216 | + }); |
| 217 | +}); |
0 commit comments