@pixel-point/toolcraft 0.0.16 → 0.0.17

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 (170) hide show
  1. package/package.json +1 -1
  2. package/src/generate-performance-iteration-execution.test.mjs +2 -2
  3. package/src/generate-test-demand-only-lifecycle.mjs +14 -3
  4. package/src/generate-test-dependency-sandbox.mjs +42 -5
  5. package/src/generate-test-doc-assertions.mjs +68 -18
  6. package/src/generate-test-doc-assertions.test.mjs +22 -0
  7. package/src/generate-test-file-assertions.mjs +43 -76
  8. package/src/generate-test-output-assertions.mjs +95 -0
  9. package/src/generate-test-package-assertions.mjs +6 -0
  10. package/src/generate-test-performance-boundary-assertions.mjs +45 -0
  11. package/src/generate-test-product-fixture.mjs +1 -0
  12. package/src/generate-test-product-fixture.test.mjs +18 -0
  13. package/src/generate-test-product-iteration.mjs +1 -0
  14. package/src/generate-test-product-iteration.test.mjs +1 -0
  15. package/src/generate-test-standalone-delivery-stages.mjs +14 -6
  16. package/src/generate.test.mjs +14 -0
  17. package/templates/runtime/contracts/component-contracts.choices.test.ts +12 -9
  18. package/templates/runtime/contracts/component-contracts.choices.ts +10 -9
  19. package/templates/runtime/contracts/component-contracts.runtime-acceptance.test.ts +17 -2
  20. package/templates/runtime/contracts/component-contracts.runtime-panels.test.ts +1 -1
  21. package/templates/runtime/contracts/component-contracts.runtime-shell.test.ts +1 -1
  22. package/templates/runtime/contracts/component-contracts.runtime.ts +3 -2
  23. package/templates/runtime/contracts/decision-contracts.test.ts +25 -4
  24. package/templates/runtime/contracts/decision-contracts.ts +3 -3
  25. package/templates/runtime/contracts/performance-verification-policy.test.ts +59 -6
  26. package/templates/runtime/contracts/performance-verification-policy.ts +5 -2
  27. package/templates/runtime/export/artifact-download.test.ts +71 -0
  28. package/templates/runtime/export/artifact-download.ts +80 -0
  29. package/templates/runtime/export/artifact-export-request.ts +23 -0
  30. package/templates/runtime/export/artifact-export-settings.test.ts +75 -0
  31. package/templates/runtime/export/artifact-export-settings.ts +96 -0
  32. package/templates/runtime/export/artifact-frame-renderer.test.ts +107 -0
  33. package/templates/runtime/export/artifact-frame-renderer.ts +96 -0
  34. package/templates/runtime/export/artifact-frame-state.test.ts +54 -0
  35. package/templates/runtime/export/artifact-frame-state.ts +44 -0
  36. package/templates/runtime/export/artifact-scene-frame.test.ts +84 -0
  37. package/templates/runtime/export/artifact-scene-frame.ts +118 -0
  38. package/templates/runtime/export/export-background.ts +65 -0
  39. package/templates/runtime/export/export-error.ts +43 -0
  40. package/templates/runtime/export/export.test.ts +6 -186
  41. package/templates/runtime/export/image-artifact-export.test.ts +111 -0
  42. package/templates/runtime/export/image-artifact-export.ts +145 -0
  43. package/templates/runtime/export/index.ts +14 -1
  44. package/templates/runtime/export/product-export-renderer.test.ts +77 -0
  45. package/templates/runtime/export/product-export-renderer.ts +63 -0
  46. package/templates/runtime/export/video-artifact-export.test.ts +166 -0
  47. package/templates/runtime/export/video-artifact-export.ts +164 -0
  48. package/templates/runtime/export/video-encoding-backend.ts +124 -0
  49. package/templates/runtime/export/video-encoding-policy.test.ts +68 -0
  50. package/templates/runtime/export/video-encoding-policy.ts +86 -0
  51. package/templates/runtime/export/video-frame-schedule.test.ts +58 -0
  52. package/templates/runtime/export/video-frame-schedule.ts +42 -0
  53. package/templates/runtime/index.ts +1 -0
  54. package/templates/runtime/react/app-shell/runtime-public-api-runtime.test.tsx +291 -0
  55. package/templates/runtime/react/app-shell/runtime-public-api-test-support.ts +21 -0
  56. package/templates/runtime/react/app-shell/runtime-public-api.test.tsx +4 -303
  57. package/templates/runtime/react/app-shell/toolcraft-app.scene-export.test.tsx +12 -27
  58. package/templates/runtime/react/app-shell/toolcraft-app.tsx +22 -4
  59. package/templates/runtime/react/app-shell/toolcraft-source-asset-context.test.tsx +16 -57
  60. package/templates/runtime/react/app-shell/toolcraft-source-asset-context.tsx +75 -434
  61. package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.test.ts +194 -0
  62. package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.ts +227 -0
  63. package/templates/runtime/react/app-shell/toolcraft-source-asset-provider-ownership.test.tsx +243 -0
  64. package/templates/runtime/react/app-shell/toolcraft-source-asset-test-support.ts +51 -0
  65. package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.test.ts +138 -0
  66. package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.ts +378 -0
  67. package/templates/runtime/react/controls-panel/__tests__/controls-panel.footer-actions.test.tsx +7 -12
  68. package/templates/runtime/react/controls-panel/__tests__/controls-panel.scene-export-actions.test.tsx +17 -83
  69. package/templates/runtime/react/controls-panel/actions/controls-panel-actions.ts +42 -84
  70. package/templates/runtime/react/controls-panel/actions/export-action-runner.ts +62 -0
  71. package/templates/runtime/react/panel-host/panel-host-runtime-placement.test.tsx +30 -0
  72. package/templates/runtime/react/panel-host/panel-host.tsx +4 -0
  73. package/templates/runtime/scene/scene-bounds.ts +1 -2
  74. package/templates/runtime/schema/canvas-render-scale.ts +62 -0
  75. package/templates/runtime/schema/controls-panel-section-id.test.ts +1 -1
  76. package/templates/runtime/schema/controls-panel-section-roundtrip.test.ts +3 -3
  77. package/templates/runtime/schema/define-toolcraft.setup-background.test.ts +1 -1
  78. package/templates/runtime/schema/define-toolcraft.setup-render-scale-timeline.test.ts +71 -3
  79. package/templates/runtime/schema/define-toolcraft.ts +1 -1
  80. package/templates/runtime/schema/schema-resolvers.ts +0 -56
  81. package/templates/runtime/schema/types.ts +4 -4
  82. package/templates/runtime/state/canvas-background-state.ts +6 -0
  83. package/templates/runtime/testing/performance-render-plan-integration.test.ts +64 -0
  84. package/templates/runtime/testing/performance-renderer-pipeline-validation.ts +18 -1
  85. package/templates/starter/AGENTS.md +8 -7
  86. package/templates/starter/NOTICE.md +6 -0
  87. package/templates/starter/docs/toolcraft/README.md +2 -1
  88. package/templates/starter/docs/toolcraft/acceptance-testing.md +8 -6
  89. package/templates/starter/docs/toolcraft/agent-worklog.md +3 -3
  90. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -3
  91. package/templates/starter/docs/toolcraft/core/media-upload.md +2 -2
  92. package/templates/starter/docs/toolcraft/core/performance.md +14 -3
  93. package/templates/starter/docs/toolcraft/core/runtime-boundary.md +3 -1
  94. package/templates/starter/docs/toolcraft/core/setup-export.md +16 -12
  95. package/templates/starter/docs/toolcraft/core/timeline-animation.md +4 -4
  96. package/templates/starter/docs/toolcraft/performance.md +20 -6
  97. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  98. package/templates/starter/docs/toolcraft/schema-reference.md +2 -6
  99. package/templates/starter/docs/toolcraft/workflow.md +7 -2
  100. package/templates/starter/e2e/app-browser-control-coverage.spec.ts +33 -0
  101. package/templates/starter/e2e/app-browser-infinity-canvas-evidence.spec.ts +43 -1
  102. package/templates/starter/e2e/app-browser-render-scale-evidence.spec.ts +60 -6
  103. package/templates/starter/e2e/app-browser-render-scale-requirements.spec.ts +77 -1
  104. package/templates/starter/e2e/app-browser-runtime-provenance.spec.ts +8 -0
  105. package/templates/starter/e2e/app-browser-runtime-reporter.spec.ts +35 -0
  106. package/templates/starter/e2e/app-browser-runtime-requirements.spec.ts +4 -1
  107. package/templates/starter/e2e/app-browser-semantic-evidence.spec.ts +0 -1
  108. package/templates/starter/e2e/app-browser-shell.spec.ts +13 -5
  109. package/templates/starter/e2e/app-performance-path-adapters.ts +8 -1
  110. package/templates/starter/e2e/app-performance.spec.ts +16 -9
  111. package/templates/starter/e2e/browser-infinity-canvas-evidence.ts +9 -330
  112. package/templates/starter/e2e/browser-infinity-canvas-unavailable-image-evidence.ts +370 -0
  113. package/templates/starter/e2e/browser-media-export-evidence.spec.ts +77 -0
  114. package/templates/starter/e2e/browser-media-export-evidence.ts +208 -0
  115. package/templates/starter/e2e/browser-render-scale-evidence.ts +37 -29
  116. package/templates/starter/e2e/browser-runtime-evidence-reporter.ts +4 -1
  117. package/templates/starter/e2e/browser-runtime-evidence-requirements.ts +43 -9
  118. package/templates/starter/e2e/browser-standard-timeline-evidence.ts +106 -0
  119. package/templates/starter/e2e/decoded-pixel-observation.spec.ts +74 -0
  120. package/templates/starter/e2e/decoded-pixel-observation.ts +227 -0
  121. package/templates/starter/e2e/export-artifact-helpers.ts +206 -25
  122. package/templates/starter/e2e/image-artifact-inspection.spec.ts +41 -0
  123. package/templates/starter/e2e/image-artifact-inspection.ts +99 -0
  124. package/templates/starter/e2e/performance-canvas-helpers.ts +22 -21
  125. package/templates/starter/e2e/performance-canvas-quality-guard.spec.ts +312 -0
  126. package/templates/starter/e2e/performance-canvas-quality-guard.ts +196 -0
  127. package/templates/starter/e2e/performance-canvas-quality-observer.ts +351 -0
  128. package/templates/starter/e2e/performance-control-layout-helpers.ts +2 -2
  129. package/templates/starter/e2e/performance-path-adapter-contract.ts +4 -4
  130. package/templates/starter/e2e/performance-path-helpers.spec.ts +203 -70
  131. package/templates/starter/e2e/performance-path-helpers.ts +210 -91
  132. package/templates/starter/e2e/performance-pipeline-evidence-continuity.spec.ts +76 -0
  133. package/templates/starter/e2e/performance-pipeline-evidence-test-fixtures.ts +11 -2
  134. package/templates/starter/e2e/performance-pipeline-invariants.ts +47 -4
  135. package/templates/starter/e2e/performance-render-scale-runner.spec.ts +140 -38
  136. package/templates/starter/e2e/video-artifact-inspection.spec.ts +64 -0
  137. package/templates/starter/e2e/video-artifact-inspection.ts +281 -0
  138. package/templates/starter/gitignore +1 -0
  139. package/templates/starter/package.json +1 -0
  140. package/templates/starter/scripts/check-toolcraft-docs.mjs +17 -0
  141. package/templates/starter/scripts/toolcraft-delivery-plan-authority.test.mjs +60 -0
  142. package/templates/starter/scripts/toolcraft-delivery-plan-errors.test.mjs +7 -2
  143. package/templates/starter/scripts/toolcraft-delivery-plan.mjs +7 -3
  144. package/templates/starter/scripts/toolcraft-product-boundary-ast.mjs +24 -2
  145. package/templates/starter/scripts/toolcraft-product-boundary.mjs +12 -0
  146. package/templates/starter/scripts/toolcraft-product-boundary.test.mjs +46 -0
  147. package/templates/starter/scripts/toolcraft-product-evidence-boundary.test.mjs +85 -20
  148. package/templates/starter/scripts/toolcraft-product-evidence-import-policy.mjs +43 -0
  149. package/templates/starter/scripts/toolcraft-product-export-boundary.mjs +168 -0
  150. package/templates/starter/scripts/toolcraft-typescript-analysis.d.mts +73 -0
  151. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade-test-helpers.mjs +318 -0
  152. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade.test.mjs +155 -206
  153. package/templates/starter/scripts/toolcraft-verification-inventory.mjs +1 -0
  154. package/templates/starter/scripts/toolcraft-verification-receipt.test.mjs +51 -0
  155. package/templates/starter/scripts/toolcraft-vite-env-activation.test.mjs +128 -59
  156. package/templates/starter/src/app/acceptance/export-artifact-coverage.ts +113 -0
  157. package/templates/starter/src/app/acceptance/output-background-rules.ts +1 -1
  158. package/templates/starter/src/app/acceptance/output-export.ts +13 -2
  159. package/templates/starter/src/app/acceptance/types.ts +7 -0
  160. package/templates/starter/src/app/acceptance/validate-coverage.ts +2 -1
  161. package/templates/starter/src/app/starter-acceptance.export-artifact.test.ts +131 -0
  162. package/templates/starter/src/app/starter-acceptance.keyframes.test.ts +14 -4
  163. package/templates/starter/src/app/starter-acceptance.output-export-rules.test.ts +42 -1
  164. package/templates/starter/src/app/starter-acceptance.ts +2 -0
  165. package/templates/starter/src/app/starter-performance-test-utils.ts +103 -0
  166. package/templates/starter/src/app/starter-performance.gates.test.ts +231 -278
  167. package/templates/starter/src/app/starter-performance.lifecycle.test.ts +261 -0
  168. package/templates/starter/src/app/test-evidence/browser-runtime-contract.ts +2 -0
  169. package/templates/starter/src/routes/index.tsx +1 -0
  170. package/templates/runtime/export/export.ts +0 -166
