Skip to content

Commit 14add0a

Browse files
committed
test(spec): pin the zone-less time value contract and its stored-default disposition
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55083e5 commit 14add0a

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { ObjectSchema } from '../data/object.zod.js';
6+
import { ActionSchema } from '../ui/action.zod.js';
7+
import { applyConversions } from './apply.js';
8+
import { ALL_CONVERSIONS } from './registry.js';
9+
import { applyConversionsToStoredItem } from './stored.js';
10+
import { CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from './types.js';
11+
12+
/**
13+
* [#20740] `time-default-utc-suffix-dropped` — the ADR-0087 disposition of a
14+
* stored `time` default with a zone, now that the stored form refuses one.
15+
*
16+
* The fixture pair in `conversions.test.ts` proves before → after over the
17+
* whole table. This file pins it where it matters, on a stored row: a `Z` or a
18+
* zero offset is dropped and the row then parses; a non-zero offset is left
19+
* byte-identical, reported as a TODO naming the field, and still refused by the
20+
* schema at that field.
21+
*/
22+
23+
const ID = 'time-default-utc-suffix-dropped';
24+
25+
function storedObject(defaultValue: unknown, type = 'time') {
26+
return { name: 'shift', label: 'Shift', fields: { starts_at: { type, label: 'Starts', defaultValue } } };
27+
}
28+
29+
function convertStored(type: string, row: Record<string, unknown>) {
30+
const notices: ConversionNotice[] = [];
31+
const todos: ConversionTodoNotice[] = [];
32+
const item = applyConversionsToStoredItem(type, row, {
33+
onNotice: (n) => notices.push(n),
34+
onTodo: (t) => todos.push(t),
35+
});
36+
return { item, notices: notices.filter((n) => n.conversionId === ID), todos };
37+
}
38+
39+
function defaultValueIssuePaths(row: unknown): string[] {
40+
const r = ObjectSchema.safeParse(row);
41+
return r.success ? [] : r.error.issues.map((i) => i.path.join('.')).filter((p) => p.endsWith('defaultValue'));
42+
}
43+
44+
describe('[#20740] time-default-utc-suffix-dropped (ADR-0087 D2)', () => {
45+
it('is registered for protocol 18 and retired from the authoring load path', () => {
46+
const entry = ALL_CONVERSIONS.find((c) => c.id === ID);
47+
expect(entry, 'the conversion is registered').toBeDefined();
48+
expect(entry!.toMajor).toBe(18);
49+
expect(entry!.retiredFromLoadPath).toBe(true);
50+
});
51+
52+
it('a stored field default with a `Z` or a zero offset loses the suffix, and the row then parses', () => {
53+
for (const [stored, wallClock] of [
54+
['10:00Z', '10:00'],
55+
['10:00:00+00:00', '10:00:00'],
56+
['10:00:00.250-0000', '10:00:00.250'],
57+
] as const) {
58+
const row = storedObject(stored);
59+
expect(defaultValueIssuePaths(row), `${stored} is refused before the replay`).toEqual(['fields.starts_at.defaultValue']);
60+
const { item, notices, todos } = convertStored('object', row);
61+
expect((item as typeof row).fields.starts_at.defaultValue).toBe(wallClock);
62+
expect(notices.map((n) => [n.path, n.from, n.to])).toEqual([
63+
['objects[0].fields.starts_at.defaultValue', JSON.stringify(stored), JSON.stringify(wallClock)],
64+
]);
65+
expect(todos).toEqual([]);
66+
expect(defaultValueIssuePaths(item), `${stored} parses after the replay`).toEqual([]);
67+
}
68+
});
69+
70+
it('a non-zero offset is left as stored, reported as a TODO naming the field, and still refused at that field', () => {
71+
const row = storedObject('10:00+08:00');
72+
const { item, notices, todos } = convertStored('object', row);
73+
expect(item, 'nothing is rewritten').toBe(row);
74+
expect(notices).toEqual([]);
75+
expect(todos).toHaveLength(1);
76+
expect(todos[0]).toMatchObject({
77+
code: CONVERSION_TODO_CODE,
78+
conversionId: ID,
79+
path: 'objects[0].fields.starts_at.defaultValue',
80+
from: '"10:00+08:00"',
81+
});
82+
expect(todos[0]!.reason).toContain('"starts_at"');
83+
expect(defaultValueIssuePaths(item)).toEqual(['fields.starts_at.defaultValue']);
84+
});
85+
86+
it('a stored action row: a `time` param default loses a `Z`; other params are untouched', () => {
87+
const row = {
88+
name: 'clock_in',
89+
label: 'Clock in',
90+
type: 'script',
91+
target: 'clockIn',
92+
params: [
93+
{ name: 'at', type: 'time', defaultValue: '07:45Z' },
94+
{ name: 'note', type: 'text', defaultValue: '07:45Z' },
95+
{ field: 'starts_at', defaultValue: '07:45Z' },
96+
],
97+
};
98+
expect(ActionSchema.safeParse(row).success).toBe(false);
99+
const { item, notices } = convertStored('action', row);
100+
const params = (item as typeof row).params;
101+
expect(params[0]!.defaultValue).toBe('07:45');
102+
expect(params[1]).toBe(row.params[1]);
103+
expect(params[2]).toBe(row.params[2]);
104+
expect(notices.map((n) => n.path)).toEqual(['actions[0].params[0].defaultValue']);
105+
expect(ActionSchema.safeParse(item).success).toBe(true);
106+
});
107+
108+
it('control: what the old stored form refused, a zone-less default and a non-`time` field are the same reference', () => {
109+
for (const row of [
110+
storedObject('10:00'),
111+
storedObject('10:00z'),
112+
storedObject('25:00Z'),
113+
storedObject('10:00Z', 'text'),
114+
storedObject({ dialect: 'cel', source: 'now()' }),
115+
]) {
116+
const { item, notices, todos } = convertStored('object', row);
117+
expect(item).toBe(row);
118+
expect(notices).toEqual([]);
119+
expect(todos).toEqual([]);
120+
}
121+
});
122+
123+
it('is idempotent — the converted result replays to itself with no second notice', () => {
124+
const once = applyConversions({ objects: [storedObject('10:00Z')] }, { includeRetired: true });
125+
const notices: ConversionNotice[] = [];
126+
const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) });
127+
expect(twice).toBe(once);
128+
expect(notices).toEqual([]);
129+
});
130+
});

