@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,923 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { compile } from 'svelte/compiler'
3
+ import type { EntitySchema } from '../schema'
4
+ import { addBlock, addTabBlock, createProject, parseProject, setDeployTarget, setEntityDataSource, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type StudioProject } from './project'
5
+ import { emitStudioProject, emitStudioAppBundle, studioDeployInfo } from './emit-project'
6
+
7
+ const customers: EntitySchema = {
8
+ name: 'customers', label: 'Customer', idField: 'id',
9
+ fields: [
10
+ { field: 'id', type: 'text', primaryKey: true, hidden: { form: true } },
11
+ { field: 'name', type: 'text' },
12
+ { field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
13
+ { field: 'mrr', type: 'number' },
14
+ ],
15
+ }
16
+ const orders: EntitySchema = {
17
+ name: 'orders', label: 'Order', idField: 'id',
18
+ fields: [
19
+ { field: 'id', type: 'text', primaryKey: true },
20
+ { field: 'total', type: 'number' },
21
+ { field: 'customer_id', type: 'relation', relation: { entity: 'customers', foreignKey: 'customer_id', labelField: 'name' } },
22
+ ],
23
+ }
24
+
25
+ describe('emitStudioProject (per-block screens)', () => {
26
+ const project = createProject([customers, orders])
27
+ const files = emitStudioProject(project)
28
+ const byPath = (p: string) => files.find((f) => f.path === p)
29
+
30
+ it('emits shared modules, a page per screen, and the shell', () => {
31
+ expect(files.map((f) => f.path)).toEqual(
32
+ expect.arrayContaining([
33
+ 'src/lib/schemas.ts',
34
+ 'src/lib/data.ts',
35
+ 'src/routes/customers/+page.svelte',
36
+ 'src/routes/orders/+page.svelte',
37
+ 'src/routes/+layout.svelte',
38
+ 'src/routes/+page.svelte',
39
+ ]),
40
+ )
41
+ })
42
+
43
+ it('composes a grid + form screen (grid, controller, edit modal, New button)', () => {
44
+ const page = byPath('src/routes/customers/+page.svelte')!.contents
45
+ expect(page).toContain('createServerDataSource')
46
+ expect(page).toContain('<SvGrid')
47
+ expect(page).toContain('onRowDoubleClick={(e) => (editing = e.row)}')
48
+ expect(page).toContain('<SvGridEditPanel')
49
+ expect(page).toContain('+ New Customer')
50
+ // grid columns honor the block config (visible fields, in order)
51
+ expect(page).toMatch(/const columns_grid_\d+ =/)
52
+ })
53
+
54
+ it('data.ts gives every row type its own `type` keyword (verbatimModuleSyntax-safe)', () => {
55
+ // SvelteKit enables verbatimModuleSyntax; `type A, B` (B unmarked) is an error.
56
+ const dataTs = byPath('src/lib/data.ts')!.contents
57
+ const importLine = dataTs.split('\n').find((l) => l.includes("from './schemas'"))!
58
+ expect(importLine).toMatch(/\btype Customers\b/)
59
+ expect(importLine).toMatch(/\btype Orders\b/) // would be a bare `Orders` before the fix
60
+ })
61
+
62
+ it('matches the data.ts lookup var name for a snake_case relation field', () => {
63
+ const page = byPath('src/routes/orders/+page.svelte')!.contents
64
+ const dataTs = byPath('src/lib/data.ts')!.contents
65
+ // orders.customer_id -> ordersCustomerIdLookup in both places
66
+ expect(page).toContain('ordersCustomerIdLookup')
67
+ expect(dataTs).toContain('ordersCustomerIdLookup')
68
+ })
69
+
70
+ it('emits chart / dashboard / kpi blocks bound to allRows', () => {
71
+ let p = createProject([customers])
72
+ const sid = p.screens[0]!.id
73
+ p = addBlock(p, sid, 'chart')
74
+ p = addBlock(p, sid, 'dashboard')
75
+ p = addBlock(p, sid, 'kpi')
76
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
77
+ expect(page).toContain('<SvSchemaChart')
78
+ expect(page).toContain('<SvSchemaDashboard')
79
+ expect(page).toContain('reduceValue(allRows,')
80
+ expect(page).toContain('async function loadAll()')
81
+ })
82
+
83
+ it('Gauge block: emits SvGauge bound to a reduced measure over allRows', () => {
84
+ let p = createProject([customers])
85
+ const sid = p.screens[0]!.id
86
+ p = addBlock(p, sid, 'gauge')
87
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
88
+ expect(page).toMatch(/import \{[^}]*SvGauge[^}]*\} from '@svgrid\/grid'/)
89
+ expect(page).toContain('<SvGauge value={reduceValue(allRows,')
90
+ expect(page).toContain('min={0} max={100}')
91
+ expect(page).toContain('.gaugecard')
92
+ })
93
+
94
+ it('Tree block: folds rows into SvTree nodes by a self-referential parent', () => {
95
+ // A category entity that references itself via parent_id.
96
+ const categories: EntitySchema = {
97
+ name: 'categories', label: 'Category', idField: 'id',
98
+ fields: [
99
+ { field: 'id', type: 'text', primaryKey: true },
100
+ { field: 'name', type: 'text' },
101
+ { field: 'parent_id', type: 'relation', relation: { entity: 'categories', labelField: 'name' } },
102
+ ],
103
+ }
104
+ let p = createProject([categories])
105
+ const sid = p.screens[0]!.id
106
+ p = addBlock(p, sid, 'tree')
107
+ const files = emitStudioProject(p)
108
+ const page = files.find((f) => f.path === 'src/routes/categories/+page.svelte')!.contents
109
+ expect(page).toMatch(/import \{[^}]*SvTree[^}]*\} from '@svgrid\/grid'/)
110
+ expect(page).toContain('function toTreeNodes(')
111
+ expect(page).toContain('<SvTree nodes={toTreeNodes(allRows')
112
+ // Default config picked the self-referential FK as the parent link.
113
+ expect(page).toContain('"name", "parent_id"')
114
+ // The configured tree page (SvTree markup + helper) compiles.
115
+ expect(() => compile(page, { filename: 'categories/+page.svelte', generate: 'client' })).not.toThrow()
116
+ })
117
+
118
+ it('Tabs block: nests display blocks into SvTabs panels bound to allRows', () => {
119
+ let p = createProject([customers])
120
+ const sid = p.screens[0]!.id
121
+ p = addBlock(p, sid, 'tabs')
122
+ const tb = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'tabs')!
123
+ // Nest a chart in the first tab and a KPI in the second.
124
+ let cfg = addTabBlock(tb.config as TabsConfig, 0, 'chart', customers)
125
+ cfg = addTabBlock(cfg, 1, 'kpi', customers)
126
+ p = updateBlock(p, sid, tb.id, { config: cfg })
127
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
128
+ expect(page).toMatch(/import \{[^}]*SvTabs[^}]*\} from '@svgrid\/grid'/)
129
+ expect(page).toContain('{#snippet panel(id)}')
130
+ expect(page).toContain('<SvSchemaChart') // chart child in tab 1
131
+ expect(page).toContain('reduceValue(allRows,') // kpi child in tab 2 uses allRows
132
+ expect(page).toMatch(/let activeTab_\w+ = \$state\(/) // one active-tab state var
133
+ expect(page).toContain('async function loadAll()') // nested aggs still load allRows
134
+ expect(() => compile(page, { filename: 'customers/+page.svelte', generate: 'client' })).not.toThrow()
135
+ })
136
+
137
+ it('a chart-only screen has no controller/edit modal', () => {
138
+ let p = createProject([customers])
139
+ const sid = p.screens[0]!.id
140
+ // remove the default grid + form, leave a chart
141
+ p = { ...p, screens: [{ ...p.screens[0]!, blocks: [] }] }
142
+ p = addBlock(p, sid, 'chart')
143
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
144
+ expect(page).not.toContain('createServerDataSource')
145
+ expect(page).not.toContain('SvGridEditPanel')
146
+ expect(page).toContain('<SvSchemaChart')
147
+ })
148
+
149
+ it('honors a custom screen route + title', () => {
150
+ const renamed = updateScreen(project, project.screens[0]!.id, { route: 'people', title: 'People' })
151
+ const page = emitStudioProject(renamed).find((f) => f.path === 'src/routes/people/+page.svelte')!
152
+ expect(page.contents).toContain('<h1 class="st__title">People</h1>')
153
+ })
154
+
155
+ it('every emitted .svelte compiles', () => {
156
+ // Cover a screen with every block kind, with master-detail fully configured.
157
+ let p = createProject([customers, orders])
158
+ const sid = p.screens[0]!.id
159
+ for (const k of ['chart', 'dashboard', 'kpi', 'gauge', 'tree', 'tabs', 'master-detail', 'lookup', 'pivot', 'filter', 'record'] as const) p = addBlock(p, sid, k)
160
+ const mdId = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'master-detail')!.id
161
+ p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
162
+ for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
163
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
164
+ }
165
+ })
166
+
167
+ it('emits a real SvGridMasterDetail when the block has a child entity + foreign key', () => {
168
+ // A customers screen with a master-detail into orders, linked by orders.customer_id.
169
+ let p = createProject([customers, orders])
170
+ const sid = p.screens.find((s) => s.entity === 'customers')!.id
171
+ p = addBlock(p, sid, 'master-detail')
172
+ const mdId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
173
+ p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
174
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
175
+
176
+ expect(page).toContain('<SvGridMasterDetail')
177
+ expect(page).toContain('SvGridMasterDetail } from') // imported (part of the @svgrid/enterprise import)
178
+ expect(page).toContain('detailSchema={ordersSchema}')
179
+ expect(page).toContain('load_md_orders_rows()') // child rows loaded in full
180
+ expect(page).toContain("['customer_id']") // getChildren keys on the FK
181
+ expect(page).toContain('async function loadAll()') // parent rows for the master grid
182
+ // child schema + source come from the shared modules
183
+ expect(page).toMatch(/import \{[^}]*\bordersSchema\b/)
184
+ expect(page).toMatch(/import \{[^}]*\bordersSource\b/)
185
+ })
186
+
187
+ it('reloads allRows in save() so the master grid is not stale after an edit (no chart/kpi needed)', () => {
188
+ // A default customers screen (grid + form) plus a master-detail into orders.
189
+ let p = createProject([customers, orders])
190
+ const sid = p.screens.find((s) => s.entity === 'customers')!.id
191
+ p = addBlock(p, sid, 'master-detail')
192
+ const mdId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
193
+ p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
194
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
195
+ expect(page).toMatch(/editing = undefined\s*\n\s*await loadAll\(\)/)
196
+ })
197
+
198
+ it('a self-referential master-detail emits no duplicate imports and compiles', () => {
199
+ const employees: EntitySchema = {
200
+ name: 'employees', label: 'Employee', idField: 'id',
201
+ fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'managerId', type: 'text' }],
202
+ }
203
+ let p = createProject([employees])
204
+ const sid = p.screens[0]!.id
205
+ p = addBlock(p, sid, 'master-detail')
206
+ const mdId = p.screens[0]!.blocks.at(-1)!.id
207
+ p = updateBlock(p, sid, mdId, { config: { childEntity: 'employees', foreignKey: 'managerId' } as Partial<MasterDetailConfig> })
208
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/employees/+page.svelte')!.contents
209
+ const importLine = page.split('\n').find((l) => l.includes("from '$lib/schemas'"))!
210
+ expect(importLine.match(/employeesSchema/g)!).toHaveLength(1) // not duplicated
211
+ expect(importLine.match(/\bEmployees\b/g)!).toHaveLength(1) // pascal(employees) = Employees
212
+ expect(() => compile(page, { filename: 'employees.svelte', generate: 'client' })).not.toThrow()
213
+ })
214
+
215
+ it('leaves a placeholder (and still compiles) when master-detail is unconfigured', () => {
216
+ let p = createProject([customers, orders])
217
+ const sid = p.screens.find((s) => s.entity === 'customers')!.id
218
+ p = addBlock(p, sid, 'master-detail')
219
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
220
+ expect(page).toContain('<!-- master-detail: set a child entity')
221
+ expect(page).not.toContain('<SvGridMasterDetail')
222
+ expect(() => compile(page, { filename: 'md.svelte', generate: 'client' })).not.toThrow()
223
+ })
224
+
225
+ it('emits a pivot block (SvPivotDesigner + fields/layout, loads all rows)', () => {
226
+ let p = createProject([customers])
227
+ const sid = p.screens[0]!.id
228
+ p = addBlock(p, sid, 'pivot')
229
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
230
+ expect(page).toContain('<SvPivotDesigner')
231
+ expect(page).toMatch(/import \{[^}]*\bSvPivotDesigner\b/)
232
+ expect(page).toContain('async function loadAll()') // pivot reads the whole table
233
+ expect(page).toContain("kind: 'measure', defaultAgg: 'sum'") // mrr becomes a measure
234
+ expect(page).toContain("agg: 'sum'")
235
+ })
236
+
237
+ it('emits a filter panel that drives the grid controller', () => {
238
+ let p = createProject([customers])
239
+ const sid = p.screens[0]!.id
240
+ p = addBlock(p, sid, 'filter') // seeds tier (enum) + name (text) facets
241
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
242
+ expect(page).toContain('class="st-filter"')
243
+ expect(page).toContain('controller.setFilter({ columns: c })')
244
+ expect(page).toContain("operator: 'equals'") // enum facet
245
+ expect(page).toContain("operator: 'contains'") // text facet
246
+ })
247
+
248
+ it('renders an error banner (with retry) + a friendly grid empty message', () => {
249
+ const page = emitStudioProject(createProject([customers])).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
250
+ expect(page).toContain('{#if view.error}') // no longer a silent empty grid
251
+ expect(page).toContain('class="st-error"')
252
+ expect(page).toContain('controller.refresh()') // retry
253
+ expect(page).toContain('emptyMessage="No customer yet."') // friendly empty state
254
+ })
255
+
256
+ it('emits a record panel wired to the grid selection', () => {
257
+ let p = createProject([customers])
258
+ const sid = p.screens[0]!.id
259
+ p = addBlock(p, sid, 'record') // read-only by default
260
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
261
+ expect(page).toContain('let selectedRecord = $state')
262
+ expect(page).toContain('onRowClick={(e) => (selectedRecord = e.row)}') // grid publishes selection
263
+ expect(page).toContain('<dl class="st-record">') // read-only field list
264
+ })
265
+
266
+ it('record panel honors its presentation (inline / modal / drawer)', () => {
267
+ const pageFor = (presentation: 'inline' | 'modal' | 'drawer') => {
268
+ let p = createProject([customers])
269
+ const sid = p.screens[0]!.id
270
+ p = addBlock(p, sid, 'record')
271
+ const rid = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'record')!.id
272
+ p = updateBlock(p, sid, rid, { config: { editable: true, presentation } as Partial<import('./project').RecordConfig> })
273
+ return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
274
+ }
275
+ expect(pageFor('inline')).toContain('<SvGridEditPanel schema={customersSchema} row={selectedRecord} presentation="inline"')
276
+ expect(pageFor('modal')).toContain('presentation="modal"')
277
+ expect(pageFor('drawer')).toContain('presentation="drawer"')
278
+ // Modal/drawer only mount while a row is selected (float over the page).
279
+ expect(pageFor('modal')).toContain('Select a row to open its edit dialog')
280
+ expect(pageFor('drawer')).toContain('Select a row to open its editor drawer')
281
+ })
282
+
283
+ it('RBAC: emits access.ts, gates the UI, guards the server route + nav, and compiles', () => {
284
+ let p = createProject([customers, orders])
285
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' }) // gets a +server.ts
286
+ p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
287
+ { role: 'admin', screens: '*', actions: '*' },
288
+ { role: 'viewer', screens: ['customers'], actions: [] },
289
+ ] } }
290
+ const files = emitStudioProject(p)
291
+
292
+ const access = files.find((f) => f.path === 'src/lib/access.ts')!.contents
293
+ expect(access).toContain('export type AppRole = "admin" | "viewer"')
294
+ expect(access).toContain('export const currentRole = writable<AppRole>("viewer")')
295
+ expect(access).toContain('export function authorizeAction')
296
+ expect(access).toContain('export function getServerRole')
297
+
298
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
299
+ expect(page).toContain("import { currentRole, can } from '$lib/access'")
300
+ expect(page).toContain("{#if can($currentRole, 'create')}") // New button gated
301
+ expect(page).toContain("if (can($currentRole, 'update'))") // edit gated
302
+
303
+ const route = files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents
304
+ expect(route).toContain('authorize: ({ action, event }) => authorizeAction(getServerRole(event), action)')
305
+
306
+ const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
307
+ expect(layout).toContain('canScreen($currentRole, item.id)') // nav hides forbidden screens
308
+
309
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
310
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
311
+ }
312
+ })
313
+
314
+ it('emits no access.ts and no gating when RBAC is off', () => {
315
+ const files = emitStudioProject(createProject([customers]))
316
+ expect(files.find((f) => f.path === 'src/lib/access.ts')).toBeUndefined()
317
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
318
+ expect(page).not.toContain('$lib/access')
319
+ expect(page).not.toContain('$currentRole')
320
+ })
321
+
322
+ it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
323
+ let p = createProject([customers, orders])
324
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
325
+ p = { ...p, audit: true }
326
+ const files = emitStudioProject(p)
327
+
328
+ expect(files.find((f) => f.path === 'src/lib/audit.ts')).toBeTruthy()
329
+ expect(files.find((f) => f.path === 'src/routes/api/audit/+server.ts')).toBeTruthy()
330
+ const viewer = files.find((f) => f.path === 'src/routes/audit/+page.svelte')!.contents
331
+ expect(viewer).toContain("endpoint: '/api/audit'")
332
+
333
+ const route = files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents
334
+ expect(route).toContain("import { recordAudit } from '$lib/audit'")
335
+ expect(route).toContain('audit: (e) => recordAudit(')
336
+ expect(route).toContain('entity: "orders"')
337
+
338
+ const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
339
+ expect(layout).toContain('Audit log') // nav gains the viewer link
340
+
341
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
342
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
343
+ }
344
+ })
345
+
346
+ it('emits no audit files without a SQL-bound entity (audit needs a server route)', () => {
347
+ const files = emitStudioProject({ ...createProject([customers]), audit: true })
348
+ expect(files.find((f) => f.path === 'src/lib/audit.ts')).toBeUndefined()
349
+ expect(files.find((f) => f.path === 'src/routes/audit/+page.svelte')).toBeUndefined()
350
+ })
351
+
352
+ it('Layout: 12-col grid, colSpan gives finer widths (legacy span still works)', () => {
353
+ let p = createProject([customers, orders])
354
+ const cid = p.screens.find((s) => s.entity === 'customers')!.id
355
+ const gridId = p.screens.find((s) => s.id === cid)!.blocks[0]!.id
356
+ p = updateBlock(p, cid, gridId, { colSpan: 5 }) // finer than 1/2/3
357
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
358
+ expect(page).toContain('class="st-screen"') // 12-col grid lives in the responsive app.css class
359
+ expect(page).toContain('grid-column: span 5') // colSpan honored
360
+ // A legacy block with only `span` (default grid span 3) maps to 12 columns.
361
+ const ordPage = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
362
+ expect(ordPage).toContain('grid-column: span 12')
363
+ })
364
+
365
+ it('No-code business logic: compiles formula -> computed and validations -> hooks.validate', () => {
366
+ const invoices: EntitySchema = {
367
+ name: 'invoices', label: 'Invoice', idField: 'id',
368
+ fields: [
369
+ { field: 'id', type: 'text', primaryKey: true },
370
+ { field: 'qty', type: 'number' },
371
+ { field: 'price', type: 'number' },
372
+ { field: 'total', type: 'number', formula: 'qty * price' },
373
+ ],
374
+ validations: [
375
+ { field: 'qty', op: 'gt', value: 0, message: 'Quantity must be positive' },
376
+ { field: 'price', op: 'gte', compareTo: 'qty', message: 'Price must be at least qty' },
377
+ ],
378
+ }
379
+ const files = emitStudioProject(createProject([invoices]))
380
+ const schemas = files.find((f) => f.path === 'src/lib/schemas.ts')!.contents
381
+ expect(schemas).toContain('.computed = (row) =>')
382
+ expect(schemas).toContain('$.qty * $.price') // field names resolved to the row
383
+ expect(schemas).toContain('.hooks = {')
384
+ expect(schemas).toContain('errors["qty"] = "Quantity must be positive"')
385
+ expect(schemas).toContain('Number($["price"]) >= Number($["qty"])') // cross-field compare
386
+ // The no-code specs are NOT left in the data literal.
387
+ expect(schemas).not.toContain('"formula"')
388
+ expect(schemas).not.toContain('"validations"')
389
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
390
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
391
+ }
392
+ })
393
+
394
+ it('PGlite: a zero-setup embedded-Postgres entity generates a client DB + seed, no server route', () => {
395
+ let p = createProject([customers])
396
+ p = setEntityDataSource(p, 'customers', { kind: 'pglite', table: 'customers' })
397
+ const files = emitStudioProject(p)
398
+ const data = files.find((f) => f.path === 'src/lib/data.ts')!.contents
399
+ expect(data).toContain("import { PGlite } from '@electric-sql/pglite'")
400
+ expect(data).toContain("new PGlite('idb://svgrid-studio')")
401
+ expect(data).toContain('CREATE TABLE IF NOT EXISTS "customers"')
402
+ // Schema-additions migrate on reload (CREATE TABLE IF NOT EXISTS is a no-op once the table exists).
403
+ expect(data).toContain('ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "mrr" double precision')
404
+ expect(data).not.toMatch(/ADD COLUMN IF NOT EXISTS "id"/) // never re-add the primary key
405
+ expect(data).toContain('"mrr" double precision') // typed column
406
+ expect(data).toContain('createSqlDataSource<Customers>') // real SQL source
407
+ expect(data).toContain('await pgReady') // gated on bootstrap
408
+ expect(data).toContain('_pgSeed(') // seeded once
409
+ // Runs in the browser: NO server route.
410
+ expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeUndefined()
411
+ // The exported app declares the dependency.
412
+ const pkg = emitStudioAppBundle(p).find((f) => f.path === 'package.json')!.contents
413
+ expect(pkg).toContain('@electric-sql/pglite')
414
+ })
415
+
416
+ it('PGlite: a curated seed on the source (e.g. an imported CSV) is what the local DB seeds', () => {
417
+ let p = createProject([customers])
418
+ const seed = [{ id: 7, name: 'Zaphod', email: 'z@beeble.brox', mrr: 999, active: true }]
419
+ p = setEntityDataSource(p, 'customers', { kind: 'pglite', table: 'customers', seed })
420
+ const data = emitStudioProject(p).find((f) => f.path === 'src/lib/data.ts')!.contents
421
+ expect(data).toContain('"Zaphod"') // the imported row, not the generator's
422
+ expect(data).toContain('_pgSeed("customers"')
423
+ })
424
+
425
+ it('Deploy: default (auto) ships adapter-auto and no provider config', () => {
426
+ const bundle = emitStudioAppBundle(createProject([customers]))
427
+ const svelteCfg = bundle.find((f) => f.path === 'svelte.config.js')!.contents
428
+ const pkg = bundle.find((f) => f.path === 'package.json')!.contents
429
+ expect(svelteCfg).toContain("import adapter from '@sveltejs/adapter-auto'")
430
+ expect(pkg).toContain('@sveltejs/adapter-auto')
431
+ // No provider-specific config files in auto mode.
432
+ expect(bundle.find((f) => f.path === 'netlify.toml')).toBeUndefined()
433
+ expect(bundle.find((f) => f.path === 'wrangler.toml')).toBeUndefined()
434
+ })
435
+
436
+ it('Deploy: picking a target swaps the adapter + config and the README deploy steps', () => {
437
+ const netlify = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'netlify'))
438
+ expect(netlify.find((f) => f.path === 'svelte.config.js')!.contents).toContain("'@sveltejs/adapter-netlify'")
439
+ expect(netlify.find((f) => f.path === 'package.json')!.contents).toContain('@sveltejs/adapter-netlify')
440
+ expect(netlify.find((f) => f.path === 'netlify.toml')!.contents).toContain('npm run build')
441
+ expect(netlify.find((f) => f.path === 'README.md')!.contents).toContain('netlify deploy')
442
+
443
+ const cf = emitStudioAppBundle(setDeployTarget(createProject([customers], { title: 'My App' }), 'cloudflare'))
444
+ const wrangler = cf.find((f) => f.path === 'wrangler.toml')!.contents
445
+ expect(wrangler).toContain('name = "my-app"')
446
+ expect(wrangler).toContain('pages_build_output_dir = ".svelte-kit/cloudflare"')
447
+ expect(cf.find((f) => f.path === 'svelte.config.js')!.contents).toContain("'@sveltejs/adapter-cloudflare'")
448
+
449
+ // The designer's Deploy panel reads the same facts.
450
+ const info = studioDeployInfo(setDeployTarget(createProject([customers]), 'vercel'))
451
+ expect(info).toMatchObject({ label: 'Vercel', cli: 'npx vercel --prod', dashboard: 'https://vercel.com/new' })
452
+ })
453
+
454
+ it('Deploy: setDeployTarget(auto) clears the target (round-trips clean)', () => {
455
+ const p = setDeployTarget(setDeployTarget(createProject([customers]), 'vercel'), 'auto')
456
+ expect(p.deploy).toBeUndefined()
457
+ expect(parseProject(JSON.stringify(p)).deploy).toBeUndefined()
458
+ })
459
+
460
+ it('Round-trip: the exported bundle ships studio.config.json that re-parses into the designer', () => {
461
+ const p = setShell(createProject([customers, orders]), { brand: 'Acme', logo: 'data:image/png;base64,AA' })
462
+ const bundle = emitStudioAppBundle(p)
463
+ const cfg = bundle.find((f) => f.path === 'studio.config.json')
464
+ expect(cfg).toBeTruthy()
465
+ const reparsed = parseProject(cfg!.contents) // Load-able back into the designer
466
+ expect(reparsed.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
467
+ expect(reparsed.screens.length).toBe(p.screens.length)
468
+ expect(reparsed.theme?.shell?.logo).toBe('data:image/png;base64,AA') // logo survives the round-trip
469
+ expect(bundle.find((f) => f.path === 'README.md')!.contents).toContain('Round-tripping back into the designer')
470
+ })
471
+
472
+ it('Shell: responsive sidebar (burger + drawer), a company logo, and a stacking screen grid', () => {
473
+ let p = createProject([customers])
474
+ p = setShell(p, { style: 'sidebar', brand: 'Acme', logo: 'data:image/png;base64,AAAA' })
475
+ const files = emitStudioProject(p)
476
+ const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
477
+ expect(layout).toContain('sv-app__mobilebar') // mobile bar
478
+ expect(layout).toContain('sv-app__burger') // hamburger toggle
479
+ expect(layout).toContain('let navOpen = $state(false)') // drawer state
480
+ expect(layout).toContain('sv-app__backdrop')
481
+ expect(layout).toContain('const logo = "data:image/png;base64,AAAA"')
482
+ expect(layout).toContain('<img class="sv-app__logo"') // logo rendered
483
+ // Collapsible sidebar: docks on desktop, collapses to a drawer on tablet/phone.
484
+ expect(layout).toContain('sv-app__collapse') // desktop collapse toggle
485
+ expect(layout).toContain('class:is-drawer={drawer}') // drawer class binding
486
+ expect(layout).toContain("matchMedia('(max-width: 1024px)')") // tablet/phone -> collapsed
487
+ expect(layout).toContain("localStorage.setItem('svapp:nav'") // collapse choice persists
488
+
489
+ const appcss = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
490
+ expect(appcss).toContain('.st-screen')
491
+ expect(appcss).toContain('grid-template-columns: repeat(12, 1fr)')
492
+ expect(appcss).toContain('@media (max-width: 720px)') // blocks stack on mobile
493
+
494
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
495
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
496
+ }
497
+ })
498
+
499
+ it('Conditional formatting: compiles no-code rules into the grid rule engine', () => {
500
+ let p = createProject([customers])
501
+ const cid = p.screens[0]!.id
502
+ const gridId = p.screens[0]!.blocks.find((b) => b.config.kind === 'grid')!.id
503
+ p = updateBlock(p, cid, gridId, { config: { formatRules: [
504
+ { field: 'mrr', op: 'lt', value: 0, color: '#dc2626', bold: true },
505
+ { field: 'tier', op: 'eq', value: 'pro', background: '#eef2ff' },
506
+ ] } as Partial<GridConfig> })
507
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
508
+ expect(page).toContain('conditionalFormats={[')
509
+ expect(page).toContain("type: 'rule' as const")
510
+ expect(page).toContain("columns: ['mrr']")
511
+ expect(page).toContain('Number(value) < 0')
512
+ expect(page).toContain('fontWeight: 700')
513
+ expect(page).toContain("String(value) === \"pro\"")
514
+ expect(() => compile(page, { filename: 'cf.svelte', generate: 'client' })).not.toThrow()
515
+ })
516
+
517
+ it('Navigation: drill-through, chart drill, URL filters, and row actions - and compiles', () => {
518
+ let p = createProject([customers, orders])
519
+ const cid = p.screens.find((s) => s.entity === 'customers')!.id
520
+ const gridId = p.screens.find((s) => s.id === cid)!.blocks.find((b) => b.config.kind === 'grid')!.id
521
+ p = updateBlock(p, cid, gridId, { config: {
522
+ rowLink: { screen: 'orders', sourceField: 'id', targetField: 'customer_id' },
523
+ rowActions: [{ kind: 'edit' }, { kind: 'delete' }, { kind: 'navigate', screen: 'orders', sourceField: 'id', targetField: 'customer_id', label: 'Orders' }],
524
+ } as Partial<GridConfig> })
525
+ const files = emitStudioProject(p)
526
+
527
+ const cust = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
528
+ expect(cust).toContain("import { goto } from '$app/navigation'")
529
+ expect(cust).toContain("onRowClick={(e) => goto('/orders?customer_id='") // drill-through on row click
530
+ expect(cust).toContain("id: '__actions'") // synthetic action column
531
+ expect(cust).toContain('renderSnippet(rowActions_')
532
+ expect(cust).toContain('{#snippet rowActions_')
533
+ expect(cust).toContain('controller.deleteRow(') // delete button (fills the missing affordance)
534
+
535
+ const ord = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
536
+ expect(ord).toContain("import { page } from '$app/stores'") // drill TARGET reads URL params
537
+ expect(ord).toContain('sp.get(_f)')
538
+
539
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
540
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
541
+ }
542
+ })
543
+
544
+ it('i18n: emits the catalog, routes labels + headers through t(), and compiles', () => {
545
+ const p = { ...createProject([customers]), i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
546
+ const files = emitStudioProject(p)
547
+
548
+ const cat = files.find((f) => f.path === 'src/lib/i18n.ts')!.contents
549
+ expect(cat).toContain('export type Locale = "en" | "es"')
550
+ expect(cat).toContain('export const currentLocale = writable<Locale>("en")')
551
+ expect(cat).toContain('export function localizeCols')
552
+ expect(cat).toContain('"col.customers.name"') // seeded from the schema
553
+ expect(cat).toContain('"nav.customers"')
554
+ expect(cat).toContain('"es": {}') // other locale left for translators
555
+
556
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
557
+ expect(page).toContain("import { t, localizeCols } from '$lib/i18n'")
558
+ expect(page).toContain("$t('screen.customers'") // localized title
559
+ expect(page).toContain('localizeCols(') // localized column headers
560
+
561
+ const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
562
+ expect(layout).toContain("import { t, currentLocale, locales } from '$lib/i18n'")
563
+ expect(layout).toContain('sv-app__locale') // the language switcher
564
+
565
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
566
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
567
+ }
568
+ })
569
+
570
+ it('i18n: KPI / Gauge / Tab labels route through t() (not raw literals)', () => {
571
+ let p: StudioProject = { ...createProject([customers]), i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
572
+ const sid = p.screens[0]!.id
573
+ p = addBlock(p, sid, 'kpi')
574
+ p = addBlock(p, sid, 'gauge')
575
+ p = addBlock(p, sid, 'tabs')
576
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
577
+ expect(page).toMatch(/\{\$t\('block\.kpi-[^']+', /) // KPI label localized
578
+ expect(page).toMatch(/\{\$t\('block\.gauge-[^']+', /) // Gauge label localized
579
+ expect(page).toMatch(/label: \$t\('tab\.tabs-[^']+'/) // Tab labels localized in the tabs array
580
+ // Without i18n the same labels stay raw literals (no $t wrapper).
581
+ let plain = createProject([customers])
582
+ plain = addBlock(plain, plain.screens[0]!.id, 'kpi')
583
+ const plainPage = emitStudioProject(plain).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
584
+ expect(plainPage).not.toContain("$t('block.")
585
+ })
586
+
587
+ it('throws on a screen pointing at a missing entity', () => {
588
+ const broken = { ...project, screens: [{ ...project.screens[0]!, entity: 'ghosts' }] }
589
+ expect(() => emitStudioProject(broken)).toThrow(/missing entity/)
590
+ })
591
+ })
592
+
593
+ describe('data-source codegen', () => {
594
+ const byPathOf = (files: ReturnType<typeof emitStudioProject>, path: string) => files.find((f) => f.path === path)
595
+
596
+ it('emits createRestDataSource for a REST-bound entity with response mapping', () => {
597
+ let p = createProject([customers])
598
+ p = setEntityDataSource(p, 'customers', {
599
+ kind: 'rest', baseUrl: 'https://api.example.com/v1', path: 'customers', method: 'GET',
600
+ params: [
601
+ { name: 'X-Key', location: 'header', type: 'string', value: 'abc' },
602
+ { name: 'region', location: 'query', type: 'string', value: 'eu' },
603
+ ],
604
+ rowsPath: 'data.items', totalPath: 'data.total',
605
+ })
606
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
607
+ expect(dataTs).toContain('createRestDataSource')
608
+ expect(dataTs).toContain("url: 'https://api.example.com/v1/customers'")
609
+ expect(dataTs).toContain("headers: { 'X-Key': 'abc' }")
610
+ expect(dataTs).toContain("query: { 'region': 'eu' }") // static query params baked into every read
611
+ expect(dataTs).toContain('body?.data?.items')
612
+ expect(dataTs).not.toContain('createInMemoryDataSource') // customers is REST-bound
613
+ })
614
+
615
+ it('substitutes static path params into the URL', () => {
616
+ let p = createProject([customers])
617
+ p = setEntityDataSource(p, 'customers', {
618
+ kind: 'rest', baseUrl: 'https://api', path: 'albums/{id}/tracks', method: 'GET',
619
+ params: [{ name: 'id', location: 'path', type: 'string', value: '5' }],
620
+ })
621
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
622
+ expect(dataTs).toContain("url: 'https://api/albums/5/tracks'")
623
+ })
624
+
625
+ it('SQL-bound entity emits a connected +server.ts route + a Kit transport client', () => {
626
+ let p = createProject([customers, orders])
627
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
628
+ const files = emitStudioProject(p)
629
+ const dataTs = byPathOf(files, 'src/lib/data.ts')!.contents
630
+ // Client talks to the API route, not a raw SQL source in the browser.
631
+ expect(dataTs).toContain("createKitDataSource<Orders>({ endpoint: '/api/orders' })")
632
+ expect(dataTs).not.toContain('createSqlDataSource')
633
+ const route = byPathOf(files, 'src/routes/api/orders/+server.ts')
634
+ expect(route).toBeTruthy()
635
+ expect(route!.contents).toContain("import pg from 'pg'")
636
+ expect(route!.contents).toContain("import { env } from '$env/dynamic/private'")
637
+ expect(route!.contents).toContain('createSqlDataSource<Orders>')
638
+ expect(route!.contents).toContain("dialect: { placeholders: '$', ilike: true }")
639
+ expect(route!.contents).toContain('createKitHandlers({')
640
+ expect(route!.contents).toContain('schema: ordersSchema')
641
+ expect(route!.contents).toContain('validate: true') // server-enforced validation on every connected route
642
+ // No connections.ts when nothing needs a browser-side client.
643
+ expect(byPathOf(files, 'src/lib/connections.ts')).toBeUndefined()
644
+ })
645
+
646
+ it('picks the right driver per dialect', () => {
647
+ for (const [dialect, needle] of [['mysql', "import mysql from 'mysql2/promise'"], ['sqlite', "import Database from 'better-sqlite3'"], ['mssql', "import mssql from 'mssql'"], ['turso', "import { createClient } from '@libsql/client'"]] as const) {
648
+ let p = createProject([customers])
649
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
650
+ const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!
651
+ expect(route.contents).toContain(needle)
652
+ }
653
+ })
654
+
655
+ it('Provisioning: a Turso (libSQL) entity wires the client + auth token + a .env.example', () => {
656
+ let p = createProject([customers])
657
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'turso' })
658
+ const bundle = emitStudioAppBundle(p)
659
+ const route = bundle.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents
660
+ expect(route).toContain("import { createClient } from '@libsql/client'")
661
+ expect(route).toContain('env.DATABASE_AUTH_TOKEN')
662
+ expect(route).toContain("dialect: { placeholders: '?' }")
663
+ const pkg = bundle.find((f) => f.path === 'package.json')!.contents
664
+ expect(pkg).toContain('@libsql/client')
665
+ const envEx = bundle.find((f) => f.path === '.env.example')!.contents
666
+ expect(envEx).toContain('DATABASE_URL=')
667
+ expect(envEx).toContain('DATABASE_AUTH_TOKEN=')
668
+ })
669
+
670
+ it('.env.example lists DATABASE_URL for SQL apps, and is omitted for zero-setup apps', () => {
671
+ let sql = createProject([customers])
672
+ sql = setEntityDataSource(sql, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
673
+ const envEx = emitStudioAppBundle(sql).find((f) => f.path === '.env.example')!.contents
674
+ expect(envEx).toContain('DATABASE_URL=')
675
+ expect(envEx).not.toContain('DATABASE_AUTH_TOKEN')
676
+ // In-memory / PGlite apps need no env, so no .env.example.
677
+ expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === '.env.example')).toBeUndefined()
678
+ })
679
+
680
+ it('emits a real Supabase client from a project URL + anon key', () => {
681
+ let p = createProject([customers])
682
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon123' })
683
+ const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
684
+ expect(conn).toContain("import { createClient } from '@supabase/supabase-js'")
685
+ expect(conn).toContain("createClient('https://x.supabase.co', 'anon123')")
686
+ })
687
+
688
+ it('falls back to a Supabase null stub without url/key', () => {
689
+ let p = createProject([customers])
690
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
691
+ const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
692
+ expect(conn).toContain('supabaseClient = null as unknown as SupabaseClientLike')
693
+ expect(conn).not.toMatch(/^import \{ createClient \}/m) // no real client import without creds
694
+ })
695
+
696
+ it('every emitted .svelte compiles with mixed REST + Supabase sources', () => {
697
+ let p = createProject([customers, orders])
698
+ p = setEntityDataSource(p, 'customers', { kind: 'rest', baseUrl: 'https://api', path: 'customers', method: 'GET', params: [] })
699
+ p = setEntityDataSource(p, 'orders', { kind: 'supabase', table: 'orders' })
700
+ for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
701
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
702
+ }
703
+ })
704
+ })
705
+
706
+ describe('grid editing (a Grid property) -> codegen', () => {
707
+ const pageFor = (p: Parameters<typeof emitStudioProject>[0]) =>
708
+ emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
709
+ const withGrid = (patch: Partial<GridConfig>) => {
710
+ let p = createProject([customers])
711
+ const sid = p.screens[0]!.id
712
+ const gid = p.screens[0]!.blocks[0]!.id
713
+ p = updateBlock(p, sid, gid, { config: patch as Partial<import('./project').BlockConfig> })
714
+ return p
715
+ }
716
+
717
+ it('form editing (default) emits the edit panel + New button + non-editable cells', () => {
718
+ const page = pageFor(createProject([customers]))
719
+ expect(page).toContain('<SvGridEditPanel')
720
+ expect(page).toContain('+ New Customer')
721
+ expect(page).toContain('onRowDoubleClick')
722
+ expect(page).toContain('editable: false')
723
+ })
724
+
725
+ it('inline editing emits onCellValueChange -> updateRow, editable cells, no edit panel', () => {
726
+ const page = pageFor(withGrid({ editing: 'inline' }))
727
+ expect(page).toContain('onCellValueChange')
728
+ expect(page).toContain('controller.updateRow(')
729
+ expect(page).not.toContain('<SvGridEditPanel')
730
+ expect(page).not.toContain('editable: false')
731
+ })
732
+
733
+ it('read-only editing emits no editors', () => {
734
+ const page = pageFor(withGrid({ editing: 'none' }))
735
+ expect(page).not.toContain('onCellValueChange')
736
+ expect(page).not.toContain('<SvGridEditPanel')
737
+ expect(page).not.toContain('onRowDoubleClick')
738
+ expect(page).toContain('editable: false')
739
+ })
740
+
741
+ it('sortable / filterable toggles gate the grid props', () => {
742
+ const page = pageFor(withGrid({ sortable: false, filterable: true }))
743
+ expect(page).not.toContain('onSortingChange')
744
+ expect(page).toContain('onFiltersChange')
745
+ expect(page).toContain('controller.setFilter')
746
+ })
747
+
748
+ it('form presentation is honored', () => {
749
+ expect(pageFor(withGrid({ formPresentation: 'drawer' }))).toContain('presentation="drawer"')
750
+ })
751
+
752
+ it('page size flows into the controller', () => {
753
+ expect(pageFor(withGrid({ pageSize: 25 }))).toContain('pageSize: 25')
754
+ })
755
+
756
+ it('striped / cell-selection / totals / density map to grid props', () => {
757
+ const page = pageFor(withGrid({ striped: true, cellSelection: true, rowSummaries: true, density: 'compact' }))
758
+ expect(page).toContain('zebraRows')
759
+ expect(page).toContain('enableCellSelection')
760
+ expect(page).toContain('enableRowSummaries={true}')
761
+ expect(page).toContain('rowHeight={28}')
762
+ })
763
+
764
+ it('normal density + defaults emit no rowHeight and totals off', () => {
765
+ const page = pageFor(createProject([customers]))
766
+ expect(page).toContain('enableRowSummaries={false}')
767
+ expect(page).not.toContain('rowHeight=')
768
+ })
769
+
770
+ it('per-column header / width / align overrides flow into the columns', () => {
771
+ let p = createProject([customers])
772
+ const sid = p.screens[0]!.id
773
+ const gid = p.screens[0]!.blocks[0]!.id
774
+ const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
775
+ const columns = cfg.columns.map((c) => (c.field === 'name' ? { ...c, header: 'Full name', width: 220, align: 'center' as const } : c))
776
+ p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
777
+ const page = pageFor(p)
778
+ expect(page).toContain("'name': { header: \"Full name\", width: 220, align: 'center' }")
779
+ expect(page).toContain('...(ov[String(c.field)] ?? {})')
780
+ })
781
+
782
+ it('pager position + page-size options map to grid props', () => {
783
+ const page = pageFor(withGrid({ paginationPosition: 'both', pageSizeOptions: [20, 40, 80] }))
784
+ expect(page).toContain('showPagination')
785
+ expect(page).toContain('paginationPosition="both"')
786
+ expect(page).toContain('pageSizeOptions={[20, 40, 80]}')
787
+ })
788
+
789
+ it('default paging (bottom, [10,25,50,100]) stays implicit', () => {
790
+ const page = pageFor(createProject([customers]))
791
+ expect(page).toContain('showPagination')
792
+ expect(page).not.toContain('paginationPosition=')
793
+ expect(page).not.toContain('pageSizeOptions=')
794
+ })
795
+
796
+ it('an unpaginated grid loads all rows and shows no pager', () => {
797
+ const page = pageFor(withGrid({ paginated: false }))
798
+ expect(page).not.toContain('showPagination')
799
+ expect(page).toContain('pageSize: 1000') // controller loads everything at once
800
+ })
801
+
802
+ it('column pinning emits initialColumnPinning + disables column virtualization', () => {
803
+ let p = createProject([customers])
804
+ const sid = p.screens[0]!.id
805
+ const gid = p.screens[0]!.blocks[0]!.id
806
+ const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
807
+ const columns = cfg.columns.map((c) => (c.field === 'name' ? { ...c, pin: 'left' as const } : c))
808
+ p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
809
+ const page = pageFor(p)
810
+ expect(page).toContain("initialColumnPinning={{ left: ['name'] }}")
811
+ expect(page).toContain('columnVirtualization={false}')
812
+ })
813
+ })
814
+
815
+ describe('emitStudioAppBundle (full runnable app)', () => {
816
+ it('emits a complete SvelteKit + Vite project (scaffolding + screens)', () => {
817
+ const paths = emitStudioAppBundle(createProject([customers, orders], { title: 'My Sales App' })).map((f) => f.path)
818
+ for (const p of [
819
+ 'package.json', 'svelte.config.js', 'vite.config.ts', 'tsconfig.json',
820
+ 'src/app.html', 'src/app.css', 'src/app.d.ts', 'src/routes/+layout.ts', 'README.md',
821
+ 'src/lib/schemas.ts', 'src/lib/data.ts', 'src/routes/+layout.svelte', 'src/routes/customers/+page.svelte',
822
+ ]) {
823
+ expect(paths).toContain(p)
824
+ }
825
+ })
826
+
827
+ it('package.json has a slugged name, run scripts, and the runtime deps', () => {
828
+ const pkg = JSON.parse(emitStudioAppBundle(createProject([customers], { title: 'My Sales App!' })).find((f) => f.path === 'package.json')!.contents)
829
+ expect(pkg.name).toBe('my-sales-app')
830
+ expect(pkg.scripts.dev).toBe('vite dev')
831
+ expect(pkg.dependencies['@svgrid/grid']).toBeTruthy()
832
+ expect(pkg.dependencies['@svgrid/enterprise']).toBeTruthy()
833
+ expect(pkg.devDependencies['@sveltejs/kit']).toBeTruthy()
834
+ expect(pkg.devDependencies['vite']).toBeTruthy()
835
+ })
836
+
837
+ it('ships a per-entity smoke test + vitest wiring', () => {
838
+ const bundle = emitStudioAppBundle(createProject([customers, orders], { title: 'My Sales App' }))
839
+ const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
840
+ expect(pkg.scripts.test).toBe('vitest run')
841
+ expect(pkg.devDependencies['vitest']).toBeTruthy()
842
+ expect(bundle.find((f) => f.path === 'vitest.config.ts')).toBeTruthy()
843
+ const test = bundle.find((f) => f.path === 'src/lib/schemas.test.ts')!.contents
844
+ // one describe block per entity, asserting render + data-source round-trip
845
+ expect(test).toContain('describe("Customer"')
846
+ expect(test).toContain('describe("Order"')
847
+ expect(test).toContain('schemaToColumns(customersSchema)')
848
+ expect(test).toContain('schemaToFormFields(customersSchema)')
849
+ expect(test).toContain('createInMemoryDataSource<Customers>([], customersSchema)')
850
+ expect(test).toContain('round-trips create -> read -> delete')
851
+ expect(test).toContain("from './schemas'")
852
+ })
853
+
854
+ it('adds the driver dep when an entity binds to Supabase / SQL', () => {
855
+ let sb = createProject([customers])
856
+ sb = setEntityDataSource(sb, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'k' })
857
+ expect(JSON.parse(emitStudioAppBundle(sb).find((f) => f.path === 'package.json')!.contents).dependencies['@supabase/supabase-js']).toBeTruthy()
858
+
859
+ let sql = createProject([customers])
860
+ sql = setEntityDataSource(sql, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
861
+ expect(JSON.parse(emitStudioAppBundle(sql).find((f) => f.path === 'package.json')!.contents).dependencies['pg']).toBeTruthy()
862
+ })
863
+ })
864
+
865
+ describe('pages (nav) + shell codegen', () => {
866
+ const layoutOf = (p: Parameters<typeof emitStudioProject>[0]) =>
867
+ emitStudioProject(p).find((f) => f.path === 'src/routes/+layout.svelte')!.contents
868
+
869
+ it('drops hidden pages from nav and honors label + order', () => {
870
+ let p = createProject([customers, orders])
871
+ p = updateScreen(p, 'orders', { nav: { show: false } })
872
+ p = updateScreen(p, 'customers', { nav: { label: 'Clients', order: 5 } })
873
+ const layout = layoutOf(p)
874
+ expect(layout).toContain('Clients')
875
+ expect(layout).not.toContain('"/orders"') // orders excluded from the nav json
876
+ })
877
+
878
+ it('emits a top-nav shell with brand + footer', () => {
879
+ const p = setShell(createProject([customers]), { style: 'top-nav', brand: 'Acme', footer: '(c) Acme' })
880
+ const layout = layoutOf(p)
881
+ expect(layout).toContain('sv-app--top')
882
+ expect(layout).toContain('Acme')
883
+ expect(layout).toContain('(c) Acme')
884
+ })
885
+
886
+ it('defaults to sidebar; an empty footer omits the footer element', () => {
887
+ const p = setShell(createProject([customers]), { footer: '' })
888
+ const layout = layoutOf(p)
889
+ expect(layout).toContain('sv-app--side')
890
+ expect(layout).not.toContain('class="sv-app__foot"')
891
+ })
892
+
893
+ it('emits the chosen theme preset tokens into :root', () => {
894
+ const p = setThemePreset(createProject([customers]), 'material')
895
+ const layout = layoutOf(p)
896
+ expect(layout).toContain('--sg-accent: #6750a4') // Material 3 light accent
897
+ expect(layout).toContain('--sg-header-bg: #f3edf7') // Material 3 light surface
898
+ expect(layout).toContain('--sg-radius: 8px')
899
+ })
900
+
901
+ it('dark mode emits the dark palette + color-scheme: dark', () => {
902
+ const p = setTheme(setThemePreset(createProject([customers]), 'tailwind'), { mode: 'dark' })
903
+ const layout = layoutOf(p)
904
+ expect(layout).toContain('--sg-bg: #0f172a') // Tailwind dark bg
905
+ expect(layout).toContain('color-scheme: dark')
906
+ })
907
+
908
+ it('an accent override wins over the preset default accent', () => {
909
+ const p = setTheme(setThemePreset(createProject([customers]), 'github'), { accent: '#123456' })
910
+ const layout = layoutOf(p)
911
+ expect(layout).toContain('--sg-accent: #123456')
912
+ expect(layout).not.toContain('--sg-accent: #0969da')
913
+ })
914
+
915
+ it('custom CSS is appended to the generated app.css', () => {
916
+ const p = setTheme(createProject([customers]), { customCss: '.st__title { letter-spacing: -0.03em; }' })
917
+ const appcss = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
918
+ expect(appcss).toContain('Custom CSS (from the designer)')
919
+ expect(appcss).toContain('.st__title { letter-spacing: -0.03em; }')
920
+ // No custom block when unset.
921
+ expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === 'src/app.css')!.contents).not.toContain('Custom CSS (from the designer)')
922
+ })
923
+ })