Skip to content

Commit e676937

Browse files
Copilothotlong
andcommitted
test: add focused tests for all UI protocol changes and update ROADMAP
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 257bf71 commit e676937

7 files changed

Lines changed: 345 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ObjectStack Protocol — Road Map
22

3-
> **Last Updated:** 2026-02-22
3+
> **Last Updated:** 2026-02-24
44
> **Current Version:** v3.0.8
55
> **Status:** Protocol Specification Complete · Runtime Implementation In Progress
66
@@ -696,6 +696,14 @@ Protocol enhancements and core component implementations for dashboard feature p
696696
- [x] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712))
697697
- [x] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714))
698698
- [x] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714))
699+
- [x] Add required `id` field (SnakeCaseIdentifier) to `DashboardWidgetSchema` for `targetWidgets` referencing
700+
- [x] Unify `WidgetActionTypeSchema` with `ActionSchema.type` — add `script` and `api` types
701+
- [x] Add `.superRefine` conditional validation to `PageSchema` (`recordReview` required for `record_review`, `blankLayout` for `blank`)
702+
- [x] Unify easing naming in `AnimationSchema` (theme.zod) to snake_case (`ease_in`, `ease_out`, `ease_in_out`)
703+
- [x] Add `themeToken` reference to `TransitionConfigSchema` for theme animation token binding
704+
- [x] Add `ResponsiveConfigSchema` and `PerformanceConfigSchema` to `ListViewSchema`
705+
- [x] Migrate `HttpMethodSchema` / `HttpRequestSchema` from `view.zod.ts` to `shared/http.zod.ts` (re-exported for backward compat)
706+
- [x] Rename `ThemeMode``ThemeModeSchema`, `DensityMode``DensityModeSchema`, `WcagContrastLevel``WcagContrastLevelSchema` (deprecated aliases kept)
699707

700708
**ObjectUI Component Implementations:**
701709
- [ ] Implement `DashboardFilterBar` component for global filters ([objectui#588](https://github.com/objectstack-ai/objectui/issues/588))

packages/spec/src/shared/http.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, it, expect } from 'vitest';
22
import {
33
HttpMethod,
4+
HttpMethodSchema,
5+
HttpRequestSchema,
46
CorsConfigSchema,
57
RateLimitConfigSchema,
68
StaticMountSchema,
@@ -134,3 +136,44 @@ describe('StaticMountSchema', () => {
134136
expect(() => StaticMountSchema.parse({ path: '/static' })).toThrow();
135137
});
136138
});
139+
140+
// ============================================================================
141+
// Issue #8: HttpMethodSchema and HttpRequestSchema migrated to shared
142+
// ============================================================================
143+
describe('HttpMethodSchema (migrated from view.zod)', () => {
144+
it('should accept common HTTP methods', () => {
145+
const valid = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
146+
valid.forEach(m => {
147+
expect(HttpMethodSchema.parse(m)).toBe(m);
148+
});
149+
});
150+
151+
it('should reject HEAD and OPTIONS (subset for UI data sources)', () => {
152+
expect(() => HttpMethodSchema.parse('HEAD')).toThrow();
153+
expect(() => HttpMethodSchema.parse('OPTIONS')).toThrow();
154+
});
155+
});
156+
157+
describe('HttpRequestSchema (migrated from view.zod)', () => {
158+
it('should accept minimal request with url only', () => {
159+
const result = HttpRequestSchema.parse({ url: 'https://api.example.com/data' });
160+
expect(result.url).toBe('https://api.example.com/data');
161+
expect(result.method).toBe('GET');
162+
});
163+
164+
it('should accept full request with all fields', () => {
165+
const result = HttpRequestSchema.parse({
166+
url: 'https://api.example.com/data',
167+
method: 'POST',
168+
headers: { 'Authorization': 'Bearer token' },
169+
params: { page: 1 },
170+
body: { name: 'test' },
171+
});
172+
expect(result.method).toBe('POST');
173+
expect(result.headers?.['Authorization']).toBe('Bearer token');
174+
});
175+
176+
it('should reject request without url', () => {
177+
expect(() => HttpRequestSchema.parse({})).toThrow();
178+
});
179+
});

