|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | | -import { describe, it, expect } from 'vitest'; |
| 3 | +import { describe, it, expect, vi } from 'vitest'; |
| 4 | +import { defineStack, normalizeStackInput } from '@objectstack/spec'; |
4 | 5 | import { |
5 | 6 | validateFormLayout, |
6 | 7 | FORM_FIELD_UNKNOWN, |
7 | 8 | FORM_COLSPAN_ABSOLUTE, |
8 | | -} from './validate-form-layout'; |
| 9 | +} from './validate-form-layout.js'; |
| 10 | + |
| 11 | +type AnyRec = Record<string, unknown>; |
9 | 12 |
|
10 | 13 | const objects = [ |
11 | 14 | { name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } }, |
@@ -112,3 +115,222 @@ describe('validateFormLayout (#2578)', () => { |
112 | 115 | expect(validateFormLayout({ views: [], objects: [] })).toEqual([]); |
113 | 116 | }); |
114 | 117 | }); |
| 118 | + |
| 119 | +// ─────────────────────────────────────────────────────────────────────────── |
| 120 | +// #6251 — `views[]` is a view CONTAINER, and both rules above were unreachable |
| 121 | +// on the shape real apps actually ship. |
| 122 | +// |
| 123 | +// The measurement that opened the issue: one broken form, three placements. |
| 124 | +// Only the placement the strict schema REFUSES was being read, so an app whose |
| 125 | +// forms all live under `form` / `formViews.<key>` — i.e. every app — got a |
| 126 | +// clean report from a rule that had read nothing at all. That is the ghost |
| 127 | +// check #4984 / #5009 name: green because nothing was inspected. |
| 128 | +// ─────────────────────────────────────────────────────────────────────────── |
| 129 | + |
| 130 | +/** The one broken form, reused verbatim in every placement below. */ |
| 131 | +const brokenForm = { |
| 132 | + data: { provider: 'object', object: 'contract' }, |
| 133 | + sections: [{ columns: 2, fields: ['name', 'ghost_field'] }], |
| 134 | +}; |
| 135 | + |
| 136 | +describe('#6251 — the view CONTAINER ladder', () => { |
| 137 | + it('reports the SAME broken form in all three placements', () => { |
| 138 | + const at = (view: AnyRec) => |
| 139 | + validateFormLayout({ objects, views: [view] }).map((f) => `${f.rule}@${f.path}`); |
| 140 | + |
| 141 | + // (1) the entry IS a bare form view — the only shape read before #6251. |
| 142 | + expect(at({ name: 'contract_form', ...brokenForm })).toEqual([ |
| 143 | + `${FORM_FIELD_UNKNOWN}@views[0].sections[0].fields[1]`, |
| 144 | + ]); |
| 145 | + // (2) the container's DEFAULT form. |
| 146 | + expect(at({ name: 'contract_views', object: 'contract', form: brokenForm })).toEqual([ |
| 147 | + `${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[1]`, |
| 148 | + ]); |
| 149 | + // (3) a NAMED form view — where `os build` on app-showcase actually puts them. |
| 150 | + expect(at({ name: 'contract_views', object: 'contract', formViews: { edit: brokenForm } })).toEqual([ |
| 151 | + `${FORM_FIELD_UNKNOWN}@views[0].formViews.edit.sections[0].fields[1]`, |
| 152 | + ]); |
| 153 | + }); |
| 154 | + |
| 155 | + it('names the sub-container in `where`, so two forms under one view are distinguishable', () => { |
| 156 | + const findings = validateFormLayout({ |
| 157 | + objects, |
| 158 | + views: [{ |
| 159 | + name: 'contract_views', |
| 160 | + object: 'contract', |
| 161 | + form: brokenForm, |
| 162 | + formViews: { edit: brokenForm, create: brokenForm }, |
| 163 | + }], |
| 164 | + }); |
| 165 | + expect(findings.map((f) => f.where)).toEqual([ |
| 166 | + 'view "contract_views" · form', |
| 167 | + 'view "contract_views" · formViews.edit', |
| 168 | + 'view "contract_views" · formViews.create', |
| 169 | + ]); |
| 170 | + }); |
| 171 | + |
| 172 | + it('a sub-container INHERITS the container binding when it declares none', () => { |
| 173 | + // The canonical container carries `object`; a `formViews.<key>` entry that |
| 174 | + // omits its own `data.object` still renders against that object, so a |
| 175 | + // dangling field reference there is as real as anywhere else. |
| 176 | + const findings = validateFormLayout({ |
| 177 | + objects, |
| 178 | + views: [{ |
| 179 | + name: 'contract_views', |
| 180 | + object: 'contract', |
| 181 | + formViews: { edit: { sections: [{ fields: ['ghost_inherited'] }] } }, |
| 182 | + }], |
| 183 | + }); |
| 184 | + expect(findings.map((f) => f.rule)).toEqual([FORM_FIELD_UNKNOWN]); |
| 185 | + expect(findings[0].message).toContain('ghost_inherited'); |
| 186 | + expect(findings[0].message).toContain('"contract"'); |
| 187 | + }); |
| 188 | + |
| 189 | + it('reads the legacy `groups` bucket too — measured NOT folded into `sections` at parse', () => { |
| 190 | + const findings = validateFormLayout({ |
| 191 | + objects, |
| 192 | + views: [{ |
| 193 | + name: 'contract_views', |
| 194 | + object: 'contract', |
| 195 | + form: { data: { object: 'contract' }, groups: [{ fields: ['name', { field: 'ghost_g', colSpan: 3 }] }] }, |
| 196 | + }], |
| 197 | + }); |
| 198 | + expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([ |
| 199 | + `${FORM_FIELD_UNKNOWN}@views[0].form.groups[0].fields[1]`, |
| 200 | + `${FORM_COLSPAN_ABSOLUTE}@views[0].form.groups[0].fields[1].colSpan`, |
| 201 | + ]); |
| 202 | + }); |
| 203 | + |
| 204 | + it('reports a map-shaped `views` at the key it sits at, not a synthetic index', () => { |
| 205 | + const findings = validateFormLayout({ |
| 206 | + objects, |
| 207 | + views: { contract_views: { object: 'contract', formViews: { edit: brokenForm } } }, |
| 208 | + }); |
| 209 | + expect(findings.map((f) => f.path)).toEqual(['views.contract_views.formViews.edit.sections[0].fields[1]']); |
| 210 | + }); |
| 211 | + |
| 212 | + // NEGATIVE polarity — this one cannot go red when the traversal is reverted |
| 213 | + // (a narrower walk trivially satisfies "does not walk list views"). It is |
| 214 | + // here to pin the SCHEMA fact, not the fix: `list` / `listViews.<key>` are |
| 215 | + // `ObjectListViewSchema`, which declares no `sections`, so a `sections` key |
| 216 | + // there is not a form and must not be judged as one. |
| 217 | + it('does not walk `list` / `listViews.*` (they carry no sections by schema)', () => { |
| 218 | + expect(validateFormLayout({ |
| 219 | + objects, |
| 220 | + views: [{ name: 'contract_views', object: 'contract', list: brokenForm, listViews: { all: brokenForm } }], |
| 221 | + })).toEqual([]); |
| 222 | + }); |
| 223 | +}); |
| 224 | + |
| 225 | +// ─────────────────────────────────────────────────────────────────────────── |
| 226 | +// The anti-ghost pin: the rule must fire on a stack built through the REAL |
| 227 | +// authoring door, not only on a hand-shaped object literal. |
| 228 | +// |
| 229 | +// `cliTierFor` is exactly what `os validate` / `os compile` hand the registry: |
| 230 | +// `defineStack` (which Zod-PARSES) followed by `normalizeStackInput`. So a |
| 231 | +// fixture that survives it is a shape an author can really ship — and a rule |
| 232 | +// that reports on it is really reachable. Without this, "the fix works" could |
| 233 | +// still mean "the fix works on shapes the schema refuses", which is how this |
| 234 | +// rule was silently dead for four minor versions. |
| 235 | +// ─────────────────────────────────────────────────────────────────────────── |
| 236 | + |
| 237 | +/** `defineStack` warns on the D2 conversion channel; keep test output clean. */ |
| 238 | +function quietly<T>(fn: () => T): { value?: T; error?: Error } { |
| 239 | + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 240 | + try { |
| 241 | + return { value: fn() }; |
| 242 | + } catch (e) { |
| 243 | + return { error: e as Error }; |
| 244 | + } finally { |
| 245 | + spy.mockRestore(); |
| 246 | + } |
| 247 | +} |
| 248 | + |
| 249 | +const cliTierFor = (stack: AnyRec): AnyRec => |
| 250 | + normalizeStackInput(defineStack(stack as never) as unknown as AnyRec); |
| 251 | + |
| 252 | +describe('#6251 — reachable on a REAL parsed app stack', () => { |
| 253 | + const manifest = { |
| 254 | + id: 'com.example.formlayout', |
| 255 | + namespace: 'fl', |
| 256 | + version: '1.0.0', |
| 257 | + type: 'app', |
| 258 | + name: 'Form Layout Probe', |
| 259 | + engines: { protocol: '^17' }, |
| 260 | + }; |
| 261 | + |
| 262 | + const data = { provider: 'object' as const, object: 'fl_contact' }; |
| 263 | + |
| 264 | + /** |
| 265 | + * The container ladder copied from `examples/app-showcase/src/ui/views/ |
| 266 | + * contact.view.ts` — default `form` grouped into sections, plus a sparse |
| 267 | + * `formViews.create` override — with one dangling field planted in each. |
| 268 | + */ |
| 269 | + const appShape: AnyRec = { |
| 270 | + manifest, |
| 271 | + objects: [{ |
| 272 | + name: 'fl_contact', |
| 273 | + label: 'Contact', |
| 274 | + fields: { |
| 275 | + name: { type: 'text', label: 'Name' }, |
| 276 | + email: { type: 'email', label: 'Email' }, |
| 277 | + phone: { type: 'phone', label: 'Phone' }, |
| 278 | + }, |
| 279 | + }], |
| 280 | + views: [{ |
| 281 | + name: 'fl_contact', |
| 282 | + object: 'fl_contact', |
| 283 | + list: { label: 'Contacts', type: 'grid', data, columns: [{ field: 'name' }] }, |
| 284 | + form: { |
| 285 | + type: 'simple', |
| 286 | + data, |
| 287 | + sections: [{ name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'ghost_default'] }], |
| 288 | + }, |
| 289 | + formViews: { |
| 290 | + create: { |
| 291 | + type: 'simple', |
| 292 | + data, |
| 293 | + title: 'New contact', |
| 294 | + sections: [{ label: 'Who is this?', columns: 1, fields: ['name', { field: 'ghost_named', colSpan: 2 }] }], |
| 295 | + }, |
| 296 | + }, |
| 297 | + }], |
| 298 | + }; |
| 299 | + |
| 300 | + it('the fixture is a shape the strict schema ACCEPTS (so the pin is not testing a rejected stack)', () => { |
| 301 | + const { error, value } = quietly(() => cliTierFor(structuredClone(appShape))); |
| 302 | + expect(error).toBeUndefined(); |
| 303 | + // And it really is the container shape: no `sections` at the entry root. |
| 304 | + const view = (value!.views as AnyRec[])[0]; |
| 305 | + expect(Object.keys(view).sort()).toEqual(['form', 'formViews', 'list', 'name', 'object']); |
| 306 | + expect(view.sections).toBeUndefined(); |
| 307 | + }); |
| 308 | + |
| 309 | + it('reports every planted defect on that stack — this is the assertion #6251 exists for', () => { |
| 310 | + const { value } = quietly(() => cliTierFor(structuredClone(appShape))); |
| 311 | + expect(validateFormLayout(value!).map((f) => `${f.rule}@${f.path}`)).toEqual([ |
| 312 | + `${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[2]`, |
| 313 | + `${FORM_FIELD_UNKNOWN}@views[0].formViews.create.sections[0].fields[1]`, |
| 314 | + `${FORM_COLSPAN_ABSOLUTE}@views[0].formViews.create.sections[0].fields[1].colSpan`, |
| 315 | + ]); |
| 316 | + }); |
| 317 | + |
| 318 | + // EMPTY-GREEN, declared. Revert the container ladder and this test still |
| 319 | + // passes — because nothing was read, not because nothing is wrong. It is kept |
| 320 | + // (a false-positive guard is worth having) but it is only meaningful PAIRED |
| 321 | + // with the test above, which proves on the same fixture family that the |
| 322 | + // traversal does read these sites. If that one is ever weakened, this one |
| 323 | + // stops guarding anything; do not treat it as independent cover. |
| 324 | + it('a CLEAN app stack of the same shape reports nothing — the fix adds no false positives', () => { |
| 325 | + const clean = structuredClone(appShape); |
| 326 | + const view = (clean.views as AnyRec[])[0]; |
| 327 | + (view.form as AnyRec).sections = [{ name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }]; |
| 328 | + (view.formViews as AnyRec).create = { |
| 329 | + type: 'simple', data, title: 'New contact', |
| 330 | + sections: [{ label: 'Who is this?', columns: 1, fields: ['name', { field: 'email', span: 'full' }] }], |
| 331 | + }; |
| 332 | + const { error, value } = quietly(() => cliTierFor(clean)); |
| 333 | + expect(error).toBeUndefined(); |
| 334 | + expect(validateFormLayout(value!)).toEqual([]); |
| 335 | + }); |
| 336 | +}); |
0 commit comments