@@ -1,19 +1,9 @@
1
1
  import type * as React from "react";
2
- import {
3
- cleanup,
4
- fireEvent,
5
- render,
6
- screen,
7
- waitFor,
8
- } from "@testing-library/react";
9
- import { afterEach } from "vitest";
10
- import { describe, expect, expectTypeOf, it, vi } from "vitest";
2
+ import { describe, expect, expectTypeOf, it } from "vitest";
11
3
 
12
4
  import * as ToolcraftRuntime from "../../index";
13
5
  import type { ToolcraftHistoryPatch } from "../../index";
14
6
  import type { ToolcraftRendererPipelineClient } from "../../rendering";
15
- import { defineToolcraft } from "../../schema/define-toolcraft";
16
- import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator";
17
7
  import type { ToolcraftCommand } from "../../state/types";
18
8
  import * as ToolcraftReact from "../index";
19
9
  import {
@@ -32,95 +22,18 @@ import {
32
22
  ToolcraftRoot,
33
23
  type ToolcraftContextValue,
34
24
  } from "./toolcraft-root";
35
- import { ToolcraftSourceAssetCoordinatorContext } from "./toolcraft-source-asset-context";
25
+ import { runtimePublicApiSchema } from "./runtime-public-api-test-support";
36
26
  import { LayersPanel } from "../layers/layers-panel";
37
27
  import { PanelContainer, PanelHost, PanelStage } from "../panel-host/panel-host";
38
28
  import { TimelinePanel } from "../timeline/timeline-panel";
39
29
  import { ToolbarPanel } from "./toolbar-panel";
40
30
  import {
41
- useToolcraft,
42
31
  useToolcraftDispatch,
43
32
  useToolcraftSelector,
44
33
  } from "./use-toolcraft";
45
34
  import { useToolcraftPipeline } from "./use-toolcraft-pipeline";
46
35
  import { useToolcraftPipelinePass } from "./use-toolcraft-pipeline-pass";
47
36
 
48
- const runtimePublicApiSchema = defineToolcraft({
49
- canvas: { enabled: true },
50
- panels: {
51
- controls: {
52
- sections: [
53
- {
54
- controls: {
55
- opacity: {
56
- defaultValue: 75,
57
- target: "selectedLayer.opacity",
58
- type: "slider",
59
- },
60
- },
61
- },
62
- ],
63
- title: "Controls",
64
- },
65
- },
66
- });
67
-
68
- const runtimePublicApiPanelActionSchema = defineToolcraft({
69
- canvas: { enabled: true },
70
- panels: {
71
- controls: {
72
- sections: [
73
- {
74
- actionGroup: "secondary",
75
- controls: {
76
- action: {
77
- actions: [
78
- {
79
- label: "Resolve media",
80
- value: "resolve-media",
81
- variant: "default",
82
- },
83
- ],
84
- target: "panel.actions",
85
- type: "panelActions",
86
- },
87
- },
88
- },
89
- ],
90
- title: "Controls",
91
- },
92
- },
93
- });
94
-
95
- function createRuntimePublicApiSourceAssetCoordinator(
96
- resolveResource?: ToolcraftSourceAssetCoordinator["resolveResource"],
97
- ): ToolcraftSourceAssetCoordinator {
98
- const idleOperation = Object.freeze({
99
- phase: "idle" as const,
100
- target: "source",
101
- });
102
-
103
- return {
104
- cancelTarget: vi.fn(),
105
- clearPresentationFeedback: vi.fn(),
106
- dispose: vi.fn(async () => undefined),
107
- getOperation: () => idleOperation,
108
- getPresentation: () => null,
109
- hydrateBinaryMedia: vi.fn(async () => undefined),
110
- hydrateModels: vi.fn(async () => undefined),
111
- importBatch: vi.fn(async () => ({
112
- assetIds: [] as const,
113
- kind: "committed" as const,
114
- })),
115
- repairModel: vi.fn(async () => ({ kind: "cancelled" as const })),
116
- reportPresentationFeedback: vi.fn(),
117
- ...(resolveResource ? { resolveResource } : {}),
118
- subscribe: () => () => undefined,
119
- };
120
- }
121
-
122
- afterEach(cleanup);
123
-
124
37
  describe("Toolcraft template runtime public API", () => {
125
38
  it("exports selector hooks without exposing the internal store context", () => {
126
39
  expect(ToolcraftRuntime).not.toHaveProperty(
@@ -157,6 +70,7 @@ describe("Toolcraft template runtime public API", () => {
157
70
  expectTypeOf<keyof ToolcraftAppComposition>().toEqualTypeOf<
158
71
  | "canvasContent"
159
72
  | "controlRenderers"
73
+ | "exportRenderer"
160
74
  | "modelPresentation"
161
75
  | "onPanelAction"
162
76
  | "renderDefaultCanvasMedia"
@@ -170,6 +84,7 @@ describe("Toolcraft template runtime public API", () => {
170
84
  | "canvasContent"
171
85
  | "className"
172
86
  | "controlRenderers"
87
+ | "exportRenderer"
173
88
  | "modelPresentation"
174
89
  | "onPanelAction"
175
90
  | "renderDefaultCanvasMedia"
@@ -193,11 +108,8 @@ describe("Toolcraft template runtime public API", () => {
193
108
  expectTypeOf<keyof ToolcraftPanelActionContext>().toEqualTypeOf<
194
109
  | "action"
195
110
  | "dispatch"
196
- | "renderRuntimeSceneToCanvas"
197
111
  | "reportFeedback"
198
- | "renderModelsToCanvas"
199
112
  | "resolveMediaResource"
200
- | "resolveSceneExportFrame"
201
113
  | "rendererPipeline"
202
114
  | "reportProgress"
203
115
  | "state"
@@ -238,63 +150,6 @@ describe("Toolcraft template runtime public API", () => {
238
150
  >().toEqualTypeOf<RuntimePanelKeys | "groupCreation">();
239
151
  });
240
152
 
241
- it("binds the source asset resolver into panel action context", async () => {
242
- const bytes = new Uint8Array([11, 22, 33]);
243
- const resolveResource = vi.fn<
244
- NonNullable<ToolcraftSourceAssetCoordinator["resolveResource"]>
245
- >(async () => bytes);
246
- const coordinator = createRuntimePublicApiSourceAssetCoordinator(
247
- resolveResource,
248
- );
249
- const signal = new AbortController().signal;
250
- let resolvedBytes: Uint8Array | null | undefined;
251
-
252
- render(
253
- <ToolcraftRoot schema={runtimePublicApiPanelActionSchema}>
254
- <ToolcraftSourceAssetCoordinatorContext.Provider value={coordinator}>
255
- <ControlsPanel
256
- framed={false}
257
- onPanelAction={async ({ resolveMediaResource }) => {
258
- resolvedBytes = await resolveMediaResource("media:known", {
259
- signal,
260
- });
261
- }}
262
- />
263
- </ToolcraftSourceAssetCoordinatorContext.Provider>
264
- </ToolcraftRoot>,
265
- );
266
-
267
- fireEvent.click(screen.getByRole("button", { name: "Resolve media" }));
268
-
269
- await waitFor(() => expect(resolvedBytes).toEqual(bytes));
270
- expect(resolveResource).toHaveBeenCalledWith("media:known", { signal });
271
- });
272
-
273
- it("returns null from panel action media resolution when no resolver exists", async () => {
274
- const coordinator = createRuntimePublicApiSourceAssetCoordinator();
275
- const signal = new AbortController().signal;
276
- let resolvedBytes: Uint8Array | null | undefined;
277
-
278
- render(
279
- <ToolcraftRoot schema={runtimePublicApiPanelActionSchema}>
280
- <ToolcraftSourceAssetCoordinatorContext.Provider value={coordinator}>
281
- <ControlsPanel
282
- framed={false}
283
- onPanelAction={async ({ resolveMediaResource }) => {
284
- resolvedBytes = await resolveMediaResource("media:missing", {
285
- signal,
286
- });
287
- }}
288
- />
289
- </ToolcraftSourceAssetCoordinatorContext.Provider>
290
- </ToolcraftRoot>,
291
- );
292
-
293
- fireEvent.click(screen.getByRole("button", { name: "Resolve media" }));
294
-
295
- await waitFor(() => expect(resolvedBytes).toBeNull());
296
- });
297
-
298
153
  it("keeps shell and panel host props separate", () => {
299
154
  expectTypeOf<
300
155
  keyof React.ComponentProps<typeof CanvasShell>
@@ -380,158 +235,4 @@ describe("Toolcraft template runtime public API", () => {
380
235
  expect(toolbarWithThemeToggle).toBeTruthy();
381
236
  });
382
237
 
383
- it("keeps broad consumers live while isolating selector and dispatch consumers", () => {
384
- const renders = {
385
- broad: 0,
386
- dispatch: 0,
387
- playback: 0,
388
- value: 0,
389
- viewport: 0,
390
- };
391
-
392
- function BroadProbe() {
393
- const { state } = useToolcraft();
394
-
395
- renders.broad += 1;
396
-
397
- return <output data-testid="broad-time">{state.timeline.currentTimeSeconds}</output>;
398
- }
399
-
400
- function DispatchProbe() {
401
- const dispatch = useToolcraftDispatch();
402
-
403
- renders.dispatch += 1;
404
-
405
- return (
406
- <button
407
- type="button"
408
- onClick={() =>
409
- dispatch({
410
- target: "selectedLayer.opacity",
411
- type: "controls.setValue",
412
- value: 50,
413
- })
414
- }
415
- >
416
- Set value
417
- </button>
418
- );
419
- }
420
-
421
- function ValueProbe() {
422
- const value = useToolcraftSelector(
423
- (state) => state.values["selectedLayer.opacity"],
424
- );
425
-
426
- renders.value += 1;
427
-
428
- return <output data-testid="selected-value">{String(value)}</output>;
429
- }
430
-
431
- function PlaybackProbe() {
432
- const currentTimeSeconds = useToolcraftSelector(
433
- (state) => state.timeline.currentTimeSeconds,
434
- );
435
-
436
- renders.playback += 1;
437
-
438
- return <output data-testid="playback-time">{currentTimeSeconds}</output>;
439
- }
440
-
441
- function ViewportProbe() {
442
- const offset = useToolcraftSelector(
443
- (state) => ({ ...state.canvas.offset }),
444
- (previous, next) => previous.x === next.x && previous.y === next.y,
445
- );
446
-
447
- renders.viewport += 1;
448
-
449
- return <output data-testid="viewport-offset">{offset.x},{offset.y}</output>;
450
- }
451
-
452
- function TransientProbe() {
453
- const store = useToolcraftStore();
454
-
455
- return (
456
- <div>
457
- <button
458
- type="button"
459
- onClick={() =>
460
- store.dispatchTransient({
461
- currentTimeSeconds: 2,
462
- type: "timeline.setCurrentTime",
463
- })
464
- }
465
- >
466
- Set playback time
467
- </button>
468
- <button
469
- type="button"
470
- onClick={() =>
471
- store.dispatchTransient({
472
- offset: { x: 0, y: 0 },
473
- type: "canvas.setViewport",
474
- zoom: 150,
475
- })
476
- }
477
- >
478
- Set viewport zoom
479
- </button>
480
- </div>
481
- );
482
- }
483
-
484
- render(
485
- <ToolcraftRoot schema={runtimePublicApiSchema}>
486
- <BroadProbe />
487
- <DispatchProbe />
488
- <ValueProbe />
489
- <PlaybackProbe />
490
- <ViewportProbe />
491
- <TransientProbe />
492
- </ToolcraftRoot>,
493
- );
494
-
495
- expect(renders).toEqual({
496
- broad: 1,
497
- dispatch: 1,
498
- playback: 1,
499
- value: 1,
500
- viewport: 1,
501
- });
502
-
503
- fireEvent.click(screen.getByRole("button", { name: "Set value" }));
504
-
505
- expect(screen.getByTestId("selected-value").textContent).toBe("50");
506
- expect(renders).toEqual({
507
- broad: 2,
508
- dispatch: 1,
509
- playback: 1,
510
- value: 2,
511
- viewport: 1,
512
- });
513
-
514
- fireEvent.click(screen.getByRole("button", { name: "Set playback time" }));
515
-
516
- expect(screen.getByTestId("broad-time").textContent).toBe("2");
517
- expect(screen.getByTestId("playback-time").textContent).toBe("2");
518
- expect(renders).toEqual({
519
- broad: 3,
520
- dispatch: 1,
521
- playback: 2,
522
- value: 2,
523
- viewport: 1,
524
- });
525
-
526
- fireEvent.click(screen.getByRole("button", { name: "Set viewport zoom" }));
527
-
528
- expect(screen.getByTestId("viewport-offset").textContent).toBe("0,0");
529
- expect(renders).toEqual({
530
- broad: 4,
531
- dispatch: 1,
532
- playback: 2,
533
- value: 2,
534
- viewport: 1,
535
- });
536
- });
537
238
  });
@@ -1,5 +1,5 @@
1
- import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
3
 
4
4
  import { defineToolcraft } from "../../schema/define-toolcraft";
5
5
  import { ToolcraftApp } from "./toolcraft-app";
@@ -10,7 +10,7 @@ afterEach(() => {
10
10
  });
11
11
 
12
12
  describe("ToolcraftApp scene export boundary", () => {
13
- it("connects product scene bounds directly to infinite export actions", () => {
13
+ it("fails before mount when a typed product export has no shared renderer", () => {
14
14
  const schema = defineToolcraft({
15
15
  canvas: {
16
16
  enabled: true,
@@ -26,6 +26,7 @@ describe("ToolcraftApp scene export boundary", () => {
26
26
  actions: [
27
27
  {
28
28
  label: "Export image",
29
+ role: "export-image",
29
30
  value: "export",
30
31
  variant: "default",
31
32
  },
@@ -40,29 +41,13 @@ describe("ToolcraftApp scene export boundary", () => {
40
41
  },
41
42
  },
42
43
  });
43
- const sceneBoundsProvider = vi.fn(() => [
44
- { height: 40, width: 80, x: -20, y: 10 },
45
- ]);
46
- let frame: unknown;
47
-
48
- render(
49
- <ToolcraftApp
50
- canvasContent={<div data-testid="product-scene" />}
51
- onPanelAction={({ resolveSceneExportFrame }) => {
52
- frame = resolveSceneExportFrame();
53
- }}
54
- sceneBoundsProvider={sceneBoundsProvider}
55
- schema={schema}
56
- />,
57
- );
58
- expect(screen.getByText("Infinity canvas")).toBeTruthy();
59
- fireEvent.click(screen.getByRole("switch"));
60
- fireEvent.click(screen.getByRole("button", { name: "Export image" }));
61
-
62
- expect(sceneBoundsProvider).toHaveBeenCalledTimes(1);
63
- expect(frame).toEqual({
64
- frame: { height: 40, width: 80, x: -20, y: 10 },
65
- ok: true,
66
- });
44
+ expect(() =>
45
+ render(
46
+ <ToolcraftApp
47
+ canvasContent={<div data-testid="product-scene" />}
48
+ schema={schema}
49
+ />,
50
+ ),
51
+ ).toThrow("Product-owned export actions require exportRenderer");
67
52
  });
68
53
  });
@@ -9,6 +9,10 @@ import {
9
9
  type ToolcraftProductSceneBoundsProvider,
10
10
  } from "../../scene";
11
11
  import type { ToolcraftState } from "../../state/types";
12
+ import {
13
+ getToolcraftExportRendererCoverageErrors,
14
+ type ToolcraftProductExportRenderer,
15
+ } from "../../export";
12
16
  import { CanvasShell } from "../canvas/canvas-shell";
13
17
  import {
14
18
  ControlsPanel,
@@ -28,6 +32,7 @@ import { useToolcraftPersistenceStatus } from "./use-toolcraft-persistence";
28
32
  export type ToolcraftAppComposition = {
29
33
  canvasContent?: React.ReactNode;
30
34
  controlRenderers?: ToolcraftControlRendererMap;
35
+ exportRenderer?: ToolcraftProductExportRenderer;
31
36
  modelPresentation?: ToolcraftModelPresentationMode;
32
37
  onPanelAction?: ToolcraftPanelActionHandler;
33
38
  renderDefaultCanvasMedia?: boolean;
@@ -122,6 +127,7 @@ function ToolcraftAppContent({
122
127
 
123
128
  export function ToolcraftApp({
124
129
  canvasContent,
130
+ exportRenderer,
125
131
  modelPresentation,
126
132
  renderDefaultCanvasMedia = true,
127
133
  rendererPipelineRegistration,
@@ -132,12 +138,24 @@ export function ToolcraftApp({
132
138
  const suppressedModelTargets = modelPresentation?.mode === "custom"
133
139
  ? modelPresentation.consumers.map(({ sourceTarget }) => sourceTarget)
134
140
  : [];
141
+ const productSceneRequired =
142
+ React.Children.count(canvasContent) > 0 ||
143
+ rendererPipelineRegistration !== undefined ||
144
+ suppressedModelTargets.length > 0;
145
+ const exportRendererErrors = getToolcraftExportRendererCoverageErrors({
146
+ exportRenderer,
147
+ productSceneRequired,
148
+ schema,
149
+ });
150
+ if (exportRendererErrors.length > 0) {
151
+ throw new Error(
152
+ `Toolcraft export renderer configuration is invalid:\n- ${exportRendererErrors.join("\n- ")}`,
153
+ );
154
+ }
135
155
  const sceneExport: ToolcraftControlsSceneExport = Object.freeze({
136
156
  ...(sceneBoundsProvider ? { boundsProvider: sceneBoundsProvider } : {}),
137
- boundsRequired:
138
- React.Children.count(canvasContent) > 0 ||
139
- rendererPipelineRegistration !== undefined ||
140
- suppressedModelTargets.length > 0,
157
+ ...(exportRenderer ? { exportRenderer } : {}),
158
+ productSceneRequired,
141
159
  visibility: createToolcraftRuntimeSceneVisibility({
142
160
  renderDefaultImages: renderDefaultCanvasMedia,
143
161
  suppressedModelTargets,
@@ -10,66 +10,23 @@ import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source
10
10
  import { useOptionalToolcraftModelRenderHost } from "../model-rendering/model-render-provider";
11
11
  import { ToolcraftModelPresentationModeContext } from "../model-rendering/model-presentation-mode";
12
12
  import { ToolcraftRoot } from "./toolcraft-root";
13
+ import { ToolcraftSourceAssetProvider } from "./toolcraft-source-asset-context";
13
14
  import {
14
- ToolcraftSourceAssetProvider,
15
- useToolcraftSourceAssetCoordinator,
16
- } from "./toolcraft-source-asset-context";
15
+ ToolcraftSourceAssetCoordinatorProbe as CoordinatorProbe,
16
+ createTestSourceAssetCoordinator as createTestCoordinator,
17
+ createTestSourceAssetStore as createTestStore,
18
+ } from "./toolcraft-source-asset-test-support";
17
19
 
18
20
  afterEach(() => {
19
21
  cleanup();
20
22
  vi.unstubAllGlobals();
21
23
  });
22
24
 
23
- function CoordinatorProbe({
24
- capture,
25
- }: {
26
- capture: (coordinator: ToolcraftSourceAssetCoordinator) => void;
27
- }) {
28
- capture(useToolcraftSourceAssetCoordinator());
29
- return null;
30
- }
31
-
32
25
  function ModelRenderHostProbe(): React.JSX.Element {
33
26
  const host = useOptionalToolcraftModelRenderHost();
34
27
  return <span data-testid="model-render-host">{host ? "ready" : "missing"}</span>;
35
28
  }
36
29
 
37
- function createTestCoordinator(
38
- dispose: ToolcraftSourceAssetCoordinator["dispose"] = vi.fn(
39
- async () => undefined,
40
- ),
41
- ): ToolcraftSourceAssetCoordinator {
42
- const idleOperation = Object.freeze({
43
- phase: "idle" as const,
44
- target: "source.test",
45
- });
46
-
47
- return {
48
- cancelTarget: vi.fn(),
49
- clearPresentationFeedback: vi.fn(),
50
- dispose,
51
- getOperation: () => idleOperation,
52
- getPresentation: () => null,
53
- hydrateBinaryMedia: vi.fn(async () => undefined),
54
- hydrateModels: vi.fn(async () => undefined),
55
- importBatch: vi.fn(async () => ({
56
- assetIds: [] as const,
57
- kind: "committed" as const,
58
- })),
59
- repairModel: vi.fn(async () => ({ kind: "cancelled" as const })),
60
- reportPresentationFeedback: vi.fn(),
61
- subscribe: () => () => undefined,
62
- };
63
- }
64
-
65
- function createTestStore() {
66
- return createToolcraftExternalStore(
67
- createToolcraftState(
68
- defineToolcraft({ canvas: { enabled: true, upload: true }, panels: {} }),
69
- ),
70
- );
71
- }
72
-
73
30
  describe("Toolcraft source asset provider", () => {
74
31
  it("shares one app-owned coordinator and disposes it on unmount", async () => {
75
32
  const schema = defineToolcraft({
@@ -108,6 +65,7 @@ describe("Toolcraft source asset provider", () => {
108
65
  store={store}
109
66
  >
110
67
  <CoordinatorProbe capture={(value) => coordinators.push(value)} />
68
+ <span data-testid="strict-source-asset-child" />
111
69
  </ToolcraftSourceAssetProvider>
112
70
  </React.StrictMode>,
113
71
  );
@@ -116,6 +74,7 @@ describe("Toolcraft source asset provider", () => {
116
74
  expect(createCoordinator).toHaveBeenCalledWith(store);
117
75
  expect(coordinators.length).toBeGreaterThanOrEqual(2);
118
76
  expect(coordinators.every((value) => value === coordinator)).toBe(true);
77
+ expect(rendered.getByTestId("strict-source-asset-child")).toBeDefined();
119
78
 
120
79
  rendered.unmount();
121
80
  await waitFor(() => expect(coordinator.dispose).toHaveBeenCalledTimes(1));
@@ -420,16 +379,16 @@ describe("Toolcraft source asset provider", () => {
420
379
  [new Error("rollback failed")],
421
380
  "cleanup failed",
422
381
  );
423
- const coordinator = createTestCoordinator(
424
- vi.fn(async () => Promise.reject(failure)),
425
- );
426
- const onDisposeError = vi.fn();
382
+ const coordinator = createTestCoordinator({
383
+ dispose: vi.fn(async () => Promise.reject(failure)),
384
+ });
385
+ const onError = vi.fn();
427
386
  const onUnhandledRejection = vi.fn();
428
387
  window.addEventListener("unhandledrejection", onUnhandledRejection);
429
388
  const rendered = render(
430
389
  <ToolcraftSourceAssetProvider
431
390
  createCoordinator={() => coordinator}
432
- onDisposeError={onDisposeError}
391
+ onError={onError}
433
392
  store={store}
434
393
  >
435
394
  <CoordinatorProbe capture={() => undefined} />
@@ -437,7 +396,7 @@ describe("Toolcraft source asset provider", () => {
437
396
  );
438
397
 
439
398
  rendered.unmount();
440
- await waitFor(() => expect(onDisposeError).toHaveBeenCalledWith(failure));
399
+ await waitFor(() => expect(onError).toHaveBeenCalledWith(failure));
441
400
  await Promise.resolve();
442
401
  window.removeEventListener("unhandledrejection", onUnhandledRejection);
443
402
 
@@ -448,9 +407,9 @@ describe("Toolcraft source asset provider", () => {
448
407
  const failure = new Error("repository owner disposal failed");
449
408
  const reportError = vi.fn();
450
409
  vi.stubGlobal("reportError", reportError);
451
- const coordinator = createTestCoordinator(
452
- vi.fn(async () => Promise.reject(failure)),
453
- );
410
+ const coordinator = createTestCoordinator({
411
+ dispose: vi.fn(async () => Promise.reject(failure)),
412
+ });
454
413
  const rendered = render(
455
414
  <ToolcraftSourceAssetProvider
456
415
  createCoordinator={() => coordinator}