packages/spec/src/ui/animation.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,31 @@ describe('I18n and ARIA integration', () => {
235235
expect(result.role).toBeUndefined();
236236
});
237237
});
238+
239+
// ============================================================================
240+
// Issue #6: TransitionConfigSchema themeToken support
241+
// ============================================================================
242+
describe('TransitionConfigSchema - themeToken', () => {
243+
it('should accept transition with themeToken reference', () => {
244+
const result = TransitionConfigSchema.parse({
245+
themeToken: 'animation.duration.fast',
246+
});
247+
expect(result.themeToken).toBe('animation.duration.fast');
248+
});
249+
250+
it('should accept transition combining themeToken with explicit values', () => {
251+
const result = TransitionConfigSchema.parse({
252+
preset: 'fade',
253+
duration: 200,
254+
easing: 'ease_in_out',
255+
themeToken: 'animation.timing.ease_in_out',
256+
});
257+
expect(result.themeToken).toBe('animation.timing.ease_in_out');
258+
expect(result.duration).toBe(200);
259+
});
260+
261+
it('should leave themeToken undefined when not provided', () => {
262+
const result = TransitionConfigSchema.parse({ duration: 300 });
263+
expect(result.themeToken).toBeUndefined();
264+
});
265+
});

packages/spec/src/ui/dashboard.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1749,3 +1749,79 @@ describe('GlobalFilterSchema - Negative Validation', () => {
17491749
})).toThrow();
17501750
});
17511751
});
1752+
1753+
// ============================================================================
1754+
// Issue #2: DashboardWidget required `id` field
1755+
// ============================================================================
1756+
describe('DashboardWidgetSchema - required id field', () => {
1757+
it('should reject widget without id', () => {
1758+
expect(() => DashboardWidgetSchema.parse({
1759+
type: 'metric',
1760+
layout: { x: 0, y: 0, w: 3, h: 2 },
1761+
})).toThrow();
1762+
});
1763+
1764+
it('should enforce snake_case for widget id', () => {
1765+
expect(() => DashboardWidgetSchema.parse({
1766+
id: 'revenue_widget',
1767+
type: 'metric',
1768+
layout: { x: 0, y: 0, w: 3, h: 2 },
1769+
})).not.toThrow();
1770+
1771+
expect(() => DashboardWidgetSchema.parse({
1772+
id: 'revenueWidget',
1773+
type: 'metric',
1774+
layout: { x: 0, y: 0, w: 3, h: 2 },
1775+
})).toThrow();
1776+
});
1777+
1778+
it('should allow targetWidgets to reference widget ids', () => {
1779+
const dashboard = DashboardSchema.parse({
1780+
name: 'filter_ref_dash',
1781+
label: 'Filter Reference Dashboard',
1782+
widgets: [
1783+
{ id: 'revenue_chart', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } },
1784+
{ id: 'pipeline_table', type: 'table', layout: { x: 6, y: 0, w: 6, h: 4 } },
1785+
],
1786+
globalFilters: [{
1787+
field: 'region',
1788+
scope: 'widget',
1789+
targetWidgets: ['revenue_chart'],
1790+
}],
1791+
});
1792+
expect(dashboard.widgets[0].id).toBe('revenue_chart');
1793+
expect(dashboard.globalFilters![0].targetWidgets).toEqual(['revenue_chart']);
1794+
});
1795+
});
1796+
1797+
// ============================================================================
1798+
// Issue #3: WidgetActionTypeSchema unified with ActionSchema.type
1799+
// ============================================================================
1800+
describe('WidgetActionTypeSchema - unified action types', () => {
1801+
it('should accept all five action types matching ActionSchema.type', () => {
1802+
const types = ['script', 'url', 'modal', 'flow', 'api'] as const;
1803+
types.forEach(type => {
1804+
expect(() => WidgetActionTypeSchema.parse(type)).not.toThrow();
1805+
});
1806+
});
1807+
1808+
it('should accept widget with script action type', () => {
1809+
expect(() => DashboardWidgetSchema.parse({
1810+
id: 'script_widget',
1811+
type: 'metric',
1812+
actionType: 'script',
1813+
actionUrl: 'refresh_data',
1814+
layout: { x: 0, y: 0, w: 3, h: 2 },
1815+
})).not.toThrow();
1816+
});
1817+
1818+
it('should accept widget with api action type', () => {
1819+
expect(() => DashboardWidgetSchema.parse({
1820+
id: 'api_widget',
1821+
type: 'metric',
1822+
actionType: 'api',
1823+
actionUrl: '/api/refresh',
1824+
layout: { x: 0, y: 0, w: 3, h: 2 },
1825+
})).not.toThrow();
1826+
});
1827+
});

