@specverse/engines 4.2.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  8. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  9. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  10. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  11. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  12. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  13. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  14. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  15. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  16. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  17. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  18. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  19. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  22. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  23. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  24. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  25. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  26. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  27. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  28. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  29. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  30. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  31. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  32. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  33. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  34. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  35. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  36. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  37. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  38. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  39. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  40. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  41. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  42. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  43. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  44. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  45. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  46. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  47. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  48. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  49. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  50. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  51. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  52. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  53. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  54. package/package.json +2 -1
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { extractBelongsToTargets, pluralize as pluralizeShared } from "./belongs-to.js";
4
+ import { walkPatternSection } from "@specverse/runtime/views/core";
5
+ import { extractBelongsToTargets, extractHasManyTargets, pluralize as pluralizeShared } from "./belongs-to.js";
6
+ import { emitJsxSource } from "./emit-jsx-source.js";
5
7
  function emitView(context) {
6
8
  const skeleton = loadSkeleton(context.view.type);
7
9
  const bodyJsx = context.renderBody(context);
@@ -32,6 +34,8 @@ function buildSubstitutions(context, body) {
32
34
  const modelName = context.model.name;
33
35
  const pluralModel = pluralize(modelName);
34
36
  const { imports, hooks } = buildBelongsToWiring(context);
37
+ const lifecycle = buildLifecycleSubstitutions(context);
38
+ const { fieldsBody, existingListBody } = buildFormSplitBodies(context);
35
39
  return {
36
40
  MODEL_NAME: modelName,
37
41
  PLURAL_MODEL: pluralModel,
@@ -39,25 +43,234 @@ function buildSubstitutions(context, body) {
39
43
  SINGULAR_LOWER: modelName.toLowerCase(),
40
44
  BODY: body,
41
45
  RELATED_IMPORTS: imports,
42
- RELATED_HOOKS: hooks
46
+ RELATED_HOOKS: hooks,
47
+ RELATED_TAB_STATE: buildRelatedTabState(context),
48
+ RELATED_TAB_IMPORT: buildRelatedTabImport(context),
49
+ FIELDS_BODY: fieldsBody,
50
+ EXISTING_LIST_BODY: existingListBody,
51
+ ...lifecycle
43
52
  };
44
53
  }
54
+ function buildFormSplitBodies(context) {
55
+ if (context.view.type.toLowerCase() !== "form") {
56
+ return { fieldsBody: "", existingListBody: "" };
57
+ }
58
+ const walkCtx = {
59
+ model: context.model,
60
+ modelSchemas: context.modelSchemas,
61
+ view: context.view
62
+ };
63
+ const imports = /* @__PURE__ */ new Set();
64
+ const fieldsNodes = walkPatternSection("form-view", "fields", walkCtx);
65
+ const listNodes = walkPatternSection("form-view", "existing-entities-list", walkCtx);
66
+ return {
67
+ fieldsBody: emitJsxSource(fieldsNodes, { imports, indent: 10 }),
68
+ existingListBody: emitJsxSource(listNodes, { imports, indent: 6 })
69
+ };
70
+ }
71
+ function buildLifecycleSubstitutions(context) {
72
+ const isForm = context.view.type.toLowerCase() === "form";
73
+ const lifecycles = extractLifecycles(context.model);
74
+ if (!isForm || lifecycles.length === 0) {
75
+ return {
76
+ LIFECYCLE_IMPORTS: "",
77
+ LIFECYCLE_HOOK: "",
78
+ LIFECYCLE_STATE: "",
79
+ LIFECYCLE_HANDLER: "",
80
+ LIFECYCLE_PANEL: "",
81
+ HAS_LIFECYCLES: "false"
82
+ };
83
+ }
84
+ const modelName = context.model.name;
85
+ const primary = lifecycles[0];
86
+ const lifecyclesConst = `${modelName.toUpperCase()}_LIFECYCLES`;
87
+ return {
88
+ LIFECYCLE_IMPORTS: ` useEvolve${modelName}Mutation,
89
+ ${lifecyclesConst},`,
90
+ LIFECYCLE_HOOK: ` const evolveItem = useEvolve${modelName}Mutation();`,
91
+ LIFECYCLE_STATE: ` const [targetState, setTargetState] = useState<string>('');`,
92
+ LIFECYCLE_HANDLER: ` const handleEvolveSubmit = async (event: React.FormEvent) => {
93
+ event.preventDefault();
94
+ if (!targetState) return;
95
+ try {
96
+ const result = await evolveItem.mutateAsync({
97
+ id: entityId as any,
98
+ toState: targetState,
99
+ lifecycleName: '${primary.name}',
100
+ });
101
+ onSuccess?.(result as ${modelName});
102
+ } catch {
103
+ // Evolve errors are surfaced via evolveItem.isError.
104
+ }
105
+ };`,
106
+ LIFECYCLE_PANEL: buildLifecyclePanel(modelName, primary, lifecyclesConst),
107
+ HAS_LIFECYCLES: "true"
108
+ };
109
+ }
110
+ function buildLifecyclePanel(modelName, primary, lifecyclesConst) {
111
+ return ` {showTabs && activeTab === 'evolve' && (
112
+ <form onSubmit={handleEvolveSubmit} className="space-y-6">
113
+ <div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">
114
+ <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
115
+ <div>
116
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Current ${humanize(primary.name)}</dt>
117
+ <dd className="mt-1">
118
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
119
+ {String((existing as any)?.${primary.name} ?? '')}
120
+ </span>
121
+ </dd>
122
+ </div>
123
+ <div>
124
+ <label htmlFor="evolve-target" className="block text-sm font-medium text-gray-500 dark:text-gray-400">Next ${humanize(primary.name)}</label>
125
+ <select
126
+ id="evolve-target"
127
+ value={targetState}
128
+ onChange={e => setTargetState(e.target.value)}
129
+ className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
130
+ >
131
+ <option value="">\u2014 choose \u2014</option>
132
+ {${lifecyclesConst}.${primary.name}.states.map(s => (
133
+ <option key={s} value={s}>{s}</option>
134
+ ))}
135
+ </select>
136
+ </div>
137
+ </dl>
138
+ </div>
139
+
140
+ <div>
141
+ <button
142
+ type="submit"
143
+ disabled={evolveItem.isPending || !targetState}
144
+ className="rounded bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-60"
145
+ >
146
+ {evolveItem.isPending ? 'Evolving\u2026' : \`Evolve to \${targetState || '\u2026'}\`}
147
+ </button>
148
+ </div>
149
+
150
+ {evolveItem.isError && (
151
+ <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
152
+ Evolve failed: {String(evolveItem.error)}
153
+ </div>
154
+ )}
155
+ </form>
156
+ )}`;
157
+ }
158
+ function extractLifecycles(model) {
159
+ const out = [];
160
+ const lifecycles = model?.lifecycles ?? {};
161
+ for (const [name, def] of Object.entries(lifecycles)) {
162
+ if (!def || typeof def !== "object") continue;
163
+ let states = [];
164
+ if (Array.isArray(def.states)) {
165
+ states = def.states.map((s) => typeof s === "string" ? s : s?.name ?? s?.id ?? "").filter(Boolean);
166
+ } else if (typeof def.flow === "string") {
167
+ states = def.flow.split(/\s*(?:->|,)\s*/).map((s) => s.trim()).filter(Boolean);
168
+ }
169
+ if (states.length > 0) out.push({ name, states });
170
+ }
171
+ return out;
172
+ }
173
+ function humanize(name) {
174
+ return name.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
175
+ }
176
+ function buildRelatedTabImport(context) {
177
+ if (context.view.type.toLowerCase() !== "detail") return "";
178
+ const hasMany = extractHasManyTargets(context.model, context.modelSchemas);
179
+ if (hasMany.length < 2) return "";
180
+ return `import { useState } from 'react';`;
181
+ }
182
+ function buildRelatedTabState(context) {
183
+ if (context.view.type.toLowerCase() !== "detail") return "";
184
+ const hasMany = extractHasManyTargets(context.model, context.modelSchemas);
185
+ if (hasMany.length < 2) return "";
186
+ const firstName = hasMany[0].name;
187
+ return ` const [relatedTab, setRelatedTab] = useState<string>('${firstName}');`;
188
+ }
45
189
  function buildBelongsToWiring(context) {
46
- const rels = extractBelongsToTargets(context.model);
47
- if (rels.length === 0) return { imports: "", hooks: "" };
190
+ const belongsTo = extractBelongsToTargets(context.model);
191
+ const isDetail = context.view.type.toLowerCase() === "detail";
192
+ const hasMany = isDetail ? extractHasManyTargets(context.model, context.modelSchemas) : [];
193
+ const crossBelongsTo = [];
194
+ if (isDetail) {
195
+ for (const rel of hasMany) {
196
+ const targetModel = context.modelSchemas[rel.target];
197
+ if (!targetModel) continue;
198
+ const targetFK = rel.foreignKey;
199
+ for (const btm of extractBelongsToTargets(targetModel)) {
200
+ if (`${btm.name}Id` === targetFK) continue;
201
+ crossBelongsTo.push(btm);
202
+ }
203
+ }
204
+ }
205
+ if (belongsTo.length === 0 && hasMany.length === 0 && crossBelongsTo.length === 0) {
206
+ const vt = context.view.type.toLowerCase();
207
+ if (vt === "detail") {
208
+ return {
209
+ imports: `import { getEntityDisplayName, formatDisplayValue } from '../lib/entity-display';`,
210
+ hooks: ""
211
+ };
212
+ }
213
+ if (vt === "list" || vt === "dashboard") {
214
+ return {
215
+ imports: `import { formatDisplayValue } from '../lib/entity-display';`,
216
+ hooks: ""
217
+ };
218
+ }
219
+ return { imports: "", hooks: "" };
220
+ }
48
221
  const hookImportNames = /* @__PURE__ */ new Set();
49
- for (const rel of rels) {
222
+ for (const rel of belongsTo) {
223
+ hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
224
+ }
225
+ for (const rel of hasMany) {
226
+ hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
227
+ }
228
+ for (const rel of crossBelongsTo) {
50
229
  hookImportNames.add(`use${pluralizeShared(rel.target)}Query`);
51
230
  }
52
- const helperImport = context.view.type.toLowerCase() === "form" ? "getEntityDisplayName" : "resolveEntityDisplayName";
231
+ const viewType = context.view.type.toLowerCase();
232
+ const helperImports = /* @__PURE__ */ new Set();
233
+ if (viewType === "form") {
234
+ helperImports.add("getEntityDisplayName");
235
+ } else if (viewType === "detail") {
236
+ helperImports.add("getEntityDisplayName");
237
+ helperImports.add("formatDisplayValue");
238
+ if (belongsTo.length > 0 || crossBelongsTo.length > 0) {
239
+ helperImports.add("resolveEntityDisplayName");
240
+ }
241
+ } else if (viewType === "list" || viewType === "dashboard") {
242
+ helperImports.add("resolveEntityDisplayName");
243
+ helperImports.add("formatDisplayValue");
244
+ } else {
245
+ helperImports.add("resolveEntityDisplayName");
246
+ }
53
247
  const importLines = [];
54
248
  importLines.push(
55
249
  `import { ${[...hookImportNames].join(", ")} } from '../hooks/useApi';`
56
250
  );
57
- importLines.push(`import { ${helperImport} } from '../lib/entity-display';`);
58
- const hookLines = rels.map(
59
- (rel) => ` const { data: ${rel.name}Options = [] } = use${pluralizeShared(rel.target)}Query();`
60
- );
251
+ if (helperImports.size > 0) {
252
+ importLines.push(`import { ${[...helperImports].join(", ")} } from '../lib/entity-display';`);
253
+ }
254
+ const hookLines = [];
255
+ for (const rel of belongsTo) {
256
+ hookLines.push(
257
+ ` const { data: ${rel.name}Options = [] } = use${pluralizeShared(rel.target)}Query();`
258
+ );
259
+ }
260
+ for (const rel of hasMany) {
261
+ hookLines.push(
262
+ ` const { data: ${rel.name}All = [] } = use${pluralizeShared(rel.target)}Query();`
263
+ );
264
+ }
265
+ const emittedOptionsVars = new Set(belongsTo.map((r) => `${r.name}Options`));
266
+ for (const rel of crossBelongsTo) {
267
+ const varName = `${rel.name}Options`;
268
+ if (emittedOptionsVars.has(varName)) continue;
269
+ emittedOptionsVars.add(varName);
270
+ hookLines.push(
271
+ ` const { data: ${varName} = [] } = use${pluralizeShared(rel.target)}Query();`
272
+ );
273
+ }
61
274
  return {
62
275
  imports: importLines.join("\n"),
63
276
  hooks: hookLines.join("\n")
@@ -3,7 +3,9 @@ import { composeListBody } from "./list-body-composer.js";
3
3
  import { composeDetailBody } from "./detail-body-composer.js";
4
4
  import { composeFormBody } from "./form-body-composer.js";
5
5
  import { composeDashboardBody } from "./dashboard-body-composer.js";
6
- import { emitEntityDisplay } from "./helpers-emitter.js";
6
+ import { emitEntityDisplay, emitUseResizableSidebar, emitUseAppEvents } from "./helpers-emitter.js";
7
+ import { emitFieldInput, emitOperationExecutor, emitOperationResultView } from "./operation-emitters.js";
8
+ import { collectOperationViews, emitOperationView } from "./operation-view-generator.js";
7
9
  const PRIMARY_VIEW_TYPES = ["list", "detail", "form", "dashboard"];
8
10
  const COMPOSER_BY_TYPE = {
9
11
  list: composeListBody,
@@ -42,7 +44,18 @@ async function generate(context) {
42
44
  `[ReactAppStarter] Skipped ${skippedSpecialists.length} specialist view(s) \u2014 implement composers for: ${[...new Set(Object.values(viewsBySpec).filter((v) => !PRIMARY_VIEW_TYPES.includes(v.type)).map((v) => v.type))].join(", ")}. Skipped: ${skippedSpecialists.join(", ")}`
43
45
  );
44
46
  }
47
+ const operationViews = collectOperationViews(spec);
48
+ for (const desc of operationViews) {
49
+ files[`src/views/${desc.viewName}.tsx`] = emitOperationView(desc);
50
+ }
45
51
  files["src/lib/entity-display.ts"] = emitEntityDisplay();
52
+ files["src/hooks/useResizableSidebar.ts"] = emitUseResizableSidebar();
53
+ files["src/hooks/useAppEvents.tsx"] = emitUseAppEvents();
54
+ if (operationViews.length > 0) {
55
+ files["src/lib/FieldInput.tsx"] = emitFieldInput();
56
+ files["src/lib/OperationExecutor.tsx"] = emitOperationExecutor();
57
+ files["src/lib/OperationResultView.tsx"] = emitOperationResultView();
58
+ }
46
59
  return files;
47
60
  }
48
61
  function indexViews(views) {
@@ -187,9 +187,21 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
187
187
  await handler.delete(id);
188
188
  return reply.status(204).send();
189
189
  } catch (error) {
190
+ // Prisma's P2003 is the foreign-key-constraint-violated error. When
191
+ // a ${lowerModel} has children (hasMany relationships not declared
192
+ // with 'cascade'), SQLite / Postgres refuse the delete and we land
193
+ // here. Return a 409 Conflict with a specific hint rather than a 500
194
+ // with a raw Prisma dump \u2014 the frontend shows the message verbatim.
195
+ const msg = error instanceof Error ? error.message : String(error);
196
+ if (msg.includes('P2003') || msg.toLowerCase().includes('foreign key constraint')) {
197
+ return reply.status(409).send({
198
+ error: 'Cannot delete ${lowerModel}',
199
+ message: \`This ${lowerModel} has related records. Delete those first, or declare the relationship with 'cascade' in your spec to delete them automatically.\`
200
+ });
201
+ }
190
202
  return reply.status(500).send({
191
203
  error: 'Failed to delete ${lowerModel}',
192
- message: error instanceof Error ? error.message : String(error)
204
+ message: msg
193
205
  });
194
206
  }`;
195
207
  case "list":
@@ -118,6 +118,24 @@ ${hasEvents ? ` // Register WebSocket bridge for real-time frontend events
118
118
  console.log(\`API endpoints: ${modelNames.map((n) => `/api/${pluralizeLower(n)}`).join(", ")}\`);
119
119
  ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
120
120
  console.log(\`Events: ${specEvents.length} wired (GET /api/runtime/events for the full list)\`);` : ""}
121
+
122
+ // Graceful shutdown \u2014 without these handlers, Ctrl-C leaves open
123
+ // WebSocket connections + Prisma clients hanging, and tsx watch
124
+ // reports "Previous process hasn't exited yet. Force killing...".
125
+ // Drain Fastify (closes HTTP + WS), then disconnect Prisma, then exit.
126
+ const shutdown = async (signal: string) => {
127
+ try {
128
+ fastify.log.info(\`Received \${signal}, closing server...\`);
129
+ await fastify.close();
130
+ try { await (prisma as any).$disconnect?.(); } catch { /* ignore */ }
131
+ process.exit(0);
132
+ } catch (err) {
133
+ fastify.log.error({ err }, 'Error during shutdown');
134
+ process.exit(1);
135
+ }
136
+ };
137
+ process.on('SIGINT', () => shutdown('SIGINT'));
138
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
121
139
  } catch (err) {
122
140
  fastify.log.error(err);
123
141
  process.exit(1);
@@ -13,7 +13,7 @@ export default function generateIndexHtml(context: TemplateContext): string {
13
13
  const description = spec.metadata?.description || 'Generated by SpecVerse';
14
14
 
15
15
  return `<!DOCTYPE html>
16
- <html lang="en">
16
+ <html lang="en" class="dark">
17
17
  <head>
18
18
  <meta charset="UTF-8" />
19
19
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
@@ -21,7 +21,7 @@ export default function generateIndexHtml(context: TemplateContext): string {
21
21
  <meta name="description" content="${description}" />
22
22
  <title>${appName}</title>
23
23
  </head>
24
- <body>
24
+ <body class="bg-gray-950 text-gray-100">
25
25
  <div id="root"></div>
26
26
  <script type="module" src="/src/main.tsx"></script>
27
27
  </body>
@@ -130,7 +130,9 @@ describe('composeDashboardBody — recent preview', () => {
130
130
  describe('composeDashboardBody — TODO comments', () => {
131
131
  it('flags the extension point for aggregation metrics', () => {
132
132
  const body = composeDashboardBody(makeContext());
133
- expect(body).toContain('TODO: add aggregation metrics');
133
+ // Phase 2: the TODO now sits in the 'charts' section (not the
134
+ // 'metrics' row) since metrics are rendered as count cards.
135
+ expect(body).toContain('TODO: add aggregation charts');
134
136
  });
135
137
  });
136
138
 
@@ -50,7 +50,7 @@ function assertValidTsx(source: string, label: string): void {
50
50
  }
51
51
 
52
52
  describe('composeDetailBody — direct output', () => {
53
- it('groups fields into business / belongsTo / metadata sections', () => {
53
+ it('groups fields into business and belongsTo sections; omits metadata', () => {
54
54
  const body = composeDetailBody(makeContext());
55
55
 
56
56
  // Business fields (first <dl>)
@@ -63,24 +63,20 @@ describe('composeDetailBody — direct output', () => {
63
63
  expect(body).toContain('>Author<');
64
64
  expect(body).toContain('resolveEntityDisplayName((item as any).authorId, authorOptions)');
65
65
 
66
- // Metadata section (id / createdAt / publishedAt) muted styling
67
- expect(body).toContain('>Id<');
68
- expect(body).toContain('>Created At<');
69
- expect(body).toContain('>Published At<');
70
- expect(body).toContain('text-xs text-gray-500');
71
-
72
- // Business section is separate from metadata (no mixing)
73
- const businessIdx = body.indexOf('>Title<');
74
- const metadataIdx = body.indexOf('>Id<');
75
- expect(businessIdx).toBeLessThan(metadataIdx);
66
+ // Metadata (id / createdAt / publishedAt) is deliberately NOT rendered
67
+ // in the detail view — the list view surfaces it when users need it.
68
+ expect(body).not.toContain('>Id<');
69
+ expect(body).not.toContain('>Created At<');
70
+ expect(body).not.toContain('>Published At<');
76
71
  });
77
72
 
78
- it('emits one <dt>/<dd> per field with (item as any).FIELD access', () => {
73
+ it('emits one <dt>/<dd> per business field with (item as any).FIELD access', () => {
79
74
  const body = composeDetailBody(makeContext());
80
- expect(body).toContain('(item as any).title ??');
81
- expect(body).toContain('(item as any).body ??');
82
- expect(body).toContain('(item as any).id ??');
83
- expect(body).toContain('(item as any).createdAt ??');
75
+ expect(body).toContain('formatDisplayValue((item as any).title)');
76
+ expect(body).toContain('formatDisplayValue((item as any).body)');
77
+ // Metadata access expressions are absent.
78
+ expect(body).not.toContain('formatDisplayValue((item as any).id)');
79
+ expect(body).not.toContain('formatDisplayValue((item as any).createdAt)');
84
80
  });
85
81
 
86
82
  it('falls back to a "no business fields" message when all attrs are metadata', () => {
@@ -125,22 +121,25 @@ describe('emitView wired to composeDetailBody — end-to-end', () => {
125
121
  assertValidTsx(source, 'PostDetailView');
126
122
  });
127
123
 
128
- it('wires the skeleton: component, hooks, actions, body', () => {
124
+ it('wires the skeleton: component, query hook, picker, body', () => {
129
125
  const source = emitView(makeContext());
130
126
  expect(source).toContain('export function PostDetailView');
131
127
  expect(source).toContain('usePostsQuery');
132
- expect(source).toContain('useDeletePostMutation');
133
- expect(source).toContain('Post detail');
134
- expect(source).toContain('onEdit(item)');
135
- expect(source).toContain("confirm('Delete this post?')");
136
- expect(source).toContain('(item as any).title ??');
128
+ expect(source).toContain('Select Post:');
129
+ expect(source).toContain('formatDisplayValue((item as any).title)');
137
130
  expect(source).not.toMatch(/\{\{[A-Z_]+\}\}/);
131
+ // Detail is inspection-only — delete/edit live in the form view.
132
+ expect(source).not.toContain('useDeletePostMutation');
133
+ expect(source).not.toContain('onEdit(item)');
134
+ expect(source).not.toContain("confirm('Delete this post?')");
138
135
  });
139
136
 
140
- it('handles plural singular correctly for the loading / not-found copy', () => {
137
+ it('handles plural / singular correctly for loading + empty copy', () => {
141
138
  const source = emitView(makeContext());
142
139
  expect(source).toContain('Loading…');
143
- // SINGULAR_LOWER substitution: "No post matching id ..."
144
- expect(source).toContain('No post matching id');
140
+ // SINGULAR_LOWER substitution: "No post to display."
141
+ expect(source).toContain('No post to display');
142
+ // PLURAL_LOWER substitution: "No posts yet" (picker empty state)
143
+ expect(source).toContain('No posts yet');
145
144
  });
146
145
  });
@@ -82,11 +82,11 @@ describe('composeListBody — direct output', () => {
82
82
  expect(body).toContain('onClick={() => onSelect?.(item)}');
83
83
 
84
84
  // Count how many times each field name appears in cell-context (inside
85
- // the `<td>...item.FIELD ??` substring). Metadata should appear 0 times;
86
- // business fields exactly once each.
85
+ // the `<td>...formatDisplayValue((item as any).FIELD)...` substring).
86
+ // Metadata should appear 0 times; business fields exactly once each.
87
87
  const cellsRegion = body.slice(body.indexOf('<tbody'));
88
88
  const cellAppearances = (field: string) =>
89
- (cellsRegion.match(new RegExp(`item as any\\)\\.${field} \\?\\?`, 'g')) ?? []).length;
89
+ (cellsRegion.match(new RegExp(`formatDisplayValue\\(\\(item as any\\)\\.${field}\\)`, 'g')) ?? []).length;
90
90
 
91
91
  expect(cellAppearances('title')).toBe(1);
92
92
  expect(cellAppearances('body')).toBe(1);
@@ -88,6 +88,8 @@ describe('orchestrator — first-run (empty project)', () => {
88
88
  `${HASHES_DIR}/${HASHES_FILE}`,
89
89
  'package.json',
90
90
  'src/App.tsx',
91
+ 'src/hooks/useAppEvents.tsx',
92
+ 'src/hooks/useResizableSidebar.ts',
91
93
  'src/lib/entity-display.ts',
92
94
  'src/views/PostDashboardView.tsx',
93
95
  'src/views/PostDetailView.tsx',
@@ -151,7 +151,7 @@ describe('P3 — list view table shell parity', () => {
151
151
  expect(body).toContain('overflow-x-auto');
152
152
  expect(body).toContain('min-w-full');
153
153
  expect(body).toContain('divide-y divide-gray-200');
154
- expect(body).toContain('bg-gray-50 dark:bg-gray-800');
154
+ expect(body).toContain('bg-gray-50 dark:bg-gray-700');
155
155
  });
156
156
 
157
157
  it('rows are synthesized by Factory B (the adapter emits placeholder for react mount)', () => {
@@ -40,15 +40,16 @@ describe('app-tsx-generator', () => {
40
40
  // One import per (model, view) pair — 2 models × 4 view types = 8 imports
41
41
  const importLines = source.match(/^import \{ \w+View \} from '\.\/views\//gm) ?? [];
42
42
  expect(importLines.length).toBe(8);
43
- // Navigation wiring
44
- expect(source).toContain("select('Post', 'list')");
45
- expect(source).toContain("select('Author', 'dashboard')");
43
+ // Navigation wiring — post-R0, setSelection is inlined (no select() helper).
44
+ // Selection state is a discriminated union with kind='model' for CRUD views.
45
+ expect(source).toContain(`setSelection({ kind: 'model', model: 'Post', view: 'list' })`);
46
+ expect(source).toContain(`setSelection({ kind: 'model', model: 'Author', view: 'dashboard' })`);
46
47
  });
47
48
 
48
49
  it('handles an empty spec gracefully', async () => {
49
50
  const source = await generateAppTsx({ spec: { models: {}, views: {} } });
50
51
  assertValidTsx(source, 'App.tsx (empty spec)');
51
- expect(source).toContain('No models in this spec.');
52
+ expect(source).toContain('No models or operations');
52
53
  // Still renders a QueryClientProvider
53
54
  expect(source).toContain('QueryClientProvider');
54
55
  });
@@ -58,8 +58,11 @@ describe('views-generator.generate', () => {
58
58
  expect(paths).toContain('src/views/AuthorFormView.tsx');
59
59
  expect(paths).toContain('src/views/AuthorDashboardView.tsx');
60
60
  expect(paths).toContain('src/lib/entity-display.ts');
61
- // 2 models × 4 view types + 1 helper = 9 files.
62
- expect(paths.length).toBe(9);
61
+ expect(paths).toContain('src/hooks/useResizableSidebar.ts');
62
+ expect(paths).toContain('src/hooks/useAppEvents.tsx');
63
+ // 2 models × 4 view types + 3 helpers (entity-display, useResizableSidebar,
64
+ // useAppEvents) = 11 files.
65
+ expect(paths.length).toBe(11);
63
66
  });
64
67
 
65
68
  it('every emitted view file parses as valid TSX', async () => {
@@ -133,7 +136,11 @@ describe('views-generator.generate', () => {
133
136
 
134
137
  it('handles an empty models map gracefully', async () => {
135
138
  const files = await generate({ spec: { models: {}, views: {} } });
136
- // Only the helpers file is emitted; no view files.
137
- expect(Object.keys(files)).toEqual(['src/lib/entity-display.ts']);
139
+ // Only the helper files are emitted; no view files.
140
+ expect(Object.keys(files).sort()).toEqual([
141
+ 'src/hooks/useAppEvents.tsx',
142
+ 'src/hooks/useResizableSidebar.ts',
143
+ 'src/lib/entity-display.ts',
144
+ ]);
138
145
  });
139
146
  });