@pixel-point/toolcraft 0.0.7 → 0.0.8

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 (41) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +84 -12
  3. package/templates/runtime/contracts/component-contracts.ts +40 -17
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +31 -0
  6. package/templates/runtime/export/export.ts +41 -0
  7. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  8. package/templates/runtime/react/canvas-shell.tsx +178 -23
  9. package/templates/runtime/react/control-conditions.ts +166 -0
  10. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  11. package/templates/runtime/react/controls-panel.test.tsx +404 -8
  12. package/templates/runtime/react/controls-panel.tsx +90 -4
  13. package/templates/runtime/react/media-file.ts +19 -0
  14. package/templates/runtime/schema/define-toolcraft.test.ts +1 -0
  15. package/templates/runtime/schema/define-toolcraft.ts +4 -2
  16. package/templates/runtime/schema/types.ts +4 -0
  17. package/templates/runtime/state/reducer.test.ts +304 -0
  18. package/templates/runtime/state/reducer.ts +148 -9
  19. package/templates/runtime/state/types.ts +10 -2
  20. package/templates/runtime/testing/performance.test.ts +1282 -48
  21. package/templates/runtime/testing/performance.ts +676 -39
  22. package/templates/starter/AGENTS.md +6 -5
  23. package/templates/starter/docs/toolcraft/acceptance-testing.md +11 -5
  24. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  25. package/templates/starter/docs/toolcraft/component-rules.md +16 -7
  26. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  27. package/templates/starter/docs/toolcraft/performance.md +47 -5
  28. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  29. package/templates/starter/docs/toolcraft/schema-reference.md +6 -6
  30. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  31. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  32. package/templates/starter/e2e/performance-helpers.ts +197 -0
  33. package/templates/starter/package.json +3 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  35. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  36. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  37. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  38. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  39. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  40. package/templates/ui/components/controls/index.ts +1 -0
  41. package/templates/ui/components/panel/panel-section.tsx +53 -1