‎packages/spec/src/data/field-default-value.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ const CASES: Case[] = [
5555
accepted: false,
5656
contains: ['ISO-8601 instant'],
5757
},
58+
// [#20740] A time is a zone-less wall clock (ADR-0053 D-C1). These parsed
59+
// clean before, and every insert that fell back to them was then refused
60+
// `invalid_time` on a field the caller never sent.
61+
{
62+
label: 'time + a wall clock with a Z',
63+
field: { type: 'time', defaultValue: '10:00Z' },
64+
accepted: false,
65+
contains: ['(time)', '"10:00Z"'],
66+
},
67+
{
68+
label: 'time + a wall clock with a non-zero offset',
69+
field: { type: 'time', defaultValue: '10:00+08:00' },
70+
accepted: false,
71+
contains: ['(time)', '"10:00+08:00"'],
72+
},
5873
{
5974
label: 'select + a non-member of its own options',
6075
field: {
@@ -116,6 +131,7 @@ const CASES: Case[] = [
116131
{ label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true },
117132
{ label: 'VALID boolean', field: { type: 'boolean', defaultValue: false }, accepted: true },
118133
{ label: 'VALID date (calendar day)', field: { type: 'date', defaultValue: '2026-08-10' }, accepted: true },
134+
{ label: 'VALID time (a zone-less wall clock)', field: { type: 'time', defaultValue: '10:00' }, accepted: true },
119135
{
120136
label: 'VALID select member',
121137
field: { type: 'select', options: [{ label: 'Gold', value: 'gold' }], defaultValue: 'gold' },

‎packages/spec/src/data/field-value.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,14 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
274274
bad({ type: 'time' }, 'not-a-time');
275275
});
276276

277+
it('[#20740] time carries no zone: a `Z` or an offset is refused, as the record validator refuses it', () => {
278+
ok({ type: 'time' }, '14:30:00.500');
279+
bad({ type: 'time' }, '14:30:00Z');
280+
bad({ type: 'time' }, '14:30+08:00');
281+
bad({ type: 'time' }, '08:15:00-0530');
282+
bad({ type: 'time' }, '14:30+00:00');
283+
});
284+
277285
it('option types enforce declared option codes; free-form without options', () => {
278286
const options = [{ value: 'high' }, { value: 'low' }];
279287
ok({ type: 'select', options }, 'high');

‎packages/spec/src/ui/action-param-default-value.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ const CASES: Array<{ label: string; param: Record<string, unknown>; accepted: bo
7171
accepted: false,
7272
},
7373
{ label: 'boolean + a string', param: { name: 'notify', type: 'boolean', defaultValue: 'yes' }, accepted: false },
74+
// [#20740] A time is a zone-less wall clock (ADR-0053 D-C1).
75+
{ label: 'time + a wall clock with a Z', param: { name: 'at', type: 'time', defaultValue: '10:00Z' }, accepted: false },
76+
{
77+
label: 'time + a wall clock with a non-zero offset',
78+
param: { name: 'at', type: 'time', defaultValue: '10:00+08:00' },
79+
accepted: false,
80+
},
7481
{
7582
label: 'lookup + an embedded record object instead of an id',
7683
param: { name: 'owner', type: 'lookup', reference: 'sys_user', defaultValue: { id: 'usr_1', name: 'Ada' } },
@@ -95,6 +102,7 @@ const CASES: Array<{ label: string; param: Record<string, unknown>; accepted: bo
95102
accepted: true,
96103
},
97104
{ label: 'VALID date', param: { name: 'due', type: 'date', defaultValue: '2026-08-10' }, accepted: true },
105+
{ label: 'VALID time (a zone-less wall clock)', param: { name: 'at', type: 'time', defaultValue: '10:00' }, accepted: true },
98106
{ label: 'VALID boolean', param: { name: 'notify', type: 'boolean', defaultValue: true }, accepted: true },
99107
{
100108
label: 'json — an explicitly OPEN value contract, so any default rides',
@@ -238,3 +246,15 @@ describe('#6970 ActionParamSchema.defaultValue — authored defaults meet the pa
238246
}
239247
});
240248
});
249+
250+
describe('[#20740] the submit door refuses a zone-suffixed `time` value', () => {
251+
it('`validateActionParams` answers `invalid_shape` naming the param; a zone-less wall clock passes', () => {
252+
for (const value of ['10:00Z', '10:00+08:00', '10:00:00-0530']) {
253+
const issue = submitIssue({ name: 'at', type: 'time', defaultValue: value });
254+
expect(issue, value).not.toBeNull();
255+
expect(issue!.param).toBe('at');
256+
expect(issue!.code).toBe('invalid_shape');
257+
}
258+
expect(submitIssue({ name: 'at', type: 'time', defaultValue: '10:00' })).toBeNull();
259+
});
260+
});

0 commit comments

Comments
 (0)