@svadmin/core 0.0.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
@@ -0,0 +1,85 @@
1
+ // HttpError + CrudOperator + LogicalFilter tests
2
+ import { describe, test, expect } from 'bun:test';
3
+ import { HttpError } from './types';
4
+ import type { CrudOperator, Filter, LogicalFilter } from './types';
5
+
6
+ describe('HttpError', () => {
7
+ test('creates with message and statusCode', () => {
8
+ const err = new HttpError('Not Found', 404);
9
+ expect(err.message).toBe('Not Found');
10
+ expect(err.statusCode).toBe(404);
11
+ expect(err.name).toBe('HttpError');
12
+ expect(err.errors).toBeUndefined();
13
+ });
14
+
15
+ test('creates with validation errors', () => {
16
+ const err = new HttpError('Validation Failed', 422, {
17
+ email: ['Email is required', 'Email must be valid'],
18
+ name: 'Name is required',
19
+ });
20
+ expect(err.statusCode).toBe(422);
21
+ expect(err.errors!.email).toEqual(['Email is required', 'Email must be valid']);
22
+ expect(err.errors!.name).toBe('Name is required');
23
+ });
24
+
25
+ test('is instanceof Error', () => {
26
+ const err = new HttpError('Server Error', 500);
27
+ expect(err instanceof Error).toBe(true);
28
+ expect(err instanceof HttpError).toBe(true);
29
+ });
30
+ });
31
+
32
+ describe('CrudOperator types', () => {
33
+ test('all operators are valid', () => {
34
+ const operators: CrudOperator[] = [
35
+ 'eq', 'ne', 'lt', 'gt', 'lte', 'gte',
36
+ 'contains', 'ncontains',
37
+ 'startswith', 'endswith',
38
+ 'in', 'nin',
39
+ 'null', 'nnull',
40
+ 'between', 'nbetween',
41
+ ];
42
+ expect(operators).toHaveLength(16);
43
+ });
44
+
45
+ test('Filter uses CrudOperator', () => {
46
+ const filter: Filter = { field: 'name', operator: 'startswith', value: 'A' };
47
+ expect(filter.operator).toBe('startswith');
48
+ });
49
+
50
+ test('negation operators', () => {
51
+ const filters: Filter[] = [
52
+ { field: 'status', operator: 'ncontains', value: 'draft' },
53
+ { field: 'deleted_at', operator: 'nnull', value: null },
54
+ { field: 'category', operator: 'nin', value: [1, 2] },
55
+ { field: 'price', operator: 'nbetween', value: [100, 200] },
56
+ ];
57
+ expect(filters).toHaveLength(4);
58
+ });
59
+ });
60
+
61
+ describe('LogicalFilter', () => {
62
+ test('or combination', () => {
63
+ const logical: LogicalFilter = {
64
+ operator: 'or',
65
+ value: [
66
+ { field: 'status', operator: 'eq', value: 'published' },
67
+ { field: 'status', operator: 'eq', value: 'draft' },
68
+ ],
69
+ };
70
+ expect(logical.operator).toBe('or');
71
+ expect(logical.value).toHaveLength(2);
72
+ });
73
+
74
+ test('and combination', () => {
75
+ const logical: LogicalFilter = {
76
+ operator: 'and',
77
+ value: [
78
+ { field: 'price', operator: 'gte', value: 10 },
79
+ { field: 'price', operator: 'lte', value: 100 },
80
+ ],
81
+ };
82
+ expect(logical.operator).toBe('and');
83
+ expect(logical.value).toHaveLength(2);
84
+ });
85
+ });
@@ -19,6 +19,9 @@ const locales: Record<string, Locale> = {
19
19
  'common.page': '{current} / {total}',
20
20
  'common.export': '导出',
21
21
  'common.import': '导入',
22
+ 'common.clone': '克隆',
23
+ 'common.autoSaving': '自动保存中...',
24
+ 'common.autoSaved': '已自动保存',
22
25
  'common.selectAll': '全选',
23
26
  'common.batchDelete': '批量删除 ({count})',
24
27
  'common.unsavedChanges': '有未保存的修改,确定离开吗?',
@@ -37,8 +40,6 @@ const locales: Record<string, Locale> = {
37
40
  'common.toggleTheme': '切换主题',
38
41
  'common.darkMode': '暗色模式',
39
42
  'common.lightMode': '亮色模式',
40
- 'common.noData': '暂无数据',
41
- 'common.search': '搜索...',
42
43
  'common.detail': '详情',
43
44
  'common.loadFailed': '加载失败: {message}',
44
45
  'common.pageNotFound': '页面未找到',
@@ -46,6 +47,9 @@ const locales: Record<string, Locale> = {
46
47
  'common.retry': '重试',
47
48
  'common.yes': '是',
48
49
  'common.no': '否',
50
+ 'common.welcome': '欢迎使用 {title}',
51
+ 'common.dashboardHint': '从侧边栏选择一个资源开始。',
52
+ 'common.redirecting': '正在跳转到登录页...',
49
53
  // Field
50
54
  'field.enterValue': '输入{label}',
51
55
  'field.selectPlaceholder': '请选择',
@@ -105,6 +109,9 @@ const locales: Record<string, Locale> = {
105
109
  'common.page': '{current} / {total}',
106
110
  'common.export': 'Export',
107
111
  'common.import': 'Import',
112
+ 'common.clone': 'Clone',
113
+ 'common.autoSaving': 'Auto-saving...',
114
+ 'common.autoSaved': 'Saved',
108
115
  'common.selectAll': 'Select All',
109
116
  'common.batchDelete': 'Batch Delete ({count})',
110
117
  'common.unsavedChanges': 'You have unsaved changes. Leave anyway?',
@@ -123,8 +130,6 @@ const locales: Record<string, Locale> = {
123
130
  'common.toggleTheme': 'Toggle theme',
124
131
  'common.darkMode': 'Dark mode',
125
132
  'common.lightMode': 'Light mode',
126
- 'common.noData': 'No data',
127
- 'common.search': 'Search...',
128
133
  'common.detail': 'Detail',
129
134
  'common.loadFailed': 'Load failed: {message}',
130
135
  'common.pageNotFound': 'Page not found',
@@ -132,6 +137,9 @@ const locales: Record<string, Locale> = {
132
137
  'common.retry': 'Retry',
133
138
  'common.yes': 'Yes',
134
139
  'common.no': 'No',
140
+ 'common.welcome': 'Welcome to {title}',
141
+ 'common.dashboardHint': 'Select a resource from the sidebar to get started.',
142
+ 'common.redirecting': 'Redirecting to login...',
135
143
  // Field
136
144
  'field.enterValue': 'Enter {label}',
137
145
  'field.selectPlaceholder': 'Select...',
@@ -219,3 +227,15 @@ export function t(key: string, params?: Record<string, string | number>): string
219
227
  export function addTranslations(locale: string, translations: Record<string, string>): void {
220
228
  locales[locale] = { ...locales[locale], ...translations };
221
229
  }
230
+
231
+ /**
232
+ * useTranslation — standard i18n hook
233
+ * Returns { translate, getLocale, changeLocale }
234
+ */
235
+ export function useTranslation() {
236
+ return {
237
+ translate: t,
238
+ getLocale,
239
+ changeLocale: setLocale,
240
+ };
241
+ }
@@ -0,0 +1,90 @@
1
+ // Unit tests for URL parsing (useParsed logic)
2
+ // We test the pure parsing logic directly since the hook itself uses Svelte runes.
3
+ import { describe, test, expect } from 'bun:test';
4
+
5
+ /**
6
+ * Parse a hash-based URL into structured route info.
7
+ * This mirrors the logic in useParsed.ts.
8
+ */
9
+ function parseHash(hash: string) {
10
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
11
+ const [pathPart, queryPart] = raw.split('?');
12
+ const segments = pathPart.split('/').filter(Boolean);
13
+
14
+ const params: Record<string, string> = {};
15
+ if (queryPart) {
16
+ for (const pair of queryPart.split('&')) {
17
+ const [k, v] = pair.split('=');
18
+ if (k) params[decodeURIComponent(k)] = decodeURIComponent(v ?? '');
19
+ }
20
+ }
21
+
22
+ return {
23
+ resource: segments[0],
24
+ action: segments[1],
25
+ id: segments[2],
26
+ params,
27
+ };
28
+ }
29
+
30
+ describe('parseHash (useParsed logic)', () => {
31
+ test('empty hash', () => {
32
+ const result = parseHash('');
33
+ expect(result.resource).toBeUndefined();
34
+ expect(result.action).toBeUndefined();
35
+ expect(result.id).toBeUndefined();
36
+ expect(result.params).toEqual({});
37
+ });
38
+
39
+ test('root hash', () => {
40
+ const result = parseHash('#/');
41
+ expect(result.resource).toBeUndefined();
42
+ });
43
+
44
+ test('resource only', () => {
45
+ const result = parseHash('#/posts');
46
+ expect(result.resource).toBe('posts');
47
+ expect(result.action).toBeUndefined();
48
+ expect(result.id).toBeUndefined();
49
+ });
50
+
51
+ test('resource and action', () => {
52
+ const result = parseHash('#/posts/create');
53
+ expect(result.resource).toBe('posts');
54
+ expect(result.action).toBe('create');
55
+ });
56
+
57
+ test('resource, action, and id', () => {
58
+ const result = parseHash('#/posts/edit/42');
59
+ expect(result.resource).toBe('posts');
60
+ expect(result.action).toBe('edit');
61
+ expect(result.id).toBe('42');
62
+ });
63
+
64
+ test('with query params', () => {
65
+ const result = parseHash('#/posts?page=2&sort=name');
66
+ expect(result.resource).toBe('posts');
67
+ expect(result.params.page).toBe('2');
68
+ expect(result.params.sort).toBe('name');
69
+ });
70
+
71
+ test('resource with action and query', () => {
72
+ const result = parseHash('#/posts/edit/5?tab=details');
73
+ expect(result.resource).toBe('posts');
74
+ expect(result.action).toBe('edit');
75
+ expect(result.id).toBe('5');
76
+ expect(result.params.tab).toBe('details');
77
+ });
78
+
79
+ test('encoded query params', () => {
80
+ const result = parseHash('#/search?q=hello%20world&filter=tag%3Djs');
81
+ expect(result.params.q).toBe('hello world');
82
+ expect(result.params.filter).toBe('tag=js');
83
+ });
84
+
85
+ test('login route', () => {
86
+ const result = parseHash('#/login');
87
+ expect(result.resource).toBe('login');
88
+ expect(result.action).toBeUndefined();
89
+ });
90
+ });
package/src/index.ts CHANGED
@@ -1,35 +1,52 @@
1
1
  // Core barrel exports
2
2
 
3
3
  export {
4
- setDataProvider, getDataProvider,
4
+ setDataProvider, getDataProvider, getDataProviderForResource, getDataProviderNames,
5
5
  setAuthProvider, getAuthProvider,
6
6
  setResources, getResources, getResource,
7
+ setRouterProvider, getRouterProvider,
7
8
  } from './context';
9
+ export type { DataProviderInput } from './context';
8
10
  export {
9
11
  useList, useInfiniteList,
10
12
  useOne, useShow,
11
13
  useSelect, useMany,
12
14
  useCustom, useApiUrl,
15
+ useCustomMutation, useInvalidate,
13
16
  useCreate, useCreateMany,
14
17
  useUpdate, useUpdateMany,
15
18
  useDelete, useDeleteMany,
16
19
  useForm, useTable,
17
- useNavigation,
20
+ useNavigation, useGo, useBack,
21
+ useGetToPath, useLink,
18
22
  useResource,
23
+ useLog, useLogList,
24
+ useModalForm, useDrawerForm, useModal,
25
+ useOvertime, useRelation,
26
+ useNotification, useDataProvider,
27
+ useMenu, useBreadcrumb, useThemedLayoutContext,
19
28
  } from './hooks.svelte';
20
- export { matchRoute, navigate, currentPath } from './router';
29
+ export { matchRoute, navigate, currentPath, setActiveRouterProvider } from './router';
21
30
  export { readURLState, writeURLState } from './url-sync';
22
31
  export { setAccessControl, canAccess, canAccessAsync } from './permissions';
23
- export { useLive } from './live';
32
+ export { useLive, useSubscription, usePublish } from './live';
24
33
  export { toast } from './toast.svelte';
25
34
  export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
26
- export { t, setLocale, getLocale, getAvailableLocales, addTranslations } from './i18n.svelte';
27
- export { audit, setAuditHandler } from './audit';
35
+ export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation } from './i18n.svelte';
36
+ export { audit, setAuditHandler, setAuditLogProvider, getAuditLogProvider } from './audit';
37
+ export type { AuditLogProvider } from './audit';
28
38
  export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
29
39
  export type { ThemeMode, ColorTheme } from './theme.svelte';
40
+ export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
41
+ export { setAdminOptions, getAdminOptions, getTextTransformers } from './options';
42
+ export type { AdminOptions, TextTransformers, OvertimeConfig } from './options';
30
43
 
44
+ export * from './query-keys';
45
+ export { HttpError } from './types';
31
46
  export type {
32
47
  DataProvider, AuthProvider, NotificationProvider, MutationMode,
48
+ ValidationErrors, CrudOperator, LogicalFilter,
49
+ CustomParams, CustomResult,
33
50
  GetListParams, GetListResult,
34
51
  GetOneParams, GetOneResult,
35
52
  GetManyParams, GetManyResult,
@@ -39,13 +56,39 @@ export type {
39
56
  UpdateManyParams, UpdateManyResult,
40
57
  DeleteParams, DeleteResult,
41
58
  DeleteManyParams, DeleteManyResult,
42
- CustomParams, CustomResult,
43
59
  Pagination, Sort, Filter, Identity,
44
60
  ResourceDefinition, FieldDefinition,
45
61
  AuthActionResult, CheckResult,
62
+ ResourceTypeMap, KnownResources, InferData,
63
+ BaseRecord,
46
64
  } from './types';
47
- export type { LiveProvider, LiveEvent } from './live';
65
+ export type { InvalidateScope } from './options';
66
+ export type { LiveProvider, LiveEvent, LiveMode } from './live';
48
67
  export type { Action, AccessControlResult, AccessControlFn } from './permissions';
49
68
  export type { AuditEntry, AuditHandler } from './audit';
50
69
  export { useCan } from './useCan';
51
70
  export { useExport, useImport } from './data-transfer';
71
+ export type { UseExportOptions, UseImportOptions } from './data-transfer';
72
+ export {
73
+ useLogin, useLogout,
74
+ useRegister, useForgotPassword, useUpdatePassword,
75
+ useGetIdentity, useIsAuthenticated,
76
+ useOnError, usePermissions,
77
+ } from './auth-hooks.svelte';
78
+ export { useParsed } from './useParsed';
79
+ export * from './useStepsForm.svelte';
80
+ export { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
81
+ export type { RouterProvider } from './router-provider';
82
+ export { inferFieldType, inferResource } from './inferencer';
83
+ export type { InferResult } from './inferencer';
84
+ export { createWebSocketLiveProvider } from './live-websocket';
85
+ export type { WebSocketLiveProviderOptions } from './live-websocket';
86
+ export { createSSELiveProvider } from './live-sse';
87
+ export type { SSELiveProviderOptions } from './live-sse';
88
+ export { inferFromOpenAPI } from './inferencer-openapi';
89
+ export type { InferFromOpenAPIOptions } from './inferencer-openapi';
90
+ export {
91
+ getDefaultFilter, getDefaultSortOrder,
92
+ unionFilters, unionSorters,
93
+ file2Base64, generateDefaultDocumentTitle,
94
+ } from './helpers';
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tests for OpenAPI → ResourceDefinition inference
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { inferFromOpenAPI } from './inferencer-openapi';
6
+
7
+ const sampleOpenAPI = {
8
+ openapi: '3.0.0',
9
+ info: { title: 'Blog API', version: '1.0.0' },
10
+ paths: {
11
+ '/api/posts': {
12
+ get: { operationId: 'getPosts' },
13
+ post: { operationId: 'createPost' },
14
+ },
15
+ '/api/posts/{id}': {
16
+ get: { operationId: 'getPost' },
17
+ put: { operationId: 'updatePost' },
18
+ delete: { operationId: 'deletePost' },
19
+ },
20
+ '/api/users': {
21
+ get: { operationId: 'getUsers' },
22
+ },
23
+ },
24
+ components: {
25
+ schemas: {
26
+ Post: {
27
+ type: 'object',
28
+ required: ['title', 'content'],
29
+ properties: {
30
+ id: { type: 'integer' },
31
+ title: { type: 'string' },
32
+ content: { type: 'string', description: 'Post body content' },
33
+ status: { type: 'string', enum: ['draft', 'published', 'archived'] },
34
+ author_id: { type: 'integer' },
35
+ email: { type: 'string', format: 'email' },
36
+ created_at: { type: 'string', format: 'date-time' },
37
+ tags: { type: 'array', items: { type: 'string' } },
38
+ is_featured: { type: 'boolean' },
39
+ views: { type: 'integer' },
40
+ website: { type: 'string', format: 'uri' },
41
+ meta: { type: 'object' },
42
+ },
43
+ },
44
+ User: {
45
+ type: 'object',
46
+ properties: {
47
+ id: { type: 'integer' },
48
+ name: { type: 'string' },
49
+ avatar: { type: 'string', format: 'uri' },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ };
55
+
56
+ describe('inferFromOpenAPI', () => {
57
+ test('parses both schemas', () => {
58
+ const resources = inferFromOpenAPI(sampleOpenAPI);
59
+ expect(resources.length).toBe(2);
60
+ });
61
+
62
+ test('resource name is pluralized and lowercase', () => {
63
+ const resources = inferFromOpenAPI(sampleOpenAPI);
64
+ const post = resources.find(r => r.name === 'posts');
65
+ expect(post).toBeDefined();
66
+ expect(post!.label).toBe('Post');
67
+ });
68
+
69
+ test('detects required fields', () => {
70
+ const resources = inferFromOpenAPI(sampleOpenAPI);
71
+ const post = resources.find(r => r.name === 'posts')!;
72
+ const titleField = post.fields.find(f => f.key === 'title')!;
73
+ const viewsField = post.fields.find(f => f.key === 'views')!;
74
+ expect(titleField.required).toBe(true);
75
+ expect(viewsField.required).toBeFalsy();
76
+ });
77
+
78
+ test('maps integer to number type', () => {
79
+ const resources = inferFromOpenAPI(sampleOpenAPI);
80
+ const post = resources.find(r => r.name === 'posts')!;
81
+ const viewsField = post.fields.find(f => f.key === 'views')!;
82
+ expect(viewsField.type).toBe('number');
83
+ });
84
+
85
+ test('maps boolean type', () => {
86
+ const resources = inferFromOpenAPI(sampleOpenAPI);
87
+ const post = resources.find(r => r.name === 'posts')!;
88
+ const field = post.fields.find(f => f.key === 'is_featured')!;
89
+ expect(field.type).toBe('boolean');
90
+ });
91
+
92
+ test('maps date-time format to date', () => {
93
+ const resources = inferFromOpenAPI(sampleOpenAPI);
94
+ const post = resources.find(r => r.name === 'posts')!;
95
+ const field = post.fields.find(f => f.key === 'created_at')!;
96
+ expect(field.type).toBe('date');
97
+ });
98
+
99
+ test('maps email format', () => {
100
+ const resources = inferFromOpenAPI(sampleOpenAPI);
101
+ const post = resources.find(r => r.name === 'posts')!;
102
+ const field = post.fields.find(f => f.key === 'email')!;
103
+ expect(field.type).toBe('email');
104
+ });
105
+
106
+ test('maps uri format to url', () => {
107
+ const resources = inferFromOpenAPI(sampleOpenAPI);
108
+ const post = resources.find(r => r.name === 'posts')!;
109
+ const field = post.fields.find(f => f.key === 'website')!;
110
+ expect(field.type).toBe('url');
111
+ });
112
+
113
+ test('maps enum to select with options', () => {
114
+ const resources = inferFromOpenAPI(sampleOpenAPI);
115
+ const post = resources.find(r => r.name === 'posts')!;
116
+ const field = post.fields.find(f => f.key === 'status')!;
117
+ expect(field.type).toBe('select');
118
+ expect(field.options!.length).toBe(3);
119
+ expect(field.options![0].value).toBe('draft');
120
+ });
121
+
122
+ test('maps array of strings to tags', () => {
123
+ const resources = inferFromOpenAPI(sampleOpenAPI);
124
+ const post = resources.find(r => r.name === 'posts')!;
125
+ const field = post.fields.find(f => f.key === 'tags')!;
126
+ expect(field.type).toBe('tags');
127
+ });
128
+
129
+ test('maps object to json', () => {
130
+ const resources = inferFromOpenAPI(sampleOpenAPI);
131
+ const post = resources.find(r => r.name === 'posts')!;
132
+ const field = post.fields.find(f => f.key === 'meta')!;
133
+ expect(field.type).toBe('json');
134
+ });
135
+
136
+ test('detects relation from _id suffix', () => {
137
+ const resources = inferFromOpenAPI(sampleOpenAPI);
138
+ const post = resources.find(r => r.name === 'posts')!;
139
+ const field = post.fields.find(f => f.key === 'author_id')!;
140
+ expect(field.type).toBe('relation');
141
+ expect(field.resource).toBe('authors');
142
+ });
143
+
144
+ test('detects CRUD capabilities from paths', () => {
145
+ const resources = inferFromOpenAPI(sampleOpenAPI);
146
+ const post = resources.find(r => r.name === 'posts')!;
147
+ expect(post.canCreate).toBe(true);
148
+ expect(post.canEdit).toBe(true);
149
+ expect(post.canDelete).toBe(true);
150
+ expect(post.canShow).toBe(true);
151
+ });
152
+
153
+ test('read-only resource has limited capabilities', () => {
154
+ const resources = inferFromOpenAPI(sampleOpenAPI);
155
+ const user = resources.find(r => r.name === 'users')!;
156
+ expect(user.canShow).toBe(true);
157
+ expect(user.canCreate).toBe(false);
158
+ expect(user.canDelete).toBe(false);
159
+ });
160
+
161
+ test('include filter works', () => {
162
+ const resources = inferFromOpenAPI(sampleOpenAPI, { include: ['Post'] });
163
+ expect(resources.length).toBe(1);
164
+ expect(resources[0].name).toBe('posts');
165
+ });
166
+
167
+ test('exclude filter works', () => {
168
+ const resources = inferFromOpenAPI(sampleOpenAPI, { exclude: ['User'] });
169
+ expect(resources.length).toBe(1);
170
+ expect(resources[0].name).toBe('posts');
171
+ });
172
+
173
+ test('content field is detected as textarea from key name hint', () => {
174
+ const resources = inferFromOpenAPI(sampleOpenAPI);
175
+ const post = resources.find(r => r.name === 'posts')!;
176
+ const field = post.fields.find(f => f.key === 'content')!;
177
+ expect(field.type).toBe('textarea');
178
+ expect(field.showInList).toBe(false);
179
+ });
180
+
181
+ test('id field excluded from form', () => {
182
+ const resources = inferFromOpenAPI(sampleOpenAPI);
183
+ const post = resources.find(r => r.name === 'posts')!;
184
+ const idField = post.fields.find(f => f.key === 'id')!;
185
+ expect(idField.showInForm).toBe(false);
186
+ });
187
+
188
+ test('empty spec returns empty array', () => {
189
+ const resources = inferFromOpenAPI({});
190
+ expect(resources.length).toBe(0);
191
+ });
192
+ });