@@ -110,12 +110,49 @@ function getScenarioBudgetAssertionPattern(scenarioId: string): RegExp {
110
110
 
111
111
  function getScenarioStressFixturePattern(scenarioId: string): RegExp {
112
112
  return new RegExp(
113
- `getToolcraftPerformanceStressValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
113
+ `(?:getToolcraftPerformanceStressValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)|dragToolcraftSliderToPerformanceStressValue\\s*\\([\\s\\S]*?(?:starterPerformance|appPerformance)|applyToolcraftPerformanceStressFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance))\\s*,\\s*(["'\`])${escapeRegExp(
114
114
  scenarioId,
115
115
  )}\\1`,
116
116
  );
117
117
  }
118
118
 
119
+ function getScenarioWorkloadFixturePattern(scenarioId: string): RegExp {
120
+ return new RegExp(
121
+ `(?:getToolcraftPerformanceWorkloadValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)|applyToolcraftPerformanceWorkloadFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance))\\s*,\\s*(["'\`])${escapeRegExp(
122
+ scenarioId,
123
+ )}\\1`,
124
+ );
125
+ }
126
+
127
+ function getScenarioCustomStressFixturePattern(scenarioId: string): RegExp {
128
+ return new RegExp(
129
+ `applyToolcraftPerformanceStressFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
130
+ scenarioId,
131
+ )}\\1`,
132
+ );
133
+ }
134
+
135
+ function getScenarioCustomWorkloadFixturePattern(scenarioId: string): RegExp {
136
+ return new RegExp(
137
+ `applyToolcraftPerformanceWorkloadFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
138
+ scenarioId,
139
+ )}\\1`,
140
+ );
141
+ }
142
+
143
+ function getScenarioSliderStressValuePattern(scenarioId: string): RegExp {
144
+ return new RegExp(
145
+ `dragToolcraftSliderToPerformanceStressValue\\s*\\([\\s\\S]*?(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
146
+ scenarioId,
147
+ )}\\1`,
148
+ );
149
+ }
150
+
151
+ function getFirstMatchIndex(source: string, pattern: RegExp): number {
152
+ const match = pattern.exec(source);
153
+ return match?.index ?? -1;
154
+ }
155
+
119
156
  test("browser performance matrix points at real Playwright tests", () => {
120
157
  const browserTestSources = readSiblingBrowserTestSources();
121
158
 
@@ -154,15 +191,71 @@ test("browser performance tests use real Toolcraft interactions", () => {
154
191
  continue;
155
192
  }
156
193
 
157
- if (scenario.workload) {
194
+ if (scenario.workload || scenario.stress === true || scenario.stressFixture) {
158
195
  expect(
159
196
  scenario.stressFixture,
160
- `${scenario.id} workload scenario must declare stressFixture so browser tests cannot use toy inputs.`,
197
+ `${scenario.id} stress scenario must declare stressFixture so browser tests cannot use toy inputs.`,
161
198
  ).toBeDefined();
162
199
  expect(
163
200
  browserTestSource,
164
201
  `${scenario.id} must read the declared stress fixture with getToolcraftPerformanceStressValue(appPerformance, "${scenario.id}") before measuring performance.`,
165
202
  ).toMatch(getScenarioStressFixturePattern(scenario.id));
203
+
204
+ if (
205
+ scenario.stressFixture?.kind === "custom" &&
206
+ typeof scenario.stressFixture.value === "object" &&
207
+ scenario.stressFixture.value !== null &&
208
+ !Array.isArray(scenario.stressFixture.value)
209
+ ) {
210
+ expect(
211
+ browserTestSource,
212
+ `${scenario.id} custom stress fixture must be applied with applyToolcraftPerformanceStressFixture so every heavy-state key is mapped through the real UI before measurement.`,
213
+ ).toMatch(getScenarioCustomStressFixturePattern(scenario.id));
214
+ }
215
+ }
216
+
217
+ if (scenario.workloadFixture) {
218
+ const workloadFixtureIndex = getFirstMatchIndex(
219
+ browserTestSource,
220
+ getScenarioWorkloadFixturePattern(scenario.id),
221
+ );
222
+ const stressFixtureIndex = getFirstMatchIndex(
223
+ browserTestSource,
224
+ getScenarioStressFixturePattern(scenario.id),
225
+ );
226
+ const budgetAssertionIndex = getFirstMatchIndex(
227
+ browserTestSource,
228
+ getScenarioBudgetAssertionPattern(scenario.id),
229
+ );
230
+
231
+ expect(
232
+ browserTestSource,
233
+ `${scenario.id} must apply the declared workloadFixture with getToolcraftPerformanceWorkloadValue(appPerformance, "${scenario.id}") or applyToolcraftPerformanceWorkloadFixture before measuring performance.`,
234
+ ).toMatch(getScenarioWorkloadFixturePattern(scenario.id));
235
+ expect(
236
+ workloadFixtureIndex,
237
+ `${scenario.id} must apply workloadFixture before stressFixture so the measured control runs inside the heavy app baseline.`,
238
+ ).toBeGreaterThanOrEqual(0);
239
+ expect(
240
+ stressFixtureIndex,
241
+ `${scenario.id} must read/apply stressFixture after workloadFixture.`,
242
+ ).toBeGreaterThan(workloadFixtureIndex);
243
+ expect(
244
+ budgetAssertionIndex,
245
+ `${scenario.id} must assert budget only after workloadFixture is applied.`,
246
+ ).toBeGreaterThan(workloadFixtureIndex);
247
+
248
+ if (
249
+ scenario.workloadFixture.kind === "custom" &&
250
+ typeof scenario.workloadFixture.value === "object" &&
251
+ scenario.workloadFixture.value !== null &&
252
+ !Array.isArray(scenario.workloadFixture.value)
253
+ ) {
254
+ expect(
255
+ browserTestSource,
256
+ `${scenario.id} custom workload fixture must be applied with applyToolcraftPerformanceWorkloadFixture so every baseline-state key is mapped through the real UI before measurement.`,
257
+ ).toMatch(getScenarioCustomWorkloadFixturePattern(scenario.id));
258
+ }
166
259
  }
167
260
 
168
261
  if (scenario.interaction === "control-drag") {
@@ -190,6 +283,13 @@ test("browser performance tests use real Toolcraft interactions", () => {
190
283
  `${scenario.id} drags discrete slider "${scenario.controlLabel}" and must verify Toolcraft marker-budget behavior plus smooth drag.`,
191
284
  ).toMatch(/expectToolcraftDiscreteSliderDragSmoothness\s*\(/);
192
285
  }
286
+
287
+ if (scenario.workload && scenario.controlLabel) {
288
+ expect(
289
+ browserTestSource,
290
+ `${scenario.id} workload slider drags must apply the exact stressFixture.value with dragToolcraftSliderToPerformanceStressValue instead of hand-dividing by max or typing a ratio.`,
291
+ ).toMatch(getScenarioSliderStressValuePattern(scenario.id));
292
+ }
193
293
  }
194
294
 
195
295
  if (scenario.interaction === "control-change") {
@@ -284,6 +384,39 @@ test("browser performance tests use real Toolcraft interactions", () => {
284
384
  ).not.toMatch(/canvas\.setOffset|canvas\.panBy|canvas\.zoom|Zoom in|Zoom out|Center canvas/);
285
385
  }
286
386
 
387
+ if (scenario.interaction === "timeline-playback") {
388
+ expect(
389
+ browserTestSource,
390
+ `${scenario.id} must use the real timeline playback UI while measuring frames.`,
391
+ ).toMatch(/measureToolcraftInteraction\s*\([\s\S]*(Play playback|Pause playback|timeline-playback|data-slot=["']timeline-playback)/);
392
+ expect(
393
+ browserTestSource,
394
+ `${scenario.id} must not satisfy timeline playback performance by dispatching timeline state directly.`,
395
+ ).not.toMatch(/timeline\.setCurrentTime|timeline\.setPlaying|timeline\.play|timeline\.pause/);
396
+ }
397
+
398
+ if (scenario.interaction === "timeline-scrub") {
399
+ expect(
400
+ browserTestSource,
401
+ `${scenario.id} must scrub the real timeline UI while measuring interaction responsiveness.`,
402
+ ).toMatch(/measureToolcraftInteraction\s*\([\s\S]*(Playback position|timeline-playback-handle|data-slot=["']timeline-playback-handle|page\.mouse\.(?:down|move|up))/);
403
+ expect(
404
+ browserTestSource,
405
+ `${scenario.id} must not satisfy timeline scrub performance by setting timeline time directly.`,
406
+ ).not.toMatch(/timeline\.setCurrentTime|canvas\.setCurrentTime/);
407
+ }
408
+
409
+ if (scenario.interaction === "mask-drag") {
410
+ expect(
411
+ browserTestSource,
412
+ `${scenario.id} must measure a real canvas handle/mask drag.`,
413
+ ).toMatch(/measureToolcraftInteraction\s*\([\s\S]*dragCanvasHandle\s*\(/);
414
+ expect(
415
+ browserTestSource,
416
+ `${scenario.id} must not fake mask drag coverage with direct runtime state mutation.`,
417
+ ).not.toMatch(/mask\.(?:set|update|move)|canvas\.setOffset|canvas\.panBy/);
418
+ }
419
+
287
420
  if (scenario.interaction === "viewport-zoom-stress") {
288
421
  expect(
289
422
  browserTestSource,
@@ -21,6 +21,26 @@ export type ToolcraftInteractionOptions = {
21
21
  settleMs?: number;
22
22
  };
23
23
 
24
+ export type ToolcraftStressFixtureApplyContext = {
25
+ config: ToolcraftPerformanceConfig;
26
+ fixture: Record<string, unknown>;
27
+ key: string;
28
+ page: Page;
29
+ scenarioId: string;
30
+ };
31
+
32
+ export type ToolcraftStressFixtureAppliers = Record<
33
+ string,
34
+ (
35
+ value: unknown,
36
+ context: ToolcraftStressFixtureApplyContext,
37
+ ) => Promise<void> | void
38
+ >;
39
+
40
+ function isToolcraftStressFixtureObject(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
24
44
  export async function startToolcraftFrameProbe(
25
45
  page: Page,
26
46
  ): Promise<() => Promise<ToolcraftFrameProbeResult>> {
@@ -371,6 +391,161 @@ export async function dragToolcraftSliderByLabel(
371
391
  await page.mouse.up();
372
392
  }
373
393
 
394
+ export async function dragToolcraftSliderToValue(
395
+ page: Page,
396
+ label: string,
397
+ value: number,
398
+ ): Promise<void> {
399
+ const field = await getToolcraftFieldByLabel(page, label);
400
+ const slider = field.locator('[data-slot="slider"], [role="slider"]').first();
401
+
402
+ await expect(slider, `Toolcraft slider "${label}" should be visible`).toBeVisible();
403
+
404
+ const range = await slider.evaluate((element) => {
405
+ const htmlElement = element as HTMLElement;
406
+ const min = Number(
407
+ htmlElement.getAttribute("aria-valuemin") ??
408
+ (htmlElement as HTMLInputElement).min ??
409
+ "0",
410
+ );
411
+ const max = Number(
412
+ htmlElement.getAttribute("aria-valuemax") ??
413
+ (htmlElement as HTMLInputElement).max ??
414
+ "100",
415
+ );
416
+
417
+ return {
418
+ max: Number.isFinite(max) ? max : 100,
419
+ min: Number.isFinite(min) ? min : 0,
420
+ };
421
+ });
422
+ const denominator = range.max - range.min;
423
+ const ratio = denominator === 0 ? 0 : (value - range.min) / denominator;
424
+
425
+ await dragToolcraftSliderByLabel(page, label, Math.min(1, Math.max(0, ratio)));
426
+ }
427
+
428
+ export async function dragToolcraftSliderToPerformanceStressValue(
429
+ page: Page,
430
+ label: string,
431
+ config: ToolcraftPerformanceConfig,
432
+ scenarioId: string,
433
+ ): Promise<void> {
434
+ const value = getToolcraftPerformanceStressValue(config, scenarioId);
435
+
436
+ if (typeof value !== "number" || !Number.isFinite(value)) {
437
+ throw new Error(
438
+ `Toolcraft performance scenario "${scenarioId}" must provide a numeric stressFixture.value for slider "${label}".`,
439
+ );
440
+ }
441
+
442
+ await dragToolcraftSliderToValue(page, label, value);
443
+ }
444
+
445
+ export async function applyToolcraftPerformanceStressFixture(
446
+ page: Page,
447
+ config: ToolcraftPerformanceConfig,
448
+ scenarioId: string,
449
+ appliers: ToolcraftStressFixtureAppliers,
450
+ ): Promise<Record<string, unknown>> {
451
+ const fixture = getToolcraftPerformanceStressValue(config, scenarioId);
452
+
453
+ if (!isToolcraftStressFixtureObject(fixture)) {
454
+ throw new Error(
455
+ `Toolcraft performance scenario "${scenarioId}" must provide an object stressFixture.value for combined fixture application.`,
456
+ );
457
+ }
458
+
459
+ const fixtureKeys = Object.keys(fixture);
460
+ if (fixtureKeys.length === 0) {
461
+ throw new Error(
462
+ `Toolcraft performance scenario "${scenarioId}" stressFixture.value must contain at least one key.`,
463
+ );
464
+ }
465
+
466
+ const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
467
+ if (missingKeys.length > 0) {
468
+ throw new Error(
469
+ `Toolcraft performance scenario "${scenarioId}" is missing fixture appliers for: ${missingKeys.join(
470
+ ", ",
471
+ )}.`,
472
+ );
473
+ }
474
+
475
+ const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
476
+ if (extraKeys.length > 0) {
477
+ throw new Error(
478
+ `Toolcraft performance scenario "${scenarioId}" declares fixture appliers not present in stressFixture.value: ${extraKeys.join(
479
+ ", ",
480
+ )}.`,
481
+ );
482
+ }
483
+
484
+ for (const key of fixtureKeys) {
485
+ await appliers[key]!(fixture[key], {
486
+ config,
487
+ fixture,
488
+ key,
489
+ page,
490
+ scenarioId,
491
+ });
492
+ }
493
+
494
+ return fixture;
495
+ }
496
+
497
+ export async function applyToolcraftPerformanceWorkloadFixture(
498
+ page: Page,
499
+ config: ToolcraftPerformanceConfig,
500
+ scenarioId: string,
501
+ appliers: ToolcraftStressFixtureAppliers,
502
+ ): Promise<Record<string, unknown>> {
503
+ const fixture = getToolcraftPerformanceWorkloadValue(config, scenarioId);
504
+
505
+ if (!isToolcraftStressFixtureObject(fixture)) {
506
+ throw new Error(
507
+ `Toolcraft performance scenario "${scenarioId}" must provide an object workloadFixture.value for baseline fixture application.`,
508
+ );
509
+ }
510
+
511
+ const fixtureKeys = Object.keys(fixture);
512
+ if (fixtureKeys.length === 0) {
513
+ throw new Error(
514
+ `Toolcraft performance scenario "${scenarioId}" workloadFixture.value must contain at least one key.`,
515
+ );
516
+ }
517
+
518
+ const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
519
+ if (missingKeys.length > 0) {
520
+ throw new Error(
521
+ `Toolcraft performance scenario "${scenarioId}" is missing workload fixture appliers for: ${missingKeys.join(
522
+ ", ",
523
+ )}.`,
524
+ );
525
+ }
526
+
527
+ const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
528
+ if (extraKeys.length > 0) {
529
+ throw new Error(
530
+ `Toolcraft performance scenario "${scenarioId}" declares workload fixture appliers not present in workloadFixture.value: ${extraKeys.join(
531
+ ", ",
532
+ )}.`,
533
+ );
534
+ }
535
+
536
+ for (const key of fixtureKeys) {
537
+ await appliers[key]!(fixture[key], {
538
+ config,
539
+ fixture,
540
+ key,
541
+ page,
542
+ scenarioId,
543
+ });
544
+ }
545
+
546
+ return fixture;
547
+ }
548
+
374
549
  export async function dragToolcraftCanvasViewport(
375
550
  page: Page,
376
551
  delta: { x: number; y: number } = { x: 96, y: -64 },
@@ -553,6 +728,28 @@ export function getToolcraftPerformanceStressValue<TValue = unknown>(
553
728
  return scenario.stressFixture.value as TValue;
554
729
  }
555
730
 
731
+ export function getToolcraftPerformanceWorkloadValue<TValue = unknown>(
732
+ config: ToolcraftPerformanceConfig,
733
+ scenarioId: string,
734
+ ): TValue {
735
+ const scenario = config.scenarios.find((item) => item.id === scenarioId);
736
+
737
+ if (!scenario) {
738
+ throw new Error(`Toolcraft performance scenario "${scenarioId}" was not found.`);
739
+ }
740
+
741
+ if (
742
+ !scenario.workloadFixture ||
743
+ !Object.prototype.hasOwnProperty.call(scenario.workloadFixture, "value")
744
+ ) {
745
+ throw new Error(
746
+ `Toolcraft performance scenario "${scenarioId}" does not declare workloadFixture.value.`,
747
+ );
748
+ }
749
+
750
+ return scenario.workloadFixture.value as TValue;
751
+ }
752
+
556
753
  export function expectToolcraftScenarioPerformanceBudget(
557
754
  result: ToolcraftPerformanceBudgetResult,
558
755
  config: ToolcraftPerformanceConfig,
@@ -20,6 +20,9 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@base-ui/react": "^1.4.1",
23
+ "@dnd-kit/core": "^6.3.1",
24
+ "@dnd-kit/sortable": "^10.0.0",
25
+ "@dnd-kit/utilities": "^3.2.2",
23
26
  "@fontsource-variable/inter": "^5.2.8",
24
27
  "@phosphor-icons/react": "^2.1.10",
25
28
  "@repo/toolcraft-runtime": "workspace:*",