Skip to content

Commit 2e6a945

Browse files
authored
Merge pull request #856 from objectstack-ai/copilot/add-object-field-validation
2 parents 5a39b6e + cd1b47b commit 2e6a945

File tree

9 files changed

+396
-9
lines changed

9 files changed

+396
-9
lines changed

examples/app-todo/src/translations/en.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,17 @@ export const en: TranslationData = {
3535
urgent: 'Urgent',
3636
},
3737
},
38-
category: { label: 'Category' },
38+
category: {
39+
label: 'Category',
40+
options: {
41+
personal: 'Personal',
42+
work: 'Work',
43+
shopping: 'Shopping',
44+
health: 'Health',
45+
finance: 'Finance',
46+
other: 'Other',
47+
},
48+
},
3949
due_date: { label: 'Due Date' },
4050
reminder_date: { label: 'Reminder Date/Time' },
4151
completed_date: { label: 'Completed Date' },
@@ -51,7 +61,15 @@ export const en: TranslationData = {
5161
},
5262
},
5363
is_recurring: { label: 'Recurring Task' },
54-
recurrence_type: { label: 'Recurrence Type' },
64+
recurrence_type: {
65+
label: 'Recurrence Type',
66+
options: {
67+
daily: 'Daily',
68+
weekly: 'Weekly',
69+
monthly: 'Monthly',
70+
yearly: 'Yearly',
71+
},
72+
},
5573
recurrence_interval: { label: 'Recurrence Interval' },
5674
is_completed: { label: 'Is Completed' },
5775
is_overdue: { label: 'Is Overdue' },

examples/app-todo/src/translations/ja-JP.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,17 @@ export const jaJP: TranslationData = {
3434
urgent: '緊急',
3535
},
3636
},
37-
category: { label: 'カテゴリ' },
37+
category: {
38+
label: 'カテゴリ',
39+
options: {
40+
personal: '個人',
41+
work: '仕事',
42+
shopping: '買い物',
43+
health: '健康',
44+
finance: '財務',
45+
other: 'その他',
46+
},
47+
},
3848
due_date: { label: '期日' },
3949
reminder_date: { label: 'リマインダー日時' },
4050
completed_date: { label: '完了日' },
@@ -50,7 +60,15 @@ export const jaJP: TranslationData = {
5060
},
5161
},
5262
is_recurring: { label: '繰り返しタスク' },
53-
recurrence_type: { label: '繰り返しタイプ' },
63+
recurrence_type: {
64+
label: '繰り返しタイプ',
65+
options: {
66+
daily: '毎日',
67+
weekly: '毎週',
68+
monthly: '毎月',
69+
yearly: '毎年',
70+
},
71+
},
5472
recurrence_interval: { label: '繰り返し間隔' },
5573
is_completed: { label: '完了済み' },
5674
is_overdue: { label: '期限超過' },
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { Task } from '../objects/task.object';
5+
import { en } from './en';
6+
import { zhCN } from './zh-CN';
7+
import type { TranslationData } from '@objectstack/spec/system';
8+
9+
/**
10+
* Translation Completeness Test
11+
*
12+
* Validates that every field and every select option in the Task object
13+
* definition has a corresponding translation in each locale.
14+
*/
15+
16+
const fieldNames = Object.keys(Task.fields);
17+
18+
const selectFields = Object.entries(Task.fields)
19+
.filter(([, f]) => Array.isArray(f.options) && f.options.length > 0)
20+
.map(([name, f]) => ({
21+
name,
22+
values: f.options!.map((o: { value: string }) => o.value),
23+
}));
24+
25+
describe.each([
26+
['en', en],
27+
['zh-CN', zhCN],
28+
] as [string, TranslationData][])('%s translation completeness', (locale, t) => {
29+
30+
it('should have task object translation', () => {
31+
expect(t.objects?.task).toBeDefined();
32+
expect(t.objects?.task?.label).toBeTruthy();
33+
});
34+
35+
it.each(fieldNames)('field: %s', (name) => {
36+
expect(
37+
t.objects?.task?.fields?.[name]?.label,
38+
`[${locale}] Missing label for field "${name}"`,
39+
).toBeTruthy();
40+
});
41+
42+
it.each(selectFields)('options: $name', ({ name, values }) => {
43+
for (const v of values) {
44+
expect(
45+
t.objects?.task?.fields?.[name]?.options?.[v],
46+
`[${locale}] Missing option "${v}" for field "${name}"`,
47+
).toBeTruthy();
48+
}
49+
});
50+
});

