@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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- 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
|
+
})
|