|
28 | 28 | * 3. THE CONTROLS — valid columns on both carriers, a column that declares a |
29 | 29 | * `type`, a child field of another type, and a child object this stack |
30 | 30 | * does not declare are all accepted. |
| 31 | + * 4. THE TABLE — every type-conditional rule of the column schema, found by |
| 32 | + * probing it, reaches `defineStack` through the hydrated-type table. |
| 33 | + * 5. THE CHAIN STEP — the `field` → `name` respelling reaches the form-view |
| 34 | + * carrier (ADR-0087's lossless-break step): stored rows in every `view` |
| 35 | + * spelling and `os migrate meta` get it; the authoring funnel does not. |
31 | 36 | * |
32 | 37 | * Key-vs-value note: the column rules judge the KEY on one type whatever its |
33 | 38 | * value, so each refusal is a full parse failure at the column's own path, and |
34 | 39 | * each control is a full success — never mere absence of `unrecognized_keys`. |
35 | 40 | */ |
36 | 41 |
|
37 | 42 | import { describe, expect, it } from 'vitest'; |
| 43 | +import { ALL_CONVERSIONS } from './conversions/registry'; |
| 44 | +import { applyConversionsToStoredItem } from './conversions/stored'; |
38 | 45 | import { InlineGridColumnSchema } from './data/field.zod'; |
39 | | -import { FormViewSchema } from './ui/view.zod'; |
| 46 | +import { applyMetaMigrations } from './migrations/chain'; |
| 47 | +import { FormViewSchema, ViewMetadataSchema } from './ui/view.zod'; |
40 | 48 | import { defineStack } from './stack.zod'; |
41 | 49 |
|
42 | 50 | type Issue = { code: string; path: PropertyKey[]; message: string }; |
@@ -240,3 +248,149 @@ describe('#20901 — defineStack judges an identity-only column by the type it r |
240 | 248 | expect(() => build(stackWithInlineColumns([remedied]))).not.toThrow(); |
241 | 249 | }); |
242 | 250 | }); |
| 251 | + |
| 252 | +// --------------------------------------------------------------------------- |
| 253 | +// The hydrated-type table in `stack.zod.ts` covers every type-conditional rule. |
| 254 | +// --------------------------------------------------------------------------- |
| 255 | + |
| 256 | +/** Values tried for each column key; a key is probed with every one its own schema accepts. */ |
| 257 | +const PROBE_VALUES: readonly unknown[] = [0, 2, true, 'x', ['x'], [{ label: 'A', value: 'a' }]]; |
| 258 | + |
| 259 | +type TypeConditionalRule = { type: string; key: string; value: unknown }; |
| 260 | + |
| 261 | +/** |
| 262 | + * Every rule of `InlineGridColumnSchema` that depends on the column's `type`, |
| 263 | + * found by probing the schema rather than listed: a (type, key, value) is one |
| 264 | + * when the column parses with the key alone and with the type alone, and not |
| 265 | + * with both. A key no probe value satisfies is reported, not skipped — its rules |
| 266 | + * would be invisible here. |
| 267 | + */ |
| 268 | +function typeConditionalRules(): { rules: TypeConditionalRule[]; unprobed: string[] } { |
| 269 | + const shape = InlineGridColumnSchema.shape as unknown as Record<string, unknown>; |
| 270 | + const types = (shape.type as { unwrap(): { options: string[] } }).unwrap().options; |
| 271 | + const keys = Object.keys(shape).filter((key) => key !== 'name' && key !== 'type'); |
| 272 | + const accepts = (column: Record<string, unknown>) => InlineGridColumnSchema.safeParse(column).success; |
| 273 | + const rules: TypeConditionalRule[] = []; |
| 274 | + const unprobed: string[] = []; |
| 275 | + for (const type of types) { |
| 276 | + if (!accepts({ name: 'probe', type })) rules.push({ type, key: '(none)', value: undefined }); |
| 277 | + } |
| 278 | + for (const key of keys) { |
| 279 | + const values = PROBE_VALUES.filter((value) => accepts({ name: 'probe', [key]: value })); |
| 280 | + if (values.length === 0) unprobed.push(key); |
| 281 | + for (const value of values) { |
| 282 | + for (const type of types) { |
| 283 | + if (!accepts({ name: 'probe', type, [key]: value })) rules.push({ type, key, value }); |
| 284 | + } |
| 285 | + } |
| 286 | + } |
| 287 | + return { rules, unprobed }; |
| 288 | +} |
| 289 | + |
| 290 | +/** |
| 291 | + * A stack whose child object carries a field `probe` of `fieldType`, and an |
| 292 | + * identity-only `inlineColumns` entry over it. The field type is the column |
| 293 | + * type's own name: at the `.objectui-sha` pin `db11afd4967c`, |
| 294 | + * `fieldTypeToColumnType` maps each of the column schema's nine types' namesake |
| 295 | + * field type to that same column type. |
| 296 | + */ |
| 297 | +const stackWithProbeField = (fieldType: string, column: Record<string, unknown>) => { |
| 298 | + const child = childObject([column]); |
| 299 | + return { manifest, objects: [PARENT, { ...child, fields: { ...child.fields, probe: { type: fieldType } } }] }; |
| 300 | +}; |
| 301 | + |
| 302 | +describe('#20901 — every type-conditional column rule has its row in the hydrated-type table', () => { |
| 303 | + it('the probe reaches every column key, and finds the rule the tree holds today', () => { |
| 304 | + const { rules, unprobed } = typeConditionalRules(); |
| 305 | + expect(unprobed, 'column keys no probe value satisfies — add a value to PROBE_VALUES').toEqual([]); |
| 306 | + // Anti-vacuity: a probe that found nothing would pass the check below vacuously. |
| 307 | + expect(rules.some((r) => r.type === 'currency' && r.key === 'scale')).toBe(true); |
| 308 | + }); |
| 309 | + |
| 310 | + it('defineStack refuses each one on an identity-only column over a field of that type', () => { |
| 311 | + for (const { type, key, value } of typeConditionalRules().rules) { |
| 312 | + const column = key === '(none)' ? { name: 'probe' } : { name: 'probe', [key]: value }; |
| 313 | + const subject = `the column schema refuses ${key === '(none)' ? 'a bare' : `\`${key}: ${JSON.stringify(value)}\` on a`} ` |
| 314 | + + `\`${type}\` column`; |
| 315 | + let refusal: Refusal | undefined; |
| 316 | + try { |
| 317 | + build(stackWithProbeField(type, column)); |
| 318 | + } catch (error) { |
| 319 | + refusal = error as Refusal; |
| 320 | + } |
| 321 | + expect( |
| 322 | + refusal, |
| 323 | + `${subject}, and defineStack accepted an identity-only column over a \`${type}\` child field ` |
| 324 | + + 'carrying it — add the row to HYDRATED_INLINE_COLUMN_TYPE in stack.zod.ts', |
| 325 | + ).toBeDefined(); |
| 326 | + expect(refusal!.code, subject).toBe('STACK_CROSS_REFERENCE_INVALID'); |
| 327 | + expect(refusal!.status, subject).toBe(422); |
| 328 | + } |
| 329 | + }); |
| 330 | +}); |
| 331 | + |
| 332 | +// --------------------------------------------------------------------------- |
| 333 | +// The `field` → `name` respelling reaches the form-view carrier (ADR-0087 D2). |
| 334 | +// --------------------------------------------------------------------------- |
| 335 | + |
| 336 | +describe('#20901 — the `field` → `name` respelling is a chain step on the form-view carrier', () => { |
| 337 | + const ID = 'form-view-subform-columns-canonicalized'; |
| 338 | + const conversion = () => ALL_CONVERSIONS.find((c) => c.id === ID); |
| 339 | + const form = (columns: unknown[]) => ({ ...FORM_BASE, subforms: [{ childObject: 'crm_invoice_line', columns }] }); |
| 340 | + const LEGACY = [{ field: 'quantity', label: 'Qty' }, { name: 'amount' }]; |
| 341 | + const RESPELLED = [{ name: 'quantity', label: 'Qty' }, { name: 'amount' }]; |
| 342 | + |
| 343 | + /** A stored `view` row in each spelling `ViewMetadataSchema` accepts. */ |
| 344 | + const storedRows = (columns: unknown[]) => ({ |
| 345 | + container: { name: 'crm_invoice', object: 'crm_invoice', formViews: { entry: form(columns) } }, |
| 346 | + record: { name: 'crm_invoice.entry', object: 'crm_invoice', viewKind: 'form', config: form(columns) }, |
| 347 | + overlay: { name: 'crm_invoice.edit', object: 'crm_invoice', viewKind: 'form', ...form(columns) }, |
| 348 | + }); |
| 349 | + const columnsOf = (row: Record<string, any>): unknown[] => |
| 350 | + (row.formViews?.entry ?? row.config ?? row).subforms[0].columns; |
| 351 | + |
| 352 | + it('is a retired protocol-18 entry stamped with the last release whose form-view carrier accepted `field`', () => { |
| 353 | + expect(conversion()?.toMajor).toBe(18); |
| 354 | + expect(conversion()?.retiredFromLoadPath).toBe(true); |
| 355 | + expect(conversion()?.retiredAfter).toBe('17.5.0'); |
| 356 | + }); |
| 357 | + |
| 358 | + it('a stored row in each `view` spelling is respelled, and only then parses', () => { |
| 359 | + for (const [spelling, row] of Object.entries(storedRows(LEGACY))) { |
| 360 | + expect(ViewMetadataSchema.safeParse(row).success, `${spelling}, as stored`).toBe(false); |
| 361 | + const converted = applyConversionsToStoredItem('view', structuredClone(row)) as Record<string, any>; |
| 362 | + expect(columnsOf(converted), spelling).toEqual(RESPELLED); |
| 363 | + expect(ViewMetadataSchema.safeParse(converted).success, `${spelling}, converted`).toBe(true); |
| 364 | + } |
| 365 | + }); |
| 366 | + |
| 367 | + it('CONTROLS — an entry already spelled `name`, and one carrying both keys, are left as they are', () => { |
| 368 | + const columns = [{ name: 'quantity' }, { field: 'amount', name: 'total' }]; |
| 369 | + for (const [spelling, row] of Object.entries(storedRows(columns))) { |
| 370 | + expect(applyConversionsToStoredItem('view', row), spelling).toBe(row); |
| 371 | + } |
| 372 | + }); |
| 373 | + |
| 374 | + it('`os migrate meta` replays it: the step-18 chain respells a source stack, one edit per column', () => { |
| 375 | + const result = applyMetaMigrations(structuredClone(stackWithSubformColumns(LEGACY, 'formViews')), 17, 18); |
| 376 | + const views = result.stack.views as Array<Record<string, any>>; |
| 377 | + expect(views[0].formViews.entry.subforms[0].columns).toEqual(RESPELLED); |
| 378 | + expect(result.applied.filter((a) => a.conversionId === ID).map((a) => a.path)).toEqual([ |
| 379 | + 'views[0].formViews.entry.subforms[0].columns[0].name', |
| 380 | + ]); |
| 381 | + }); |
| 382 | + |
| 383 | + it('the authoring funnel does not replay it: defineStack refuses the `field` spelling at the schema parse', () => { |
| 384 | + const refusal = refusalOf(stackWithSubformColumns(LEGACY)); |
| 385 | + expect(refusal.code).toBe('STACK_SCHEMA_INVALID'); |
| 386 | + expect(refusal.status).toBe(422); |
| 387 | + }); |
| 388 | + |
| 389 | + it('both carriers get one respelling: a field\'s `inlineColumns` and a subform\'s `columns` convert alike', () => { |
| 390 | + const columns = [...LEGACY, { field: 'amount', name: 'total' }, 'not-a-column']; |
| 391 | + const object = applyConversionsToStoredItem('object', childObject(columns)) as Record<string, any>; |
| 392 | + const view = applyConversionsToStoredItem('view', storedRows(columns).container) as Record<string, any>; |
| 393 | + expect(columnsOf(view)).toEqual(object.fields.invoice.inlineColumns); |
| 394 | + expect(columnsOf(view)).toEqual([...RESPELLED, { field: 'amount', name: 'total' }, 'not-a-column']); |
| 395 | + }); |
| 396 | +}); |
0 commit comments