examples/app-todo/src/translations/zh-CN.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { TranslationData } from '@objectstack/spec/system';
4+
import type { StrictObjectTranslation } from '@objectstack/spec/system';
5+
import { Task } from '../objects/task.object';
6+
7+
type TaskTranslation = StrictObjectTranslation<typeof Task>;
48

59
/**
610
* 简体中文 (zh-CN) — Todo App Translations
@@ -34,7 +38,17 @@ export const zhCN: TranslationData = {
3438
urgent: '紧急',
3539
},
3640
},
37-
category: { label: '分类' },
41+
category: {
42+
label: '分类',
43+
options: {
44+
personal: '个人',
45+
work: '工作',
46+
shopping: '购物',
47+
health: '健康',
48+
finance: '财务',
49+
other: '其他',
50+
},
51+
},
3852
due_date: { label: '截止日期' },
3953
reminder_date: { label: '提醒日期/时间' },
4054
completed_date: { label: '完成日期' },
@@ -50,7 +64,15 @@ export const zhCN: TranslationData = {
5064
},
5165
},
5266
is_recurring: { label: '周期性任务' },
53-
recurrence_type: { label: '重复类型' },
67+
recurrence_type: {
68+
label: '重复类型',
69+
options: {
70+
daily: '每天',
71+
weekly: '每周',
72+
monthly: '每月',
73+
yearly: '每年',
74+
},
75+
},
5476
recurrence_interval: { label: '重复间隔' },
5577
is_completed: { label: '是否完成' },
5678
is_overdue: { label: '是否逾期' },
@@ -60,7 +82,7 @@ export const zhCN: TranslationData = {
6082
notes: { label: '备注' },
6183
category_color: { label: '分类颜色' },
6284
},
63-
},
85+
} satisfies TaskTranslation,
6486
},
6587
apps: {
6688
todo_app: {

packages/spec/src/data/object.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,12 @@ export const ObjectSchema = Object.assign(ObjectSchemaBase, {
378378
* });
379379
* ```
380380
*/
381-
create: (config: z.input<typeof ObjectSchemaBase>): ServiceObject => {
381+
create: <const T extends z.input<typeof ObjectSchemaBase>>(config: T): Omit<ServiceObject, 'fields'> & Pick<T, 'fields'> => {
382382
const withDefaults = {
383383
...config,
384384
label: config.label ?? snakeCaseToLabel(config.name),
385385
};
386-
return ObjectSchemaBase.parse(withDefaults);
386+
return ObjectSchemaBase.parse(withDefaults) as Omit<ServiceObject, 'fields'> & Pick<T, 'fields'>;
387387
},
388388
});
389389