packages/spec/src/ui/page.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,3 +1221,67 @@ describe('RecordReviewConfigSchema - Negative Validation', () => {
12211221
})).toThrow();
12221222
});
12231223
});
1224+
1225+
// ============================================================================
1226+
// Issue #5: PageSchema conditional validation (superRefine)
1227+
// ============================================================================
1228+
describe('PageSchema - conditional validation', () => {
1229+
it('should reject record_review page without recordReview config', () => {
1230+
expect(() => PageSchema.parse({
1231+
name: 'review_page',
1232+
label: 'Review',
1233+
type: 'record_review',
1234+
regions: [],
1235+
})).toThrow();
1236+
});
1237+
1238+
it('should accept record_review page with recordReview config', () => {
1239+
expect(() => PageSchema.parse({
1240+
name: 'review_page',
1241+
label: 'Review',
1242+
type: 'record_review',
1243+
regions: [],
1244+
recordReview: {
1245+
object: 'order',
1246+
actions: [{ label: 'Approve', type: 'approve' }],
1247+
},
1248+
})).not.toThrow();
1249+
});
1250+
1251+
it('should reject blank page without blankLayout config', () => {
1252+
expect(() => PageSchema.parse({
1253+
name: 'blank_page',
1254+
label: 'Blank',
1255+
type: 'blank',
1256+
regions: [],
1257+
})).toThrow();
1258+
});
1259+
1260+
it('should accept blank page with blankLayout config', () => {
1261+
expect(() => PageSchema.parse({
1262+
name: 'blank_page',
1263+
label: 'Blank',
1264+
type: 'blank',
1265+
regions: [],
1266+
blankLayout: { items: [] },
1267+
})).not.toThrow();
1268+
});
1269+
1270+
it('should not require recordReview for non-record_review types', () => {
1271+
expect(() => PageSchema.parse({
1272+
name: 'grid_page',
1273+
label: 'Grid',
1274+
type: 'grid',
1275+
regions: [],
1276+
})).not.toThrow();
1277+
});
1278+
1279+
it('should not require blankLayout for non-blank types', () => {
1280+
expect(() => PageSchema.parse({
1281+
name: 'dashboard_page',
1282+
label: 'Dashboard',
1283+
type: 'dashboard',
1284+
regions: [],
1285+
})).not.toThrow();
1286+
});
1287+
});

packages/spec/src/ui/theme.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest';
22
import {
33
ThemeSchema,
44
ThemeMode,
5+
ThemeModeSchema,
6+
DensityModeSchema,
7+
WcagContrastLevelSchema,
58
ColorPaletteSchema,
69
TypographySchema,
710
SpacingSchema,
@@ -541,3 +544,66 @@ describe('Theme Density, WCAG, and RTL', () => {
541544
expect(theme.rtl).toBe(false);
542545
});
543546
});
547+
548+
// ============================================================================
549+
// Issue #6: Easing naming unified to snake_case in theme animation tokens
550+
// ============================================================================
551+
describe('AnimationSchema - snake_case timing keys', () => {
552+
it('should accept snake_case easing keys', () => {
553+
const theme = ThemeSchema.parse({
554+
name: 'snake_case_timing',
555+
label: 'Snake Case Timing',
556+
colors: { primary: '#000' },
557+
animation: {
558+
timing: {
559+
linear: 'linear',
560+
ease: 'ease',
561+
ease_in: 'ease-in',
562+
ease_out: 'ease-out',
563+
ease_in_out: 'ease-in-out',
564+
},
565+
},
566+
});
567+
expect(theme.animation?.timing?.ease_in).toBe('ease-in');
568+
expect(theme.animation?.timing?.ease_out).toBe('ease-out');
569+
expect(theme.animation?.timing?.ease_in_out).toBe('ease-in-out');
570+
});
571+
});
572+
573+
// ============================================================================
574+
// Issue #9: ThemeModeSchema / DensityModeSchema / WcagContrastLevelSchema
575+
// ============================================================================
576+
describe('ThemeModeSchema (canonical name)', () => {
577+
it('should accept valid theme modes', () => {
578+
expect(() => ThemeModeSchema.parse('light')).not.toThrow();
579+
expect(() => ThemeModeSchema.parse('dark')).not.toThrow();
580+
expect(() => ThemeModeSchema.parse('auto')).not.toThrow();
581+
});
582+
583+
it('should be the same as deprecated ThemeMode alias', () => {
584+
expect(ThemeModeSchema).toBe(ThemeMode);
585+
});
586+
});
587+
588+
describe('DensityModeSchema (canonical name)', () => {
589+
it('should accept valid density modes', () => {
590+
expect(() => DensityModeSchema.parse('compact')).not.toThrow();
591+
expect(() => DensityModeSchema.parse('regular')).not.toThrow();
592+
expect(() => DensityModeSchema.parse('spacious')).not.toThrow();
593+
});
594+
595+
it('should be the same as deprecated DensityMode alias', () => {
596+
expect(DensityModeSchema).toBe(DensityMode);
597+
});
598+
});
599+
600+
describe('WcagContrastLevelSchema (canonical name)', () => {
601+
it('should accept AA and AAA', () => {
602+
expect(() => WcagContrastLevelSchema.parse('AA')).not.toThrow();
603+
expect(() => WcagContrastLevelSchema.parse('AAA')).not.toThrow();
604+
});
605+
606+
it('should be the same as deprecated WcagContrastLevel alias', () => {
607+
expect(WcagContrastLevelSchema).toBe(WcagContrastLevel);
608+
});
609+
});

