@pixel-point/toolcraft 0.0.17 → 0.0.18

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 (196) hide show
  1. package/package.json +1 -1
  2. package/src/generate-test-doc-assertions.mjs +58 -68
  3. package/src/generate-test-doc-assertions.test.mjs +43 -0
  4. package/src/generate-test-doc-policy-assertions.mjs +92 -0
  5. package/src/generate-test-file-assertions.mjs +26 -75
  6. package/src/generate-test-product-iteration.test.mjs +17 -0
  7. package/src/generate-test-runtime-boundary-assertions.mjs +158 -0
  8. package/src/generate-test-standalone-assertions.mjs +10 -0
  9. package/templates/runtime/contracts/component-contracts.choices.test.ts +47 -3
  10. package/templates/runtime/contracts/component-contracts.choices.ts +48 -3
  11. package/templates/runtime/contracts/component-contracts.inputs.test.ts +6 -6
  12. package/templates/runtime/contracts/component-contracts.inputs.ts +6 -6
  13. package/templates/runtime/contracts/component-contracts.media-custom.test.ts +8 -2
  14. package/templates/runtime/contracts/component-contracts.media-custom.ts +8 -2
  15. package/templates/runtime/contracts/component-contracts.runtime-acceptance.test.ts +4 -4
  16. package/templates/runtime/contracts/component-contracts.runtime-labels.test.ts +7 -1
  17. package/templates/runtime/contracts/component-contracts.runtime-panels.test.ts +3 -0
  18. package/templates/runtime/contracts/component-contracts.runtime-shell.test.ts +3 -0
  19. package/templates/runtime/contracts/component-contracts.runtime.ts +12 -8
  20. package/templates/runtime/contracts/component-contracts.test.ts +2 -0
  21. package/templates/runtime/contracts/decision-contracts.test.ts +18 -0
  22. package/templates/runtime/contracts/decision-contracts.ts +10 -10
  23. package/templates/runtime/export/export-background.ts +0 -19
  24. package/templates/runtime/export/export.test.ts +45 -42
  25. package/templates/runtime/export/index.ts +44 -16
  26. package/templates/runtime/index.ts +2 -0
  27. package/templates/runtime/model-import/model-import-limits.ts +15 -4
  28. package/templates/runtime/react/app-shell/runtime-public-api.test.tsx +11 -1
  29. package/templates/runtime/react/app-shell/toolcraft-app.scene-export.test.tsx +29 -0
  30. package/templates/runtime/react/app-shell/toolcraft-app.tsx +18 -8
  31. package/templates/runtime/react/canvas/canvas-shell-rendering.test.tsx +76 -0
  32. package/templates/runtime/react/canvas/canvas-shell-test-utils.tsx +13 -1
  33. package/templates/runtime/react/canvas/canvas-shell.tsx +16 -1
  34. package/templates/runtime/react/canvas/product-scene-surface.test.tsx +195 -0
  35. package/templates/runtime/react/canvas/product-scene-surface.tsx +155 -0
  36. package/templates/runtime/react/canvas/runtime-scene-export.ts +1 -1
  37. package/templates/runtime/react/controls-panel/__tests__/control-conditions.test.ts +61 -0
  38. package/templates/runtime/react/controls-panel/__tests__/controls-panel.collection-actions.test.tsx +89 -0
  39. package/templates/runtime/react/controls-panel/__tests__/controls-panel.control-visibility.test.tsx +40 -19
  40. package/templates/runtime/react/controls-panel/__tests__/controls-panel.scene-export-actions.test.tsx +1 -1
  41. package/templates/runtime/react/controls-panel/__tests__/controls-panel.source-collection.test.tsx +62 -0
  42. package/templates/runtime/react/controls-panel/__tests__/controls-panel.source-color-bank.test.tsx +67 -0
  43. package/templates/runtime/react/controls-panel/actions/controls-panel-actions.ts +2 -4
  44. package/templates/runtime/react/controls-panel/actions/export-action-runner.ts +10 -4
  45. package/templates/runtime/react/controls-panel/conditions/control-conditions.ts +32 -119
  46. package/templates/runtime/react/controls-panel/layout/controls-panel-control-group.tsx +5 -2
  47. package/templates/runtime/react/controls-panel/layout/controls-panel-layout.tsx +10 -8
  48. package/templates/runtime/react/controls-panel/layout/controls-panel-section.tsx +3 -3
  49. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-item-fields.test.tsx +63 -0
  50. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-item-fields.tsx +138 -0
  51. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-items.tsx +104 -0
  52. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-renderer.tsx +115 -236
  53. package/templates/runtime/react/controls-panel/renderers/controls-panel-file-drop-item-controls.test.tsx +95 -0
  54. package/templates/runtime/react/controls-panel/renderers/controls-panel-file-drop-item-controls.tsx +82 -0
  55. package/templates/runtime/react/controls-panel/renderers/controls-panel-media-renderer.test.tsx +73 -1
  56. package/templates/runtime/react/controls-panel/renderers/controls-panel-media-renderer.tsx +50 -1
  57. package/templates/runtime/react/controls-panel/renderers/controls-panel-renderer-registry.test.ts +1 -1
  58. package/templates/runtime/react/controls-panel/renderers/controls-panel-renderer-registry.ts +2 -1
  59. package/templates/runtime/react/controls-panel/values/controls-panel-collection-values.ts +38 -6
  60. package/templates/runtime/react/controls-panel/values/controls-panel-value-labels.ts +1 -0
  61. package/templates/runtime/react/index.ts +4 -0
  62. package/templates/runtime/react/model-rendering/model-export.ts +1 -1
  63. package/templates/runtime/react/orientation-gizmo/orientation-gizmo-selection.ts +5 -5
  64. package/templates/runtime/react/orientation-gizmo/use-toolcraft-orientation-control-selection.ts +2 -2
  65. package/templates/runtime/scene/scene-bounds.test.ts +23 -0
  66. package/templates/runtime/scene/scene-bounds.ts +31 -17
  67. package/templates/runtime/schema/app-capabilities.test.ts +4 -0
  68. package/templates/runtime/schema/collection-item-controls.ts +29 -0
  69. package/templates/runtime/schema/control-applicability.test.ts +127 -0
  70. package/templates/runtime/schema/control-applicability.ts +433 -0
  71. package/templates/runtime/schema/control-schema-normalization.ts +177 -11
  72. package/templates/runtime/schema/controls-panel-actions.ts +2 -2
  73. package/templates/runtime/schema/controls-panel-section-layout.ts +14 -9
  74. package/templates/runtime/schema/controls-panel-section-roundtrip.test.ts +59 -0
  75. package/templates/runtime/schema/define-toolcraft.collection-actions-item-controls.test.ts +127 -0
  76. package/templates/runtime/schema/define-toolcraft.control-sections.test.ts +10 -4
  77. package/templates/runtime/schema/define-toolcraft.file-drop-item-controls.test.ts +98 -0
  78. package/templates/runtime/schema/define-toolcraft.file-drop-normalization.test.ts +27 -0
  79. package/templates/runtime/schema/define-toolcraft.segmented-control-fit.test.ts +20 -2
  80. package/templates/runtime/schema/define-toolcraft.setup-canvas.test.ts +16 -3
  81. package/templates/runtime/schema/define-toolcraft.source-collection.test.ts +79 -0
  82. package/templates/runtime/schema/runtime-setup-section.ts +24 -5
  83. package/templates/runtime/schema/types.ts +68 -13
  84. package/templates/starter/AGENTS.md +12 -10
  85. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -31
  86. package/templates/starter/docs/toolcraft/agent-worklog.md +15 -0
  87. package/templates/starter/docs/toolcraft/assembly-workflow.md +10 -3
  88. package/templates/starter/docs/toolcraft/component-rules.md +8 -6
  89. package/templates/starter/docs/toolcraft/core/control-selection.md +9 -3
  90. package/templates/starter/docs/toolcraft/core/layout.md +7 -7
  91. package/templates/starter/docs/toolcraft/core/media-upload.md +2 -0
  92. package/templates/starter/docs/toolcraft/core/runtime-boundary.md +13 -2
  93. package/templates/starter/docs/toolcraft/core/setup-export.md +33 -7
  94. package/templates/starter/docs/toolcraft/custom-controls.md +6 -4
  95. package/templates/starter/docs/toolcraft/decision-contract.md +3 -1
  96. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  97. package/templates/starter/docs/toolcraft/schema-reference.md +21 -19
  98. package/templates/starter/e2e/app-browser-control-applicability-requirements.spec.ts +163 -0
  99. package/templates/starter/e2e/app-browser-infinity-canvas-evidence.spec.ts +7 -2
  100. package/templates/starter/e2e/app-browser-orientation-evidence.spec.ts +174 -21
  101. package/templates/starter/e2e/app-browser-runtime-requirements.spec.ts +1 -33
  102. package/templates/starter/e2e/app-browser-semantic-evidence.spec.ts +61 -15
  103. package/templates/starter/e2e/{browser-conditional-output-evidence-helpers.ts → browser-background-output-evidence.ts} +0 -68
  104. package/templates/starter/e2e/browser-control-applicability-evidence.ts +114 -0
  105. package/templates/starter/e2e/browser-infinity-canvas-evidence.ts +20 -11
  106. package/templates/starter/e2e/browser-orientation-gizmo-evidence-helpers.ts +4 -0
  107. package/templates/starter/e2e/browser-orientation-gizmo-live-preconditions.ts +86 -0
  108. package/templates/starter/e2e/browser-runtime-applicability-requirements.ts +84 -0
  109. package/templates/starter/e2e/browser-runtime-evidence-reporter.ts +9 -2
  110. package/templates/starter/e2e/browser-runtime-evidence-requirements.ts +29 -26
  111. package/templates/starter/e2e/browser-semantic-evidence-test-helpers.ts +25 -0
  112. package/templates/starter/scripts/toolcraft-contract-manifest.json +1 -0
  113. package/templates/starter/scripts/toolcraft-product-boundary-ast.mjs +85 -115
  114. package/templates/starter/scripts/toolcraft-product-boundary-module-policy.mjs +101 -0
  115. package/templates/starter/scripts/toolcraft-product-boundary.test.mjs +0 -72
  116. package/templates/starter/scripts/toolcraft-product-control-bindings.mjs +182 -0
  117. package/templates/starter/scripts/toolcraft-product-control-boundary.mjs +146 -0
  118. package/templates/starter/scripts/toolcraft-product-control-boundary.test.mjs +186 -0
  119. package/templates/starter/scripts/toolcraft-product-export-boundary.mjs +6 -17
  120. package/templates/starter/scripts/toolcraft-product-export-boundary.test.mjs +196 -0
  121. package/templates/starter/scripts/toolcraft-typescript-source-evidence-structure.test.mjs +20 -0
  122. package/templates/starter/scripts/toolcraft-typescript-source-evidence.mjs +1 -0
  123. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade.test.mjs +2 -0
  124. package/templates/starter/src/app/acceptance/artifact-export-intent.ts +130 -0
  125. package/templates/starter/src/app/acceptance/conditions.ts +1 -1
  126. package/templates/starter/src/app/acceptance/control-acceptance-context.ts +2 -2
  127. package/templates/starter/src/app/acceptance/control-acceptance-coverage.ts +6 -20
  128. package/templates/starter/src/app/acceptance/control-acceptance-kind-rules.ts +24 -5
  129. package/templates/starter/src/app/acceptance/control-acceptance-policy.ts +2 -13
  130. package/templates/starter/src/app/acceptance/control-applicability-cases.test.ts +321 -0
  131. package/templates/starter/src/app/acceptance/control-applicability-cases.ts +290 -0
  132. package/templates/starter/src/app/acceptance/control-applicability.ts +332 -0
  133. package/templates/starter/src/app/acceptance/control-layout-dependency-rules.ts +20 -9
  134. package/templates/starter/src/app/acceptance/control-layout-entity-rules.ts +4 -6
  135. package/templates/starter/src/app/acceptance/control-layout-model.ts +4 -4
  136. package/templates/starter/src/app/acceptance/control-layout-section-rules.ts +3 -10
  137. package/templates/starter/src/app/acceptance/control-parts.ts +8 -0
  138. package/templates/starter/src/app/acceptance/control-section-entity-cohesion.test.ts +215 -0
  139. package/templates/starter/src/app/acceptance/control-section-entity-cohesion.ts +132 -0
  140. package/templates/starter/src/app/acceptance/control-section-inventory.test.ts +87 -0
  141. package/templates/starter/src/app/acceptance/control-section-inventory.ts +74 -44
  142. package/templates/starter/src/app/acceptance/custom-controls.ts +11 -3
  143. package/templates/starter/src/app/acceptance/inline-layout.ts +64 -9
  144. package/templates/starter/src/app/acceptance/media-upload.ts +5 -1
  145. package/templates/starter/src/app/acceptance/orientation-gizmo.ts +18 -11
  146. package/templates/starter/src/app/acceptance/output-background-rules.ts +2 -2
  147. package/templates/starter/src/app/acceptance/output-export-layout-rules.ts +18 -10
  148. package/templates/starter/src/app/acceptance/output-export-model.ts +54 -11
  149. package/templates/starter/src/app/acceptance/output-export.ts +22 -10
  150. package/templates/starter/src/app/acceptance/output-video-export-rules.ts +99 -0
  151. package/templates/starter/src/app/acceptance/types.ts +23 -8
  152. package/templates/starter/src/app/acceptance/validate-coverage.ts +19 -2
  153. package/templates/starter/src/app/starter-acceptance.artifact-export-intent.test.ts +239 -0
  154. package/templates/starter/src/app/starter-acceptance.background-export.test.ts +33 -0
  155. package/templates/starter/src/app/starter-acceptance.canvas-sizing.test.ts +16 -1
  156. package/templates/starter/src/app/starter-acceptance.compound-part-coverage.test.ts +65 -0
  157. package/templates/starter/src/app/starter-acceptance.contract-fixtures.test.ts +41 -0
  158. package/templates/starter/src/app/starter-acceptance.contract-fixtures.ts +112 -16
  159. package/templates/starter/src/app/starter-acceptance.control-applicability.test.ts +224 -0
  160. package/templates/starter/src/app/starter-acceptance.control-order.test.ts +58 -3
  161. package/templates/starter/src/app/starter-acceptance.control-state.test.ts +0 -111
  162. package/templates/starter/src/app/starter-acceptance.custom-control-collections.test.ts +3 -1
  163. package/templates/starter/src/app/starter-acceptance.custom-control-valid.test.ts +1 -1
  164. package/templates/starter/src/app/starter-acceptance.export-test-utils.ts +22 -0
  165. package/templates/starter/src/app/{starter-acceptance.image-video-export-settings.test.ts → starter-acceptance.image-export-settings.test.ts} +37 -9
  166. package/templates/starter/src/app/starter-acceptance.interaction-ownership.test.ts +12 -0
  167. package/templates/starter/src/app/starter-acceptance.media-upload.fixtures.ts +11 -1
  168. package/templates/starter/src/app/starter-acceptance.media-upload.test.ts +28 -0
  169. package/templates/starter/src/app/starter-acceptance.output-export-intent.test.ts +384 -0
  170. package/templates/starter/src/app/starter-acceptance.output-export-rules.test.ts +4 -0
  171. package/templates/starter/src/app/starter-acceptance.product-output-export.test.ts +112 -105
  172. package/templates/starter/src/app/starter-acceptance.product-readiness.test.ts +12 -0
  173. package/templates/starter/src/app/starter-acceptance.section-cohesion.test.ts +1 -1
  174. package/templates/starter/src/app/starter-acceptance.section-dependencies.test.ts +35 -24
  175. package/templates/starter/src/app/starter-acceptance.section-size.test.ts +72 -0
  176. package/templates/starter/src/app/starter-acceptance.ts +11 -2
  177. package/templates/starter/src/app/starter-acceptance.video-export-settings.test.ts +336 -0
  178. package/templates/starter/src/app/starter-acceptance.view-interaction.test.ts +8 -0
  179. package/templates/starter/src/app/test-evidence/browser-runtime-contract.ts +2 -2
  180. package/templates/starter/src/routes/index.tsx +1 -0
  181. package/templates/ui/components/composites/tabs.tsx +2 -2
  182. package/templates/ui/components/controls/collection-actions/collection-item-groups.test.tsx +38 -0
  183. package/templates/ui/components/controls/collection-actions/collection-item-groups.tsx +36 -0
  184. package/templates/ui/components/controls/collection-actions/index.ts +2 -0
  185. package/templates/ui/components/controls/file-drop/file-drop-collection-control.tsx +137 -0
  186. package/templates/ui/components/controls/file-drop/file-drop-control.test.tsx +189 -0
  187. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +81 -88
  188. package/templates/ui/components/controls/file-drop/file-drop-presentation.tsx +131 -9
  189. package/templates/ui/components/controls/file-drop/file-drop-types.ts +36 -22
  190. package/templates/ui/components/controls/file-drop/index.ts +1 -0
  191. package/templates/ui/components/controls/index.ts +3 -0
  192. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +1 -1
  193. package/templates/ui/components/controls/range-slider/range-slider-value.test.ts +14 -0
  194. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -2
  195. package/templates/starter/src/app/acceptance/condition-exclusivity.test.ts +0 -60
  196. package/templates/starter/src/app/acceptance/condition-exclusivity.ts +0 -221
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ getToolcraftControlSectionEntityCohesionErrors,
5
+ hasToolcraftInventoryEntityAgreement,
6
+ } from "./control-section-entity-cohesion";
7
+ import type { ToolcraftControlSectionInventoryEntry } from "./types";
8
+
9
+ function makeSection({
10
+ count,
11
+ entity = "Renderable object",
12
+ entityId = "renderable-object",
13
+ id = "object",
14
+ splitReason,
15
+ workflowStage,
16
+ }: {
17
+ count: number;
18
+ entity?: string;
19
+ entityId?: string;
20
+ id?: string;
21
+ splitReason?: string;
22
+ workflowStage?: string;
23
+ }): ToolcraftControlSectionInventoryEntry {
24
+ return {
25
+ entity,
26
+ entityId,
27
+ groupingReason: `The ${id} controls edit one coherent part of the entity.`,
28
+ id,
29
+ splitReason,
30
+ targets: Array.from(
31
+ { length: count },
32
+ (_, index) => `${id}.value${index + 1}`,
33
+ ),
34
+ title: id,
35
+ workflowStage,
36
+ };
37
+ }
38
+
39
+ describe("Toolcraft control section entity cohesion", () => {
40
+ it.each([1, 7, 8, 10])(
41
+ "accepts one entity in one section with %s controls",
42
+ (count) => {
43
+ expect(
44
+ getToolcraftControlSectionEntityCohesionErrors([
45
+ makeSection({ count }),
46
+ ]),
47
+ ).toEqual([]);
48
+ },
49
+ );
50
+
51
+ it("rejects splitting an entity that fits in one section", () => {
52
+ const errors = getToolcraftControlSectionEntityCohesionErrors([
53
+ makeSection({ count: 3, id: "object-source" }),
54
+ makeSection({ count: 4, id: "object-style" }),
55
+ ]);
56
+
57
+ expect(errors).toContain(
58
+ 'Control Section Inventory entity "renderable-object" owns 7 controls across 2 sections (object-source, object-style). Entities with 10 or fewer controls must stay in one section.',
59
+ );
60
+ });
61
+
62
+ it("rejects an entity above the hard maximum left in one section", () => {
63
+ expect(
64
+ getToolcraftControlSectionEntityCohesionErrors([
65
+ makeSection({ count: 11 }),
66
+ ]),
67
+ ).toContain(
68
+ 'Control Section Inventory entity "renderable-object" owns 11 controls in section object. Split entities above 10 controls into explicit workflow stages.',
69
+ );
70
+ });
71
+
72
+ it("accepts a balanced large-entity workflow split", () => {
73
+ expect(
74
+ getToolcraftControlSectionEntityCohesionErrors([
75
+ makeSection({
76
+ count: 5,
77
+ id: "object-structure",
78
+ splitReason: "Structure is authored before the finishing workflow.",
79
+ workflowStage: "structure",
80
+ }),
81
+ makeSection({
82
+ count: 6,
83
+ id: "object-finish",
84
+ splitReason: "Finish is authored after the structural workflow.",
85
+ workflowStage: "finish",
86
+ }),
87
+ ]),
88
+ ).toEqual([]);
89
+ });
90
+
91
+ it("rejects a one-control tail in a split entity", () => {
92
+ expect(
93
+ getToolcraftControlSectionEntityCohesionErrors([
94
+ makeSection({
95
+ count: 10,
96
+ id: "object-primary",
97
+ splitReason: "Primary editing precedes the final workflow stage.",
98
+ workflowStage: "primary",
99
+ }),
100
+ makeSection({
101
+ count: 1,
102
+ id: "object-final",
103
+ splitReason: "The final workflow follows primary editing.",
104
+ workflowStage: "final",
105
+ }),
106
+ ]),
107
+ ).toContain(
108
+ 'Control Section Inventory entity "renderable-object" section object-final owns 1 control. Every section in a split entity must own between 2 and 10 controls.',
109
+ );
110
+ });
111
+
112
+ it("rejects a split section above the per-section maximum", () => {
113
+ expect(
114
+ getToolcraftControlSectionEntityCohesionErrors([
115
+ makeSection({
116
+ count: 11,
117
+ id: "object-primary",
118
+ splitReason: "Primary editing precedes the final workflow stage.",
119
+ workflowStage: "primary",
120
+ }),
121
+ makeSection({
122
+ count: 2,
123
+ id: "object-final",
124
+ splitReason: "The final workflow follows primary editing.",
125
+ workflowStage: "final",
126
+ }),
127
+ ]),
128
+ ).toContain(
129
+ 'Control Section Inventory entity "renderable-object" section object-primary owns 11 controls. Every section in a split entity must own between 2 and 10 controls.',
130
+ );
131
+ });
132
+
133
+ it("requires a workflow stage on every section of a split entity", () => {
134
+ expect(
135
+ getToolcraftControlSectionEntityCohesionErrors([
136
+ makeSection({
137
+ count: 6,
138
+ id: "object-primary",
139
+ splitReason: "Primary editing precedes the final workflow stage.",
140
+ }),
141
+ makeSection({
142
+ count: 6,
143
+ id: "object-final",
144
+ splitReason: "The final workflow follows primary editing.",
145
+ workflowStage: "final",
146
+ }),
147
+ ]),
148
+ ).toContain(
149
+ 'Control Section Inventory entity "renderable-object" section object-primary must declare workflowStage because the entity is split across sections.',
150
+ );
151
+ });
152
+
153
+ it("requires complete, unique split evidence and a consistent entity name", () => {
154
+ const errors = getToolcraftControlSectionEntityCohesionErrors([
155
+ makeSection({ count: 6, id: "object-a", workflowStage: "edit" }),
156
+ makeSection({
157
+ count: 6,
158
+ entity: "Different label",
159
+ id: "object-b",
160
+ splitReason: "This section follows the first editing stage.",
161
+ workflowStage: "edit",
162
+ }),
163
+ ]);
164
+
165
+ expect(errors).toEqual(
166
+ expect.arrayContaining([
167
+ expect.stringContaining("uses inconsistent entity names"),
168
+ expect.stringContaining("object-a must declare splitReason"),
169
+ expect.stringContaining('repeats workflowStage "edit"'),
170
+ ]),
171
+ );
172
+ });
173
+
174
+ it("keeps independent entities independent", () => {
175
+ expect(
176
+ getToolcraftControlSectionEntityCohesionErrors([
177
+ makeSection({ count: 4, entityId: "entity-a", id: "entity-a" }),
178
+ makeSection({ count: 4, entityId: "entity-b", id: "entity-b" }),
179
+ ]),
180
+ ).toEqual([]);
181
+ });
182
+
183
+ it("requires one shared entityId for target-prefix corroboration", () => {
184
+ const first = makeSection({ count: 6, id: "first" });
185
+ const second = makeSection({
186
+ count: 6,
187
+ entityId: "different-entity",
188
+ id: "second",
189
+ });
190
+ const sectionInventoryById = new Map([
191
+ [first.id, first],
192
+ [second.id, second],
193
+ ]);
194
+ const sections = new Set([first.id, second.id]);
195
+
196
+ expect(
197
+ hasToolcraftInventoryEntityAgreement({
198
+ sectionInventoryById,
199
+ sections,
200
+ }),
201
+ ).toBe(false);
202
+
203
+ sectionInventoryById.set(second.id, {
204
+ ...second,
205
+ entityId: first.entityId,
206
+ });
207
+
208
+ expect(
209
+ hasToolcraftInventoryEntityAgreement({
210
+ sectionInventoryById,
211
+ sections,
212
+ }),
213
+ ).toBe(true);
214
+ });
215
+ });
@@ -0,0 +1,132 @@
1
+ import type { ToolcraftControlSectionInventoryEntry } from "./types";
2
+
3
+ export const TOOLCRAFT_DENSE_SECTION_MIN_CONTROLS = 8;
4
+
5
+ const TOOLCRAFT_MAX_CONTROLS_PER_SECTION = 10;
6
+ const TOOLCRAFT_MIN_CONTROLS_PER_SPLIT_SECTION = 2;
7
+ const TOOLCRAFT_MIN_SPLIT_REASON_LENGTH = 12;
8
+
9
+ export function hasToolcraftInventoryEntityAgreement({
10
+ sectionInventoryById,
11
+ sections,
12
+ }: {
13
+ sectionInventoryById: ReadonlyMap<
14
+ string,
15
+ ToolcraftControlSectionInventoryEntry
16
+ >;
17
+ sections: ReadonlySet<string>;
18
+ }): boolean {
19
+ const entries = [...sections].map((sectionId) =>
20
+ sectionInventoryById.get(sectionId),
21
+ );
22
+ const entityIds = entries.map((entry) => entry?.entityId?.trim() ?? "");
23
+
24
+ return entityIds.every(Boolean) && new Set(entityIds).size === 1;
25
+ }
26
+
27
+ export function getToolcraftControlSectionEntityCohesionErrors(
28
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
29
+ ): string[] {
30
+ const errors: string[] = [];
31
+ const entriesByEntityId = new Map<
32
+ string,
33
+ ToolcraftControlSectionInventoryEntry[]
34
+ >();
35
+
36
+ for (const entry of sectionInventory) {
37
+ const entityId = entry.entityId?.trim() ?? "";
38
+ const entity = entry.entity?.trim() ?? "";
39
+
40
+ if (
41
+ !entityId ||
42
+ !entity ||
43
+ (entry.id?.trim().length ?? 0) === 0 ||
44
+ !Array.isArray(entry.targets) ||
45
+ entry.targets.length === 0
46
+ ) {
47
+ continue;
48
+ }
49
+
50
+ const entries = entriesByEntityId.get(entityId) ?? [];
51
+ entries.push(entry);
52
+ entriesByEntityId.set(entityId, entries);
53
+ }
54
+
55
+ for (const [entityId, entries] of entriesByEntityId) {
56
+ const controlCount = entries.reduce(
57
+ (total, entry) => total + entry.targets.length,
58
+ 0,
59
+ );
60
+ const sectionNames = entries.map((entry) => entry.title.trim() || entry.id);
61
+ const entityNames = new Set(entries.map((entry) => entry.entity.trim()));
62
+
63
+ if (entityNames.size > 1) {
64
+ errors.push(
65
+ `Control Section Inventory entity "${entityId}" uses inconsistent entity names: ${[
66
+ ...entityNames,
67
+ ].join(", ")}. Reuse one human-readable entity name for every section in the entity.`,
68
+ );
69
+ }
70
+
71
+ if (controlCount <= TOOLCRAFT_MAX_CONTROLS_PER_SECTION) {
72
+ if (entries.length > 1) {
73
+ errors.push(
74
+ `Control Section Inventory entity "${entityId}" owns ${controlCount} controls across ${entries.length} sections (${sectionNames.join(", ")}). Entities with ${TOOLCRAFT_MAX_CONTROLS_PER_SECTION} or fewer controls must stay in one section.`,
75
+ );
76
+ }
77
+ continue;
78
+ }
79
+
80
+ if (entries.length === 1) {
81
+ errors.push(
82
+ `Control Section Inventory entity "${entityId}" owns ${controlCount} controls in section ${sectionNames[0]}. Split entities above ${TOOLCRAFT_MAX_CONTROLS_PER_SECTION} controls into explicit workflow stages.`,
83
+ );
84
+ continue;
85
+ }
86
+
87
+ const sectionsByStage = new Map<string, string[]>();
88
+
89
+ for (const entry of entries) {
90
+ const sectionName = entry.title.trim() || entry.id;
91
+ const sectionControlCount = entry.targets.length;
92
+ const workflowStage = entry.workflowStage?.trim() ?? "";
93
+ const splitReason = entry.splitReason?.trim() ?? "";
94
+
95
+ if (
96
+ sectionControlCount < TOOLCRAFT_MIN_CONTROLS_PER_SPLIT_SECTION ||
97
+ sectionControlCount > TOOLCRAFT_MAX_CONTROLS_PER_SECTION
98
+ ) {
99
+ errors.push(
100
+ `Control Section Inventory entity "${entityId}" section ${sectionName} owns ${sectionControlCount} ${sectionControlCount === 1 ? "control" : "controls"}. Every section in a split entity must own between ${TOOLCRAFT_MIN_CONTROLS_PER_SPLIT_SECTION} and ${TOOLCRAFT_MAX_CONTROLS_PER_SECTION} controls.`,
101
+ );
102
+ }
103
+
104
+ if (!workflowStage) {
105
+ errors.push(
106
+ `Control Section Inventory entity "${entityId}" section ${sectionName} must declare workflowStage because the entity is split across sections.`,
107
+ );
108
+ } else {
109
+ const normalizedStage = workflowStage.toLowerCase();
110
+ const stageSections = sectionsByStage.get(normalizedStage) ?? [];
111
+ stageSections.push(sectionName);
112
+ sectionsByStage.set(normalizedStage, stageSections);
113
+ }
114
+
115
+ if (splitReason.length < TOOLCRAFT_MIN_SPLIT_REASON_LENGTH) {
116
+ errors.push(
117
+ `Control Section Inventory entity "${entityId}" section ${sectionName} must declare splitReason of at least ${TOOLCRAFT_MIN_SPLIT_REASON_LENGTH} characters because the entity is split across sections.`,
118
+ );
119
+ }
120
+ }
121
+
122
+ for (const [stage, stageSections] of sectionsByStage) {
123
+ if (stageSections.length > 1) {
124
+ errors.push(
125
+ `Control Section Inventory entity "${entityId}" repeats workflowStage "${stage}" across sections ${stageSections.join(", ")}. Workflow stages must be unique within one entity.`,
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ return errors;
132
+ }
@@ -40,6 +40,7 @@ function createSchema({
40
40
 
41
41
  const inventoryEntry = {
42
42
  entity: "Appearance",
43
+ entityId: "appearance",
43
44
  groupingReason: "These controls define the visible appearance of the output.",
44
45
  id: "appearance",
45
46
  targets: ["appearance.opacity"],
@@ -56,6 +57,53 @@ describe("Toolcraft Control Section Inventory identity", () => {
56
57
  ).toEqual([]);
57
58
  });
58
59
 
60
+ it("preserves inventory ownership when runtime Setup relocates product background controls", () => {
61
+ const schema = defineToolcraft({
62
+ canvas: { enabled: true },
63
+ panels: {
64
+ controls: {
65
+ sections: [
66
+ {
67
+ controls: {
68
+ background: {
69
+ applicability: { mode: "always" },
70
+ defaultValue: "#101010",
71
+ target: "appearance.background",
72
+ type: "color",
73
+ },
74
+ includeBackground: {
75
+ applicability: { mode: "always" },
76
+ defaultValue: true,
77
+ target: "export.includeBackground",
78
+ type: "switch",
79
+ },
80
+ },
81
+ id: "background",
82
+ title: "Background",
83
+ },
84
+ ],
85
+ title: "Controls",
86
+ },
87
+ },
88
+ });
89
+ const backgroundInventory = {
90
+ entity: "Output background",
91
+ entityId: "output-background",
92
+ groupingReason:
93
+ "Inclusion and color jointly define the exported background.",
94
+ id: "background",
95
+ targets: ["export.includeBackground", "appearance.background"],
96
+ title: "Background",
97
+ } as const;
98
+
99
+ expect(schema.panels.controls?.sections[0]?.id).toBe("runtime.setup");
100
+ expect(
101
+ getToolcraftControlSectionInventoryErrors(schema, [
102
+ backgroundInventory,
103
+ ]),
104
+ ).toEqual([]);
105
+ });
106
+
59
107
  it("rejects an inventory id that does not match the schema section", () => {
60
108
  const errors = getToolcraftControlSectionInventoryErrors(createSchema(), [
61
109
  { ...inventoryEntry, id: "different-section" },
@@ -80,6 +128,45 @@ describe("Toolcraft Control Section Inventory identity", () => {
80
128
  );
81
129
  });
82
130
 
131
+ it.each([
132
+ [
133
+ "blank entityId",
134
+ { ...inventoryEntry, entityId: "" },
135
+ 'Control Section Inventory entry "Original title" must declare a non-empty stable entityId.',
136
+ ],
137
+ [
138
+ "blank entity",
139
+ { ...inventoryEntry, entity: "" },
140
+ 'Control Section Inventory entry "Original title" must declare a human-readable entity name.',
141
+ ],
142
+ ])("rejects %s", (_label, entry, expectedMessage) => {
143
+ expect(
144
+ getToolcraftControlSectionInventoryErrors(createSchema(), [entry]),
145
+ ).toContain(expectedMessage);
146
+ });
147
+
148
+ it("rejects a missing entityId without throwing", () => {
149
+ const { entityId: _entityId, ...entryWithoutEntityId } = inventoryEntry;
150
+ const malformedInventory = [
151
+ entryWithoutEntityId,
152
+ ] as unknown as readonly ToolcraftControlSectionInventoryEntry[];
153
+
154
+ expect(() =>
155
+ getToolcraftControlSectionInventoryErrors(
156
+ createSchema(),
157
+ malformedInventory,
158
+ ),
159
+ ).not.toThrow();
160
+ expect(
161
+ getToolcraftControlSectionInventoryErrors(
162
+ createSchema(),
163
+ malformedInventory,
164
+ ),
165
+ ).toContain(
166
+ 'Control Section Inventory entry "Original title" must declare a non-empty stable entityId.',
167
+ );
168
+ });
169
+
83
170
  it.each([
84
171
  ["missing", undefined],
85
172
  ["non-string", 42],
@@ -7,6 +7,12 @@ import {
7
7
  import { getToolcraftSectionLabel } from "./sections";
8
8
  import type { ToolcraftControlSectionInventoryEntry } from "./types";
9
9
 
10
+ type ToolcraftInventorySchemaSection = Readonly<{
11
+ id: string;
12
+ label: string;
13
+ targets: readonly string[];
14
+ }>;
15
+
10
16
  function getToolcraftControlSectionInventoryEntryId(
11
17
  entry: ToolcraftControlSectionInventoryEntry,
12
18
  ): string | null {
@@ -36,44 +42,31 @@ export function getToolcraftSectionInventoryById(
36
42
  return new Map(entries);
37
43
  }
38
44
 
39
- export function hasToolcraftInventorySplitEvidence({
40
- sectionInventoryById,
41
- sections,
42
- }: {
43
- sectionInventoryById: ReadonlyMap<string, ToolcraftControlSectionInventoryEntry>;
44
- sections: ReadonlySet<string>;
45
- }): boolean {
46
- const entries = [...sections].map((sectionId) =>
47
- sectionInventoryById.get(sectionId),
48
- );
45
+ function getToolcraftInventoryEntryByTarget(
46
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
47
+ ): ReadonlyMap<string, ToolcraftControlSectionInventoryEntry> {
48
+ const entries = new Map<string, ToolcraftControlSectionInventoryEntry>();
49
49
 
50
- return (
51
- entries.length === sections.size &&
52
- entries.every(
53
- (entry) =>
54
- entry &&
55
- (entry.workflowStage?.trim().length ?? 0) > 0 &&
56
- (entry.splitReason?.trim().length ?? 0) >= 12,
57
- )
58
- );
50
+ for (const entry of sectionInventory) {
51
+ for (const target of entry.targets) {
52
+ if (!entries.has(target)) {
53
+ entries.set(target, entry);
54
+ }
55
+ }
56
+ }
57
+
58
+ return entries;
59
59
  }
60
60
 
61
- export function getToolcraftControlSectionInventoryErrors(
61
+ function collectToolcraftInventorySchemaSections(
62
62
  schema: ResolvedToolcraftAppSchema,
63
63
  sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
64
- ): string[] {
65
- const errors: string[] = [];
66
-
67
- if (sectionInventory.length === 0) {
68
- return errors;
69
- }
64
+ ): ToolcraftInventorySchemaSection[] {
65
+ const inventoryEntryByTarget =
66
+ getToolcraftInventoryEntryByTarget(sectionInventory);
70
67
 
71
- const schemaSections = (schema.panels.controls?.sections ?? []).flatMap(
68
+ return (schema.panels.controls?.sections ?? []).flatMap(
72
69
  (section, sectionIndex) => {
73
- if (section.id === "runtime.setup") {
74
- return [];
75
- }
76
-
77
70
  const targets = Object.values(section.controls)
78
71
  .filter(isToolcraftProductSectionControl)
79
72
  .map((control) => control.target);
@@ -82,15 +75,53 @@ export function getToolcraftControlSectionInventoryErrors(
82
75
  return [];
83
76
  }
84
77
 
85
- return [
86
- {
78
+ if (section.id !== "runtime.setup") {
79
+ return [{
87
80
  id: section.id,
88
81
  label: getToolcraftSectionLabel(section.title, sectionIndex),
89
82
  targets,
90
- },
91
- ];
83
+ }];
84
+ }
85
+
86
+ const relocatedSections = new Map<string, ToolcraftInventorySchemaSection>();
87
+
88
+ for (const target of targets) {
89
+ const inventoryEntry = inventoryEntryByTarget.get(target);
90
+ const inventoryId = inventoryEntry
91
+ ? getToolcraftControlSectionInventoryEntryId(inventoryEntry)
92
+ : null;
93
+ const id = inventoryId ?? section.id;
94
+ const existing = relocatedSections.get(id);
95
+
96
+ relocatedSections.set(id, {
97
+ id,
98
+ label:
99
+ inventoryId && inventoryEntry
100
+ ? inventoryEntry.title
101
+ : getToolcraftSectionLabel(section.title, sectionIndex),
102
+ targets: [...(existing?.targets ?? []), target],
103
+ });
104
+ }
105
+
106
+ return [...relocatedSections.values()];
92
107
  },
93
108
  );
109
+ }
110
+
111
+ export function getToolcraftControlSectionInventoryErrors(
112
+ schema: ResolvedToolcraftAppSchema,
113
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
114
+ ): string[] {
115
+ const errors: string[] = [];
116
+
117
+ if (sectionInventory.length === 0) {
118
+ return errors;
119
+ }
120
+
121
+ const schemaSections = collectToolcraftInventorySchemaSections(
122
+ schema,
123
+ sectionInventory,
124
+ );
94
125
  const schemaSectionIds = new Set(schemaSections.map((section) => section.id));
95
126
  const schemaTargetToSection = new Map<string, string>();
96
127
 
@@ -107,9 +138,8 @@ export function getToolcraftControlSectionInventoryErrors(
107
138
  const id = getToolcraftControlSectionInventoryEntryId(entry);
108
139
  const title = entry.title.trim();
109
140
  const entity = entry.entity?.trim() ?? "";
110
- const workflowStage = entry.workflowStage?.trim() ?? "";
141
+ const entityId = entry.entityId?.trim() ?? "";
111
142
  const groupingReason = entry.groupingReason.trim();
112
- const splitReason = entry.splitReason?.trim() ?? "";
113
143
 
114
144
  if (!id) {
115
145
  errors.push(
@@ -133,21 +163,21 @@ export function getToolcraftControlSectionInventoryErrors(
133
163
  );
134
164
  }
135
165
 
136
- if (!entity && !workflowStage) {
166
+ if (!entityId) {
137
167
  errors.push(
138
- `Control Section Inventory entry "${title}" must declare entity or workflowStage so grouping is based on product meaning, not UI layout.`,
168
+ `Control Section Inventory entry "${title}" must declare a non-empty stable entityId.`,
139
169
  );
140
170
  }
141
171
 
142
- if (groupingReason.length < 12) {
172
+ if (!entity) {
143
173
  errors.push(
144
- `Control Section Inventory entry "${title}" must include a concrete groupingReason explaining why these controls belong together.`,
174
+ `Control Section Inventory entry "${title}" must declare a human-readable entity name.`,
145
175
  );
146
176
  }
147
177
 
148
- if (splitReason && splitReason.length < 12) {
178
+ if (groupingReason.length < 12) {
149
179
  errors.push(
150
- `Control Section Inventory entry "${title}" splitReason is too vague. Explain the product workflow split or omit splitReason.`,
180
+ `Control Section Inventory entry "${title}" must include a concrete groupingReason explaining why these controls belong together.`,
151
181
  );
152
182
  }
153
183
 
@@ -208,7 +238,7 @@ export function getToolcraftControlSectionInventoryErrors(
208
238
 
209
239
  if (!entry) {
210
240
  errors.push(
211
- `Control Section Inventory is missing product section id "${section.id}" (${section.label}). Add the same explicit id, entity/workflow stage, targets, and groupingReason.`,
241
+ `Control Section Inventory is missing product section id "${section.id}" (${section.label}). Add the same explicit id, entityId, entity, targets, and groupingReason.`,
212
242
  );
213
243
  continue;
214
244
  }
@@ -109,10 +109,18 @@ export function getBuiltInFitCheckErrors(
109
109
  );
110
110
  }
111
111
 
112
+ const ownsRepeatedItems = capabilities.some((capability) =>
113
+ ["collection", "reorder", "selection"].includes(capability),
114
+ );
115
+
116
+ if (ownsRepeatedItems && !checkedBuiltIns.includes("sourceCollection")) {
117
+ errors.push(
118
+ `${label} builtInFitCheck.checkedBuiltIns must include sourceCollection when the custom control owns a repeated runtime item set whose cardinality could be source-owned.`,
119
+ );
120
+ }
121
+
112
122
  if (
113
- capabilities.some((capability) =>
114
- ["collection", "reorder", "selection"].includes(capability),
115
- ) &&
123
+ ownsRepeatedItems &&
116
124
  !checkedBuiltIns.includes("collectionActions")
117
125
  ) {
118
126
  errors.push(