packages/spec/src/system/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export * from './job.zod';
4141
export * from './worker.zod';
4242
export * from './notification.zod';
4343
export * from './translation.zod';
44+
export * from './translation-typegen';
45+
export * from './translation-skeleton';
4446
export * from './collaboration.zod';
4547
export * from './metadata-persistence.zod';
4648
export * from './core-services.zod';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Translation Skeleton Protocol Constants
5+
*
6+
* Defines the placeholder convention used in AI-friendly translation
7+
* skeleton templates. Runtime implementations (skeleton generation,
8+
* validation) belong in implementation packages (CLI, service-i18n, etc.).
9+
*
10+
* @example
11+
* ```json
12+
* {
13+
* "label": "__TRANSLATE__: \"Task\"",
14+
* "fields": {
15+
* "subject": { "label": "__TRANSLATE__: \"Subject\"" }
16+
* }
17+
* }
18+
* ```
19+
*/
20+
21+
/** Placeholder prefix used in translation skeleton output */
22+
export const TRANSLATE_PLACEHOLDER = '__TRANSLATE__';
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, it, expect, expectTypeOf } from 'vitest';
2+
import type { StrictObjectTranslation } from './translation-typegen';
3+
4+
// ────────────────────────────────────────────────────────────────────────────
5+
// Test fixtures — minimal object shapes that mimic ObjectSchema.create() output
6+
// ────────────────────────────────────────────────────────────────────────────
7+
8+
const SimpleObject = {
9+
fields: {
10+
name: { type: 'text' as const, label: 'Name' },
11+
email: { type: 'email' as const, label: 'Email' },
12+
},
13+
};
14+
15+
const ObjectWithSelect = {
16+
fields: {
17+
title: { type: 'text' as const, label: 'Title' },
18+
status: {
19+
type: 'select' as const,
20+
label: 'Status',
21+
options: [
22+
{ label: 'Open', value: 'open' },
23+
{ label: 'Closed', value: 'closed' },
24+
] as const,
25+
},
26+
priority: {
27+
type: 'select' as const,
28+
label: 'Priority',
29+
options: [
30+
{ label: 'Low', value: 'low' },
31+
{ label: 'High', value: 'high' },
32+
] as const,
33+
},
34+
},
35+
};
36+
37+
// ────────────────────────────────────────────────────────────────────────────
38+
// Type-level tests
39+
// ────────────────────────────────────────────────────────────────────────────
40+
41+
describe('StrictObjectTranslation', () => {
42+
43+
it('should require label at object level', () => {
44+
type T = StrictObjectTranslation<typeof SimpleObject>;
45+
expectTypeOf<T>().toHaveProperty('label');
46+
expectTypeOf<T['label']>().toBeString();
47+
});
48+
49+
it('should make pluralLabel optional', () => {
50+
type T = StrictObjectTranslation<typeof SimpleObject>;
51+
expectTypeOf<T>().toHaveProperty('pluralLabel');
52+
});
53+
54+
it('should require all field keys', () => {
55+
type T = StrictObjectTranslation<typeof SimpleObject>;
56+
type FieldKeys = keyof T['fields'];
57+
expectTypeOf<FieldKeys>().toEqualTypeOf<'name' | 'email'>();
58+
});
59+
60+
it('should require label on non-select fields', () => {
61+
type T = StrictObjectTranslation<typeof SimpleObject>;
62+
expectTypeOf<T['fields']['name']>().toHaveProperty('label');
63+
expectTypeOf<T['fields']['name']['label']>().toBeString();
64+
});
65+
66+
it('should require options map on select fields', () => {
67+
type T = StrictObjectTranslation<typeof ObjectWithSelect>;
68+
expectTypeOf<T['fields']['status']>().toHaveProperty('options');
69+
});
70+
71+
it('should require all option values as keys in options map', () => {
72+
type T = StrictObjectTranslation<typeof ObjectWithSelect>;
73+
type StatusOptions = keyof T['fields']['status']['options'];
74+
expectTypeOf<StatusOptions>().toEqualTypeOf<'open' | 'closed'>();
75+
76+
type PriorityOptions = keyof T['fields']['priority']['options'];
77+
expectTypeOf<PriorityOptions>().toEqualTypeOf<'low' | 'high'>();
78+
});
79+
80+
it('should accept a valid complete translation', () => {
81+
type T = StrictObjectTranslation<typeof ObjectWithSelect>;
82+
const valid: T = {
83+
label: 'Test',
84+
fields: {
85+
title: { label: 'Title' },
86+
status: {
87+
label: 'Status',
88+
options: { open: 'Open', closed: 'Closed' },
89+
},
90+
priority: {
91+
label: 'Priority',
92+
options: { low: 'Low', high: 'High' },
93+
},
94+
},
95+
};
96+
expect(valid.label).toBe('Test');
97+
expect(valid.fields.status.options.open).toBe('Open');
98+
});
99+
100+
it('should report TS error when a field is missing', () => {
101+
type T = StrictObjectTranslation<typeof SimpleObject>;
102+
// @ts-expect-error — missing 'email' field
103+
const _invalid: T = {
104+
label: 'Test',
105+
fields: {
106+
name: { label: 'Name' },
107+
},
108+
};
109+
expect(_invalid).toBeDefined();
110+
});
111+
112+
it('should report TS error when an extra field is present', () => {
113+
type T = StrictObjectTranslation<typeof SimpleObject>;
114+
const _invalid: T = {
115+
label: 'Test',
116+
fields: {
117+
name: { label: 'Name' },
118+
email: { label: 'Email' },
119+
// @ts-expect-error — 'ghost' does not exist in source fields
120+
ghost: { label: 'Ghost' },
121+
},
122+
};
123+
expect(_invalid).toBeDefined();
124+
});
125+
126+
it('should report TS error when an option is missing', () => {
127+
type T = StrictObjectTranslation<typeof ObjectWithSelect>;
128+
// @ts-expect-error — missing 'closed' option
129+
const _invalid: T = {
130+
label: 'Test',
131+
fields: {
132+
title: { label: 'Title' },
133+
status: {
134+
label: 'Status',
135+
options: { open: 'Open' },
136+
},
137+
priority: {
138+
label: 'Priority',
139+
options: { low: 'Low', high: 'High' },
140+
},
141+
},
142+
};
143+
expect(_invalid).toBeDefined();
144+
});
145+
});

0 commit comments

Comments
 (0)