packages/spec/src/ui/view.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,3 +2369,62 @@ describe('ListViewSchema filter field', () => {
23692369
})).toThrow();
23702370
});
23712371
});
2372+
2373+
// ============================================================================
2374+
// Issue #7: ListView responsive and performance config
2375+
// ============================================================================
2376+
describe('ListViewSchema - responsive and performance', () => {
2377+
it('should accept list view with responsive config', () => {
2378+
const view = ListViewSchema.parse({
2379+
type: 'grid',
2380+
columns: ['name', 'status'],
2381+
responsive: {
2382+
hiddenOn: ['xs'],
2383+
columns: { xs: 12, md: 6, lg: 4 },
2384+
},
2385+
});
2386+
expect(view.responsive?.hiddenOn).toEqual(['xs']);
2387+
expect(view.responsive?.columns?.md).toBe(6);
2388+
});
2389+
2390+
it('should accept list view with performance config', () => {
2391+
const view = ListViewSchema.parse({
2392+
type: 'grid',
2393+
columns: ['name'],
2394+
performance: {
2395+
lazyLoad: true,
2396+
virtualScroll: { enabled: true, itemHeight: 40, overscan: 5 },
2397+
cacheStrategy: 'stale-while-revalidate',
2398+
prefetch: true,
2399+
},
2400+
});
2401+
expect(view.performance?.lazyLoad).toBe(true);
2402+
expect(view.performance?.virtualScroll?.enabled).toBe(true);
2403+
expect(view.performance?.cacheStrategy).toBe('stale-while-revalidate');
2404+
});
2405+
2406+
it('should accept list view without responsive/performance (optional)', () => {
2407+
const view = ListViewSchema.parse({
2408+
type: 'grid',
2409+
columns: ['name'],
2410+
});
2411+
expect(view.responsive).toBeUndefined();
2412+
expect(view.performance).toBeUndefined();
2413+
});
2414+
});
2415+
2416+
// ============================================================================
2417+
// Issue #8: HttpMethodSchema/HttpRequestSchema re-exported from view.zod
2418+
// ============================================================================
2419+
describe('HttpMethodSchema/HttpRequestSchema backward compat', () => {
2420+
it('should still be importable from view.zod', () => {
2421+
expect(HttpMethodSchema).toBeDefined();
2422+
expect(HttpRequestSchema).toBeDefined();
2423+
});
2424+
2425+
it('should still parse correctly when imported from view.zod', () => {
2426+
expect(HttpMethodSchema.parse('GET')).toBe('GET');
2427+
const result = HttpRequestSchema.parse({ url: '/api/test' });
2428+
expect(result.method).toBe('GET');
2429+
});
2430+
});

0 commit comments

Comments
 (0)