Skip to content

Commit 9b6f85a

Browse files
committed
wip(spec): pin the hydrated-type table against the column schema's type-conditional rules, and the form-view respelling's reach
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b63601 commit 9b6f85a

1 file changed

Lines changed: 155 additions & 1 deletion

File tree

‎packages/spec/src/inline-grid-column-carriers.test.ts‎

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,23 @@
2828
* 3. THE CONTROLS — valid columns on both carriers, a column that declares a
2929
* `type`, a child field of another type, and a child object this stack
3030
* 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.
3136
*
3237
* Key-vs-value note: the column rules judge the KEY on one type whatever its
3338
* value, so each refusal is a full parse failure at the column's own path, and
3439
* each control is a full success — never mere absence of `unrecognized_keys`.
3540
*/
3641

3742
import { describe, expect, it } from 'vitest';
43+
import { ALL_CONVERSIONS } from './conversions/registry';
44+
import { applyConversionsToStoredItem } from './conversions/stored';
3845
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';
4048
import { defineStack } from './stack.zod';
4149

4250
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
240248
expect(() => build(stackWithInlineColumns([remedied]))).not.toThrow();
241249
});
242250
});
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

Comments
 (0)