@pixel-point/toolcraft 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/generate.mjs +34 -5
- package/src/generate.test.mjs +12 -0
- package/src/package-json.mjs +15 -0
- package/src/package-json.test.mjs +14 -1
- package/templates/runtime/contracts/component-contracts.test.ts +185 -23
- package/templates/runtime/contracts/component-contracts.ts +91 -36
- package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
- package/templates/runtime/contracts/decision-contracts.ts +4 -4
- package/templates/runtime/export/export.test.ts +31 -0
- package/templates/runtime/export/export.ts +41 -0
- package/templates/runtime/react/canvas-shell.test.tsx +77 -1
- package/templates/runtime/react/canvas-shell.tsx +178 -23
- package/templates/runtime/react/control-conditions.ts +166 -0
- package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
- package/templates/runtime/react/controls-panel.test.tsx +774 -17
- package/templates/runtime/react/controls-panel.tsx +155 -8
- package/templates/runtime/react/media-file.ts +19 -0
- package/templates/runtime/schema/define-toolcraft.test.ts +46 -1
- package/templates/runtime/schema/define-toolcraft.ts +29 -3
- package/templates/runtime/schema/types.ts +7 -0
- package/templates/runtime/state/reducer.test.ts +304 -0
- package/templates/runtime/state/reducer.ts +148 -9
- package/templates/runtime/state/types.ts +10 -2
- package/templates/runtime/testing/performance.test.ts +1424 -56
- package/templates/runtime/testing/performance.ts +710 -43
- package/templates/starter/AGENTS.md +10 -9
- package/templates/starter/docs/toolcraft/README.md +1 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +16 -8
- package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -7
- package/templates/starter/docs/toolcraft/component-rules.md +46 -16
- package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
- package/templates/starter/docs/toolcraft/performance.md +54 -6
- package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
- package/templates/starter/docs/toolcraft/schema-reference.md +48 -19
- package/templates/starter/docs/toolcraft/workflow.md +2 -2
- package/templates/starter/e2e/app-performance.spec.ts +136 -3
- package/templates/starter/e2e/performance-helpers.ts +197 -0
- package/templates/starter/gitignore +1 -0
- package/templates/starter/package.json +5 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
- package/templates/starter/scripts/toolcraft-port.mjs +102 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +1288 -105
- package/templates/starter/src/app/starter-acceptance.ts +740 -24
- package/templates/starter/src/app/starter-performance.test.ts +66 -5
- package/templates/ui/components/control-layout/index.tsx +8 -3
- package/templates/ui/components/controls/actions/actions-control.tsx +10 -4
- package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
- package/templates/ui/components/controls/color/index.ts +4 -1
- package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
- package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
- package/templates/ui/components/controls/file-drop/index.ts +1 -1
- package/templates/ui/components/controls/index.ts +3 -0
- package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
- package/templates/ui/components/controls/select/select-control.tsx +9 -4
- package/templates/ui/components/controls/slider/slider-value.ts +0 -1
- package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
- package/templates/ui/components/controls/vector/index.ts +1 -0
- package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
- package/templates/ui/components/panel/panel-section.tsx +82 -6
|
@@ -9,8 +9,11 @@ export type ToolcraftPerformanceInteraction =
|
|
|
9
9
|
| "control-change"
|
|
10
10
|
| "control-drag"
|
|
11
11
|
| "export-copy"
|
|
12
|
+
| "mask-drag"
|
|
12
13
|
| "media-import"
|
|
13
14
|
| "preview-render"
|
|
15
|
+
| "timeline-playback"
|
|
16
|
+
| "timeline-scrub"
|
|
14
17
|
| "viewport-zoom-stress"
|
|
15
18
|
| "viewport-stability";
|
|
16
19
|
|
|
@@ -36,7 +39,7 @@ export type ToolcraftPerformanceValueSet = {
|
|
|
36
39
|
min?: unknown;
|
|
37
40
|
};
|
|
38
41
|
|
|
39
|
-
export type
|
|
42
|
+
export type ToolcraftPerformanceFixtureKind =
|
|
40
43
|
| "custom"
|
|
41
44
|
| "high-density"
|
|
42
45
|
| "large-canvas"
|
|
@@ -45,8 +48,8 @@ export type ToolcraftPerformanceStressFixtureKind =
|
|
|
45
48
|
| "max-value"
|
|
46
49
|
| "media";
|
|
47
50
|
|
|
48
|
-
export type
|
|
49
|
-
kind:
|
|
51
|
+
export type ToolcraftPerformanceFixture = {
|
|
52
|
+
kind: ToolcraftPerformanceFixtureKind;
|
|
50
53
|
minChars?: number;
|
|
51
54
|
minCount?: number;
|
|
52
55
|
minLines?: number;
|
|
@@ -54,6 +57,10 @@ export type ToolcraftPerformanceStressFixture = {
|
|
|
54
57
|
value?: unknown;
|
|
55
58
|
};
|
|
56
59
|
|
|
60
|
+
export type ToolcraftPerformanceStressFixtureKind = ToolcraftPerformanceFixtureKind;
|
|
61
|
+
export type ToolcraftPerformanceStressFixture = ToolcraftPerformanceFixture;
|
|
62
|
+
export type ToolcraftPerformanceWorkloadFixture = ToolcraftPerformanceFixture;
|
|
63
|
+
|
|
57
64
|
export type ToolcraftPerformanceScenario = ToolcraftPerformanceCoverage & {
|
|
58
65
|
budget: ToolcraftPerformanceBudget;
|
|
59
66
|
controlLabel?: string;
|
|
@@ -64,6 +71,7 @@ export type ToolcraftPerformanceScenario = ToolcraftPerformanceCoverage & {
|
|
|
64
71
|
target?: string;
|
|
65
72
|
stress?: boolean;
|
|
66
73
|
stressFixture?: ToolcraftPerformanceStressFixture;
|
|
74
|
+
workloadFixture?: ToolcraftPerformanceWorkloadFixture;
|
|
67
75
|
uiSelector?: string;
|
|
68
76
|
values?: ToolcraftPerformanceValueSet;
|
|
69
77
|
workload: boolean;
|
|
@@ -170,7 +178,70 @@ export type ToolcraftRendererTechnique = {
|
|
|
170
178
|
whyNotAlternativeStrategies: readonly string[];
|
|
171
179
|
};
|
|
172
180
|
|
|
181
|
+
export type ToolcraftRenderPassKind =
|
|
182
|
+
| "decode"
|
|
183
|
+
| "preprocess"
|
|
184
|
+
| "pixel-transform"
|
|
185
|
+
| "vector-build"
|
|
186
|
+
| "text-layout"
|
|
187
|
+
| "rasterize"
|
|
188
|
+
| "composite"
|
|
189
|
+
| "handles"
|
|
190
|
+
| "export";
|
|
191
|
+
|
|
192
|
+
export type ToolcraftRenderPassRunLocation =
|
|
193
|
+
| "main"
|
|
194
|
+
| "worker"
|
|
195
|
+
| "gpu"
|
|
196
|
+
| "worker-or-gpu"
|
|
197
|
+
| "export-only";
|
|
198
|
+
|
|
199
|
+
export type ToolcraftRenderPassOutput =
|
|
200
|
+
| "source"
|
|
201
|
+
| "intermediate"
|
|
202
|
+
| "preview"
|
|
203
|
+
| "overlay"
|
|
204
|
+
| "export";
|
|
205
|
+
|
|
206
|
+
export type ToolcraftRenderPassQuality = "preview" | "full" | "retina" | "export";
|
|
207
|
+
|
|
208
|
+
export type ToolcraftRenderPass = {
|
|
209
|
+
cacheKey?: readonly string[];
|
|
210
|
+
id: string;
|
|
211
|
+
inputs: readonly string[];
|
|
212
|
+
invalidatedBy: readonly string[];
|
|
213
|
+
kind: ToolcraftRenderPassKind;
|
|
214
|
+
output: ToolcraftRenderPassOutput;
|
|
215
|
+
quality: ToolcraftRenderPassQuality;
|
|
216
|
+
runsOn: ToolcraftRenderPassRunLocation;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export type ToolcraftPipelineInteraction =
|
|
220
|
+
| "animation-frame"
|
|
221
|
+
| "control-change"
|
|
222
|
+
| "control-drag"
|
|
223
|
+
| "media-import"
|
|
224
|
+
| "mask-drag"
|
|
225
|
+
| "viewport-drag"
|
|
226
|
+
| "viewport-zoom"
|
|
227
|
+
| "timeline-playback"
|
|
228
|
+
| "timeline-scrub"
|
|
229
|
+
| "export";
|
|
230
|
+
|
|
231
|
+
export type ToolcraftInteractionInvalidation = {
|
|
232
|
+
interaction: ToolcraftPipelineInteraction;
|
|
233
|
+
invalidates: readonly string[];
|
|
234
|
+
mustNotInvalidate?: readonly string[];
|
|
235
|
+
targets: readonly string[];
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export type ToolcraftRendererPipeline = {
|
|
239
|
+
interactionInvalidation: readonly ToolcraftInteractionInvalidation[];
|
|
240
|
+
passes: readonly ToolcraftRenderPass[];
|
|
241
|
+
};
|
|
242
|
+
|
|
173
243
|
export type ToolcraftPerformanceConfig = {
|
|
244
|
+
rendererPipeline?: ToolcraftRendererPipeline;
|
|
174
245
|
rendererStrategy: ToolcraftRendererStrategy;
|
|
175
246
|
rendererTechnique?: ToolcraftRendererTechnique;
|
|
176
247
|
rendererWorkload: ToolcraftRendererWorkload;
|
|
@@ -213,8 +284,10 @@ const heavyTextInputPattern = /code|css|instruction|json|prompt|script|shader|te
|
|
|
213
284
|
|
|
214
285
|
const largeTextStressMinChars = 50_000;
|
|
215
286
|
const largeTextStressMinLines = 1_000;
|
|
287
|
+
const mediaStressMinArea = 1920 * 1080;
|
|
288
|
+
const mediaStressMinLongEdge = 1920;
|
|
216
289
|
|
|
217
|
-
const
|
|
290
|
+
const performanceFixtureKinds = new Set<ToolcraftPerformanceFixtureKind>([
|
|
218
291
|
"custom",
|
|
219
292
|
"high-density",
|
|
220
293
|
"large-canvas",
|
|
@@ -224,6 +297,79 @@ const stressFixtureKinds = new Set<ToolcraftPerformanceStressFixtureKind>([
|
|
|
224
297
|
"media",
|
|
225
298
|
]);
|
|
226
299
|
|
|
300
|
+
const renderPassKinds = new Set<ToolcraftRenderPassKind>([
|
|
301
|
+
"decode",
|
|
302
|
+
"preprocess",
|
|
303
|
+
"pixel-transform",
|
|
304
|
+
"vector-build",
|
|
305
|
+
"text-layout",
|
|
306
|
+
"rasterize",
|
|
307
|
+
"composite",
|
|
308
|
+
"handles",
|
|
309
|
+
"export",
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
const renderPassRunLocations = new Set<ToolcraftRenderPassRunLocation>([
|
|
313
|
+
"main",
|
|
314
|
+
"worker",
|
|
315
|
+
"gpu",
|
|
316
|
+
"worker-or-gpu",
|
|
317
|
+
"export-only",
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const renderPassOutputs = new Set<ToolcraftRenderPassOutput>([
|
|
321
|
+
"source",
|
|
322
|
+
"intermediate",
|
|
323
|
+
"preview",
|
|
324
|
+
"overlay",
|
|
325
|
+
"export",
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
const renderPassQualities = new Set<ToolcraftRenderPassQuality>([
|
|
329
|
+
"preview",
|
|
330
|
+
"full",
|
|
331
|
+
"retina",
|
|
332
|
+
"export",
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const pipelineInteractions = new Set<ToolcraftPipelineInteraction>([
|
|
336
|
+
"animation-frame",
|
|
337
|
+
"control-change",
|
|
338
|
+
"control-drag",
|
|
339
|
+
"media-import",
|
|
340
|
+
"mask-drag",
|
|
341
|
+
"viewport-drag",
|
|
342
|
+
"viewport-zoom",
|
|
343
|
+
"timeline-playback",
|
|
344
|
+
"timeline-scrub",
|
|
345
|
+
"export",
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
const expensiveRenderPassKinds = new Set<ToolcraftRenderPassKind>([
|
|
349
|
+
"decode",
|
|
350
|
+
"preprocess",
|
|
351
|
+
"pixel-transform",
|
|
352
|
+
"text-layout",
|
|
353
|
+
"rasterize",
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
const cacheRequiredRenderPassKinds = new Set<ToolcraftRenderPassKind>([
|
|
357
|
+
...expensiveRenderPassKinds,
|
|
358
|
+
"composite",
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
const highFrequencyViewportInteractions = new Set<ToolcraftPipelineInteraction>([
|
|
362
|
+
"animation-frame",
|
|
363
|
+
"mask-drag",
|
|
364
|
+
"timeline-playback",
|
|
365
|
+
"timeline-scrub",
|
|
366
|
+
"viewport-drag",
|
|
367
|
+
"viewport-zoom",
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
const vaguePipelineReferencePattern =
|
|
371
|
+
/^(?:all|all values|everything|props|runtime|settings|state|values)$/i;
|
|
372
|
+
|
|
227
373
|
function getControlSemanticText(control: ToolcraftControlSchema): string {
|
|
228
374
|
return [
|
|
229
375
|
control.target,
|
|
@@ -309,12 +455,47 @@ function hasMinDefaultMax(values: ToolcraftPerformanceScenario["values"]): boole
|
|
|
309
455
|
return values !== undefined && "default" in values && "min" in values && "max" in values;
|
|
310
456
|
}
|
|
311
457
|
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
):
|
|
458
|
+
function getSliderDragControlType(
|
|
459
|
+
control: ToolcraftControlSchema | undefined,
|
|
460
|
+
): "rangeSlider" | "slider" | null {
|
|
461
|
+
if (control?.type === "slider" || control?.type === "rangeSlider") {
|
|
462
|
+
return control.type;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function hasControlDragScenario(scenarios: readonly ToolcraftPerformanceScenario[]): boolean {
|
|
469
|
+
return scenarios.some((scenario) => scenario.interaction === "control-drag");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function hasPerformanceFixtureValue(
|
|
473
|
+
fixture: ToolcraftPerformanceFixture,
|
|
474
|
+
): fixture is ToolcraftPerformanceFixture & { value: unknown } {
|
|
315
475
|
return Object.prototype.hasOwnProperty.call(fixture, "value");
|
|
316
476
|
}
|
|
317
477
|
|
|
478
|
+
function isPerformanceFixtureObjectValue(value: unknown): value is Record<string, unknown> {
|
|
479
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getMediaStressFixtureDimensions(
|
|
483
|
+
value: unknown,
|
|
484
|
+
): { height: number; width: number } | null {
|
|
485
|
+
if (!isPerformanceFixtureObjectValue(value)) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const width = Number(value.width);
|
|
490
|
+
const height = Number(value.height);
|
|
491
|
+
|
|
492
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return { height, width };
|
|
497
|
+
}
|
|
498
|
+
|
|
318
499
|
function getTextLineCount(value: string): number {
|
|
319
500
|
if (value.length === 0) {
|
|
320
501
|
return 0;
|
|
@@ -340,50 +521,79 @@ function isLargeTextWorkloadControl(
|
|
|
340
521
|
);
|
|
341
522
|
}
|
|
342
523
|
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
|
|
524
|
+
function getPerformanceFixtureShapeErrors(
|
|
525
|
+
scenarioId: string,
|
|
526
|
+
fieldName: "stressFixture" | "workloadFixture",
|
|
527
|
+
fixture: ToolcraftPerformanceFixture,
|
|
528
|
+
options: {
|
|
529
|
+
requireLargeText?: boolean;
|
|
530
|
+
requireMedia?: boolean;
|
|
531
|
+
} = {},
|
|
346
532
|
): string[] {
|
|
347
|
-
if (!scenario.workload) {
|
|
348
|
-
return [];
|
|
349
|
-
}
|
|
350
|
-
|
|
351
533
|
const errors: string[] = [];
|
|
352
|
-
const fixture = scenario.stressFixture;
|
|
353
534
|
|
|
354
|
-
if (!fixture) {
|
|
355
|
-
|
|
356
|
-
`${scenario.id} workload scenario must declare stressFixture with the real heaviest value used by browser performance tests.`,
|
|
357
|
-
];
|
|
535
|
+
if (!performanceFixtureKinds.has(fixture.kind)) {
|
|
536
|
+
errors.push(`${scenarioId} ${fieldName}.kind "${fixture.kind}" is not supported.`);
|
|
358
537
|
}
|
|
359
538
|
|
|
360
|
-
if (
|
|
361
|
-
|
|
539
|
+
if (options.requireMedia) {
|
|
540
|
+
if (fixture.kind !== "media") {
|
|
541
|
+
errors.push(
|
|
542
|
+
`${scenarioId} media-import scenario must use ${fieldName}.kind "media" with a realistic uploaded source size.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
362
545
|
}
|
|
363
546
|
|
|
364
547
|
if (!fixture.reason.trim()) {
|
|
365
548
|
errors.push(
|
|
366
|
-
`${
|
|
549
|
+
`${scenarioId} ${fieldName} must explain why this is the heaviest useful fixture.`,
|
|
367
550
|
);
|
|
368
551
|
}
|
|
369
552
|
|
|
370
|
-
if (
|
|
371
|
-
errors.push(
|
|
553
|
+
if (!hasPerformanceFixtureValue(fixture)) {
|
|
554
|
+
errors.push(
|
|
555
|
+
`${scenarioId} ${fieldName} must include value so browser tests can apply the exact heavy state.`,
|
|
556
|
+
);
|
|
557
|
+
} else if (fixture.kind === "custom") {
|
|
558
|
+
if (!isPerformanceFixtureObjectValue(fixture.value)) {
|
|
559
|
+
errors.push(
|
|
560
|
+
`${scenarioId} custom ${fieldName}.value must be an object with one key per heavy state part so browser tests can apply every key.`,
|
|
561
|
+
);
|
|
562
|
+
} else if (Object.keys(fixture.value).length === 0) {
|
|
563
|
+
errors.push(
|
|
564
|
+
`${scenarioId} custom ${fieldName}.value must include at least one heavy state key.`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
} else if (fixture.kind === "media") {
|
|
568
|
+
const dimensions = getMediaStressFixtureDimensions(fixture.value);
|
|
569
|
+
|
|
570
|
+
if (!dimensions) {
|
|
571
|
+
errors.push(
|
|
572
|
+
`${scenarioId} media ${fieldName}.value must be an object with numeric width and height so browser tests can generate or load a realistic source image.`,
|
|
573
|
+
);
|
|
574
|
+
} else if (
|
|
575
|
+
Math.max(dimensions.width, dimensions.height) < mediaStressMinLongEdge ||
|
|
576
|
+
dimensions.width * dimensions.height < mediaStressMinArea
|
|
577
|
+
) {
|
|
578
|
+
errors.push(
|
|
579
|
+
`${scenarioId} media ${fieldName}.value must be at least 1920x1080-equivalent; received ${dimensions.width}x${dimensions.height}.`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
372
582
|
}
|
|
373
583
|
|
|
374
|
-
if (!
|
|
584
|
+
if (!options.requireLargeText && fixture.kind !== "large-text") {
|
|
375
585
|
return errors;
|
|
376
586
|
}
|
|
377
587
|
|
|
378
588
|
if (fixture.kind !== "large-text") {
|
|
379
589
|
errors.push(
|
|
380
|
-
`${
|
|
590
|
+
`${scenarioId} text workload scenario must use ${fieldName}.kind "large-text" with a real long text value.`,
|
|
381
591
|
);
|
|
382
592
|
return errors;
|
|
383
593
|
}
|
|
384
594
|
|
|
385
595
|
if (typeof fixture.value !== "string") {
|
|
386
|
-
errors.push(`${
|
|
596
|
+
errors.push(`${scenarioId} large-text ${fieldName}.value must be a string.`);
|
|
387
597
|
return errors;
|
|
388
598
|
}
|
|
389
599
|
|
|
@@ -392,31 +602,148 @@ function getStressFixtureErrors(
|
|
|
392
602
|
|
|
393
603
|
if (minChars < largeTextStressMinChars) {
|
|
394
604
|
errors.push(
|
|
395
|
-
`${
|
|
605
|
+
`${scenarioId} large-text ${fieldName}.minChars must be >= ${largeTextStressMinChars}.`,
|
|
396
606
|
);
|
|
397
607
|
}
|
|
398
608
|
|
|
399
609
|
if (minLines < largeTextStressMinLines) {
|
|
400
610
|
errors.push(
|
|
401
|
-
`${
|
|
611
|
+
`${scenarioId} large-text ${fieldName}.minLines must be >= ${largeTextStressMinLines}.`,
|
|
402
612
|
);
|
|
403
613
|
}
|
|
404
614
|
|
|
405
615
|
if (fixture.value.length < minChars) {
|
|
406
616
|
errors.push(
|
|
407
|
-
`${
|
|
617
|
+
`${scenarioId} large-text ${fieldName}.value must contain at least ${minChars} characters.`,
|
|
408
618
|
);
|
|
409
619
|
}
|
|
410
620
|
|
|
411
621
|
if (getTextLineCount(fixture.value) < minLines) {
|
|
412
622
|
errors.push(
|
|
413
|
-
`${
|
|
623
|
+
`${scenarioId} large-text ${fieldName}.value must contain at least ${minLines} lines.`,
|
|
414
624
|
);
|
|
415
625
|
}
|
|
416
626
|
|
|
417
627
|
return errors;
|
|
418
628
|
}
|
|
419
629
|
|
|
630
|
+
function getStressFixtureErrors(
|
|
631
|
+
scenario: ToolcraftPerformanceScenario,
|
|
632
|
+
control: ToolcraftControlSchema | undefined,
|
|
633
|
+
): string[] {
|
|
634
|
+
const isMediaImport = scenario.interaction === "media-import";
|
|
635
|
+
|
|
636
|
+
if (!scenario.workload && scenario.stress !== true && !scenario.stressFixture && !isMediaImport) {
|
|
637
|
+
return [];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const fixture = scenario.stressFixture;
|
|
641
|
+
|
|
642
|
+
if (!fixture) {
|
|
643
|
+
if (isMediaImport) {
|
|
644
|
+
return [
|
|
645
|
+
`${scenario.id} media-import scenario must declare stressFixture.kind "media" with a realistic uploaded source size.`,
|
|
646
|
+
];
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return scenario.workload
|
|
650
|
+
? [
|
|
651
|
+
`${scenario.id} workload scenario must declare stressFixture with the real heaviest value used by browser performance tests.`,
|
|
652
|
+
]
|
|
653
|
+
: [
|
|
654
|
+
`${scenario.id} stress scenario must declare stressFixture with the real heaviest state used by browser performance tests.`,
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const errors: string[] = [];
|
|
659
|
+
|
|
660
|
+
if (isMediaImport && !scenario.workload) {
|
|
661
|
+
errors.push(
|
|
662
|
+
`${scenario.id} media-import scenario must set workload true because decoded source size changes renderer workload.`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
errors.push(
|
|
667
|
+
...getPerformanceFixtureShapeErrors(scenario.id, "stressFixture", fixture, {
|
|
668
|
+
requireLargeText: isLargeTextWorkloadControl(control),
|
|
669
|
+
requireMedia: isMediaImport,
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
return errors;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function stressFixtureAlreadyDefinesIndependentWorkload(
|
|
677
|
+
scenario: ToolcraftPerformanceScenario,
|
|
678
|
+
control: ToolcraftControlSchema | undefined,
|
|
679
|
+
): boolean {
|
|
680
|
+
return (
|
|
681
|
+
scenario.stressFixture?.kind === "large-text" &&
|
|
682
|
+
isLargeTextWorkloadControl(control)
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function isWorkloadBaselineSensitiveScenario(
|
|
687
|
+
config: ToolcraftPerformanceConfig,
|
|
688
|
+
scenario: ToolcraftPerformanceScenario,
|
|
689
|
+
control: ToolcraftControlSchema | undefined,
|
|
690
|
+
): boolean {
|
|
691
|
+
if (
|
|
692
|
+
!scenario.workload ||
|
|
693
|
+
(scenario.interaction !== "control-change" && scenario.interaction !== "control-drag")
|
|
694
|
+
) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!config.usesCustomRenderer) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (config.rendererWorkload === "none" || config.rendererWorkload === "simple-composition") {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return !stressFixtureAlreadyDefinesIndependentWorkload(scenario, control);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function getWorkloadFixtureErrors(
|
|
710
|
+
config: ToolcraftPerformanceConfig,
|
|
711
|
+
scenario: ToolcraftPerformanceScenario,
|
|
712
|
+
control: ToolcraftControlSchema | undefined,
|
|
713
|
+
): string[] {
|
|
714
|
+
const requiresBaseline = isWorkloadBaselineSensitiveScenario(config, scenario, control);
|
|
715
|
+
const errors: string[] = [];
|
|
716
|
+
|
|
717
|
+
if (scenario.workloadFixture && !scenario.stressFixture) {
|
|
718
|
+
errors.push(
|
|
719
|
+
`${scenario.id} workloadFixture must be paired with stressFixture so tests apply a heavy baseline and then the measured scenario value.`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (!requiresBaseline && !scenario.workloadFixture) {
|
|
724
|
+
return errors;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const fixture = scenario.workloadFixture;
|
|
728
|
+
|
|
729
|
+
if (!fixture) {
|
|
730
|
+
errors.push(
|
|
731
|
+
`${scenario.id} workload control scenario must declare workloadFixture for the app's heavy baseline state; stressFixture covers the control value only.`,
|
|
732
|
+
);
|
|
733
|
+
return errors;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
errors.push(
|
|
737
|
+
...getPerformanceFixtureShapeErrors(
|
|
738
|
+
scenario.id,
|
|
739
|
+
"workloadFixture",
|
|
740
|
+
fixture,
|
|
741
|
+
),
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
return errors;
|
|
745
|
+
}
|
|
746
|
+
|
|
420
747
|
function hasPositiveBudgetField(
|
|
421
748
|
budget: ToolcraftPerformanceBudget,
|
|
422
749
|
field: keyof ToolcraftPerformanceBudget,
|
|
@@ -438,6 +765,7 @@ function getMissingInteractionBudgetFields(
|
|
|
438
765
|
? ["maxFrameGapMs"]
|
|
439
766
|
: ["maxFrameGapMs", "maxLongTaskMs"];
|
|
440
767
|
case "animation-viewport-drag":
|
|
768
|
+
case "mask-drag":
|
|
441
769
|
case "viewport-zoom-stress":
|
|
442
770
|
return (["maxInteractionMs", "maxFrameGapMs", "maxLongTaskMs"] as const).filter(
|
|
443
771
|
(field) => !hasPositiveBudgetField(scenario.budget, field),
|
|
@@ -459,6 +787,14 @@ function getMissingInteractionBudgetFields(
|
|
|
459
787
|
hasPositiveBudgetField(scenario.budget, "maxRenderMs")
|
|
460
788
|
? []
|
|
461
789
|
: ["maxPreviewMs or maxRenderMs"];
|
|
790
|
+
case "timeline-playback":
|
|
791
|
+
return (["maxFrameGapMs", "maxLongTaskMs"] as const).filter(
|
|
792
|
+
(field) => !hasPositiveBudgetField(scenario.budget, field),
|
|
793
|
+
);
|
|
794
|
+
case "timeline-scrub":
|
|
795
|
+
return (["maxInteractionMs", "maxFrameGapMs", "maxLongTaskMs"] as const).filter(
|
|
796
|
+
(field) => !hasPositiveBudgetField(scenario.budget, field),
|
|
797
|
+
);
|
|
462
798
|
}
|
|
463
799
|
}
|
|
464
800
|
|
|
@@ -476,7 +812,13 @@ function getBudgetCapErrors(scenario: ToolcraftPerformanceScenario): string[] {
|
|
|
476
812
|
}
|
|
477
813
|
|
|
478
814
|
function requiresConcreteUiTarget(interaction: ToolcraftPerformanceInteraction): boolean {
|
|
479
|
-
return
|
|
815
|
+
return (
|
|
816
|
+
interaction === "control-change" ||
|
|
817
|
+
interaction === "control-drag" ||
|
|
818
|
+
interaction === "mask-drag" ||
|
|
819
|
+
interaction === "timeline-playback" ||
|
|
820
|
+
interaction === "timeline-scrub"
|
|
821
|
+
);
|
|
480
822
|
}
|
|
481
823
|
|
|
482
824
|
function getAllSchemaControls(
|
|
@@ -588,6 +930,32 @@ function hasHighCountCanvas2DRendererLayer(
|
|
|
588
930
|
);
|
|
589
931
|
}
|
|
590
932
|
|
|
933
|
+
function hasDetailHeavyCanvas2DRendererLayer(
|
|
934
|
+
technique: ToolcraftRendererTechnique | undefined,
|
|
935
|
+
): boolean {
|
|
936
|
+
return (technique?.layers ?? []).some(
|
|
937
|
+
(layer) =>
|
|
938
|
+
layer.renderer === "canvas-2d" &&
|
|
939
|
+
layer.content.some((content) => detailHeavyRendererContent.has(content)),
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function hasMeasuredGpuAlternativeEvidence(
|
|
944
|
+
technique: ToolcraftRendererTechnique,
|
|
945
|
+
): boolean {
|
|
946
|
+
const evidenceText = [
|
|
947
|
+
...technique.whyNotAlternativeStrategies,
|
|
948
|
+
...technique.performanceRisks,
|
|
949
|
+
].join(" ");
|
|
950
|
+
|
|
951
|
+
return (
|
|
952
|
+
/\b(?:webgl|webgpu|gpu)\b/i.test(evidenceText) &&
|
|
953
|
+
/\b(?:budget|evidence|frame|long task|measure|measured|perf|performance|stress)\b/i.test(
|
|
954
|
+
evidenceText,
|
|
955
|
+
)
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
591
959
|
function hasStressPreviewOrAnimationScenario(config: ToolcraftPerformanceConfig): boolean {
|
|
592
960
|
return config.scenarios.some(
|
|
593
961
|
(scenario) =>
|
|
@@ -767,6 +1135,267 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
|
|
|
767
1135
|
|
|
768
1136
|
errors.push(...getRendererLayerErrors(technique));
|
|
769
1137
|
|
|
1138
|
+
if (
|
|
1139
|
+
config.usesCustomRenderer &&
|
|
1140
|
+
technique.rendererStrategy === "canvas-2d" &&
|
|
1141
|
+
hasDetailHeavyCanvas2DRendererLayer(technique) &&
|
|
1142
|
+
!hasMeasuredGpuAlternativeEvidence(technique)
|
|
1143
|
+
) {
|
|
1144
|
+
errors.push(
|
|
1145
|
+
"Detail-heavy Canvas 2D renderers must evaluate WebGL/WebGPU in rendererTechnique.whyNotAlternativeStrategies or performanceRisks with measured stress evidence before keeping the pixel work on CPU.",
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
return errors;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function getPassById(
|
|
1153
|
+
pipeline: ToolcraftRendererPipeline,
|
|
1154
|
+
): Map<string, ToolcraftRenderPass> {
|
|
1155
|
+
return new Map(pipeline.passes.map((pass) => [pass.id, pass]));
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function hasPipelineReference(value: string): boolean {
|
|
1159
|
+
return value.trim().length > 0 && !vaguePipelineReferencePattern.test(value.trim());
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function getPipelineReferenceErrors(
|
|
1163
|
+
passId: string,
|
|
1164
|
+
field: string,
|
|
1165
|
+
references: readonly string[] | undefined,
|
|
1166
|
+
): string[] {
|
|
1167
|
+
if (!references || references.length === 0) {
|
|
1168
|
+
return [`rendererPipeline pass "${passId}" must list ${field}.`];
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return references.flatMap((reference) =>
|
|
1172
|
+
hasPipelineReference(reference)
|
|
1173
|
+
? []
|
|
1174
|
+
: [
|
|
1175
|
+
`rendererPipeline pass "${passId}" ${field} entry "${reference}" is too vague. Name the concrete runtime target, source key, resource key, or cache key part.`,
|
|
1176
|
+
],
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function getPipelineInteractionForScenario(
|
|
1181
|
+
interaction: ToolcraftPerformanceInteraction,
|
|
1182
|
+
): ToolcraftPipelineInteraction | null {
|
|
1183
|
+
switch (interaction) {
|
|
1184
|
+
case "animation-frame":
|
|
1185
|
+
return "animation-frame";
|
|
1186
|
+
case "animation-viewport-drag":
|
|
1187
|
+
return "viewport-drag";
|
|
1188
|
+
case "control-change":
|
|
1189
|
+
return "control-change";
|
|
1190
|
+
case "control-drag":
|
|
1191
|
+
return "control-drag";
|
|
1192
|
+
case "export-copy":
|
|
1193
|
+
return "export";
|
|
1194
|
+
case "mask-drag":
|
|
1195
|
+
return "mask-drag";
|
|
1196
|
+
case "media-import":
|
|
1197
|
+
return "media-import";
|
|
1198
|
+
case "preview-render":
|
|
1199
|
+
return null;
|
|
1200
|
+
case "timeline-playback":
|
|
1201
|
+
return "timeline-playback";
|
|
1202
|
+
case "timeline-scrub":
|
|
1203
|
+
return "timeline-scrub";
|
|
1204
|
+
case "viewport-zoom-stress":
|
|
1205
|
+
return "viewport-zoom";
|
|
1206
|
+
case "viewport-stability":
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function getRendererPipelineErrors(
|
|
1212
|
+
_schema: ResolvedToolcraftAppSchema,
|
|
1213
|
+
config: ToolcraftPerformanceConfig,
|
|
1214
|
+
): string[] {
|
|
1215
|
+
const errors: string[] = [];
|
|
1216
|
+
const pipeline = config.rendererPipeline;
|
|
1217
|
+
|
|
1218
|
+
if (config.usesCustomRenderer && !pipeline) {
|
|
1219
|
+
return [
|
|
1220
|
+
"Custom renderers must declare rendererPipeline so render passes, cache keys, and invalidation are machine-checkable.",
|
|
1221
|
+
];
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!config.usesCustomRenderer && pipeline) {
|
|
1225
|
+
return ["Non-custom renderer configs must omit rendererPipeline."];
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (!pipeline) {
|
|
1229
|
+
return errors;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (pipeline.passes.length === 0) {
|
|
1233
|
+
errors.push("rendererPipeline must declare at least one render pass.");
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (pipeline.interactionInvalidation.length === 0) {
|
|
1237
|
+
errors.push(
|
|
1238
|
+
"rendererPipeline must declare interactionInvalidation so high-frequency UI work cannot accidentally invalidate expensive passes.",
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const passIds = new Set<string>();
|
|
1243
|
+
|
|
1244
|
+
for (const pass of pipeline.passes) {
|
|
1245
|
+
const passId = pass.id.trim();
|
|
1246
|
+
|
|
1247
|
+
if (!passId) {
|
|
1248
|
+
errors.push("rendererPipeline passes must have non-empty ids.");
|
|
1249
|
+
} else if (passIds.has(passId)) {
|
|
1250
|
+
errors.push(`rendererPipeline pass id "${passId}" must be unique.`);
|
|
1251
|
+
} else {
|
|
1252
|
+
passIds.add(passId);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (!renderPassKinds.has(pass.kind)) {
|
|
1256
|
+
errors.push(`rendererPipeline pass "${pass.id}" kind "${pass.kind}" is not supported.`);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (!renderPassRunLocations.has(pass.runsOn)) {
|
|
1260
|
+
errors.push(
|
|
1261
|
+
`rendererPipeline pass "${pass.id}" runsOn "${pass.runsOn}" is not supported.`,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (!renderPassOutputs.has(pass.output)) {
|
|
1266
|
+
errors.push(
|
|
1267
|
+
`rendererPipeline pass "${pass.id}" output "${pass.output}" is not supported.`,
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (!renderPassQualities.has(pass.quality)) {
|
|
1272
|
+
errors.push(
|
|
1273
|
+
`rendererPipeline pass "${pass.id}" quality "${pass.quality}" is not supported.`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
errors.push(...getPipelineReferenceErrors(pass.id, "inputs", pass.inputs));
|
|
1278
|
+
errors.push(...getPipelineReferenceErrors(pass.id, "invalidatedBy", pass.invalidatedBy));
|
|
1279
|
+
|
|
1280
|
+
if (pass.cacheKey) {
|
|
1281
|
+
errors.push(...getPipelineReferenceErrors(pass.id, "cacheKey", pass.cacheKey));
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (
|
|
1285
|
+
cacheRequiredRenderPassKinds.has(pass.kind) &&
|
|
1286
|
+
(!pass.cacheKey || pass.cacheKey.length === 0)
|
|
1287
|
+
) {
|
|
1288
|
+
errors.push(
|
|
1289
|
+
`rendererPipeline pass "${pass.id}" is a cache-sensitive ${pass.kind} pass and must declare cacheKey so tests can reject full recomputation on every control change.`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (pass.kind === "decode") {
|
|
1294
|
+
const hasMediaImportScenario = config.scenarios.some(
|
|
1295
|
+
(scenario) => scenario.interaction === "media-import",
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
if (!hasMediaImportScenario) {
|
|
1299
|
+
errors.push(
|
|
1300
|
+
`rendererPipeline pass "${pass.id}" decodes media, so performance scenarios must include media-import coverage.`,
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const passesById = getPassById(pipeline);
|
|
1307
|
+
const invalidationTargets = new Set<string>();
|
|
1308
|
+
const pipelineInteractionSet = new Set(
|
|
1309
|
+
pipeline.interactionInvalidation.map((invalidation) => invalidation.interaction),
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
for (const invalidation of pipeline.interactionInvalidation) {
|
|
1313
|
+
if (!pipelineInteractions.has(invalidation.interaction)) {
|
|
1314
|
+
errors.push(
|
|
1315
|
+
`rendererPipeline interaction "${invalidation.interaction}" is not supported.`,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
errors.push(
|
|
1320
|
+
...getPipelineReferenceErrors(
|
|
1321
|
+
invalidation.interaction,
|
|
1322
|
+
"targets",
|
|
1323
|
+
invalidation.targets,
|
|
1324
|
+
),
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
for (const target of invalidation.targets) {
|
|
1328
|
+
if (hasPipelineReference(target)) {
|
|
1329
|
+
invalidationTargets.add(target);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const mustNotInvalidate = new Set(invalidation.mustNotInvalidate ?? []);
|
|
1334
|
+
|
|
1335
|
+
for (const passId of invalidation.invalidates) {
|
|
1336
|
+
if (!passId.trim()) {
|
|
1337
|
+
errors.push(
|
|
1338
|
+
`rendererPipeline ${invalidation.interaction} invalidates contains an empty pass id.`,
|
|
1339
|
+
);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const pass = passesById.get(passId);
|
|
1344
|
+
|
|
1345
|
+
if (!pass) {
|
|
1346
|
+
errors.push(
|
|
1347
|
+
`rendererPipeline ${invalidation.interaction} invalidates unknown pass "${passId}".`,
|
|
1348
|
+
);
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (mustNotInvalidate.has(passId)) {
|
|
1353
|
+
errors.push(
|
|
1354
|
+
`rendererPipeline ${invalidation.interaction} cannot both invalidate and mustNotInvalidate pass "${passId}".`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (
|
|
1359
|
+
highFrequencyViewportInteractions.has(invalidation.interaction) &&
|
|
1360
|
+
expensiveRenderPassKinds.has(pass.kind)
|
|
1361
|
+
) {
|
|
1362
|
+
errors.push(
|
|
1363
|
+
`rendererPipeline ${invalidation.interaction} must not invalidate expensive pass "${passId}" (${pass.kind}). Move viewport work to transforms/uniforms or explain it through a cheaper pass.`,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
for (const passId of invalidation.mustNotInvalidate ?? []) {
|
|
1369
|
+
if (!passId.trim()) {
|
|
1370
|
+
errors.push(
|
|
1371
|
+
`rendererPipeline ${invalidation.interaction} mustNotInvalidate contains an empty pass id.`,
|
|
1372
|
+
);
|
|
1373
|
+
} else if (!passesById.has(passId)) {
|
|
1374
|
+
errors.push(
|
|
1375
|
+
`rendererPipeline ${invalidation.interaction} mustNotInvalidate unknown pass "${passId}".`,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
for (const scenario of config.scenarios) {
|
|
1382
|
+
const pipelineInteraction = getPipelineInteractionForScenario(scenario.interaction);
|
|
1383
|
+
|
|
1384
|
+
if (pipelineInteraction && !pipelineInteractionSet.has(pipelineInteraction)) {
|
|
1385
|
+
errors.push(
|
|
1386
|
+
`Performance scenario ${scenario.id} exercises ${pipelineInteraction}, so rendererPipeline.interactionInvalidation must declare that interaction.`,
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
for (const target of config.workloadTargets) {
|
|
1392
|
+
if (!invalidationTargets.has(target)) {
|
|
1393
|
+
errors.push(
|
|
1394
|
+
`Performance workload target ${target} must appear in rendererPipeline interactionInvalidation targets.`,
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
770
1399
|
return errors;
|
|
771
1400
|
}
|
|
772
1401
|
|
|
@@ -790,6 +1419,7 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
790
1419
|
);
|
|
791
1420
|
|
|
792
1421
|
errors.push(...getRendererTechniqueErrors(config));
|
|
1422
|
+
errors.push(...getRendererPipelineErrors(schema, config));
|
|
793
1423
|
|
|
794
1424
|
if (config.usesCustomRenderer && !customRendererStrategies.has(config.rendererStrategy)) {
|
|
795
1425
|
errors.push(
|
|
@@ -817,10 +1447,11 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
817
1447
|
|
|
818
1448
|
if (
|
|
819
1449
|
config.rendererWorkload === "pixel-output" &&
|
|
820
|
-
!gpuRendererStrategies.has(config.rendererStrategy)
|
|
1450
|
+
!gpuRendererStrategies.has(config.rendererStrategy) &&
|
|
1451
|
+
(!config.rendererTechnique || !hasMeasuredGpuAlternativeEvidence(config.rendererTechnique))
|
|
821
1452
|
) {
|
|
822
1453
|
errors.push(
|
|
823
|
-
`rendererWorkload "pixel-output"
|
|
1454
|
+
`rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}". Keeping a CPU renderer requires measured WebGL/WebGPU evidence in rendererTechnique.whyNotAlternativeStrategies or performanceRisks.`,
|
|
824
1455
|
);
|
|
825
1456
|
}
|
|
826
1457
|
|
|
@@ -910,11 +1541,17 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
910
1541
|
}
|
|
911
1542
|
}
|
|
912
1543
|
|
|
913
|
-
if (
|
|
1544
|
+
if (
|
|
1545
|
+
scenario.workload &&
|
|
1546
|
+
scenario.interaction !== "media-import" &&
|
|
1547
|
+
!hasMinDefaultMax(scenario.values)
|
|
1548
|
+
) {
|
|
914
1549
|
errors.push(`${scenario.id} workload scenario must include min/default/max values.`);
|
|
915
1550
|
}
|
|
916
1551
|
|
|
917
|
-
|
|
1552
|
+
const scenarioControl = controlsByTarget.get(scenario.target ?? "");
|
|
1553
|
+
errors.push(...getStressFixtureErrors(scenario, scenarioControl));
|
|
1554
|
+
errors.push(...getWorkloadFixtureErrors(config, scenario, scenarioControl));
|
|
918
1555
|
|
|
919
1556
|
if (
|
|
920
1557
|
requiresConcreteUiTarget(scenario.interaction) &&
|
|
@@ -964,24 +1601,40 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
964
1601
|
}
|
|
965
1602
|
|
|
966
1603
|
const targetScenarios = scenariosByTarget.get(target) ?? [];
|
|
1604
|
+
const targetControl = controlsByTarget.get(target);
|
|
1605
|
+
const targetRequiresDrag = getSliderDragControlType(targetControl) !== null;
|
|
967
1606
|
const hasWorkloadCoverage = targetScenarios.some(
|
|
968
1607
|
(scenario) =>
|
|
969
1608
|
scenario.workload &&
|
|
970
|
-
(
|
|
971
|
-
scenario.interaction === "control-
|
|
1609
|
+
(targetRequiresDrag
|
|
1610
|
+
? scenario.interaction === "control-drag"
|
|
1611
|
+
: scenario.interaction === "control-drag" ||
|
|
1612
|
+
scenario.interaction === "control-change") &&
|
|
972
1613
|
hasMinDefaultMax(scenario.values),
|
|
973
1614
|
);
|
|
974
1615
|
|
|
975
1616
|
if (!hasWorkloadCoverage) {
|
|
976
|
-
errors.push(
|
|
1617
|
+
errors.push(
|
|
1618
|
+
targetRequiresDrag
|
|
1619
|
+
? `${target} must have min/default/max workload performance coverage through a real control-drag scenario.`
|
|
1620
|
+
: `${target} must have min/default/max workload performance coverage.`,
|
|
1621
|
+
);
|
|
977
1622
|
}
|
|
978
1623
|
}
|
|
979
1624
|
|
|
980
1625
|
for (const target of getVisiblePerformanceControlTargets(schema)) {
|
|
981
|
-
|
|
1626
|
+
const targetScenarios = scenariosByTarget.get(target) ?? [];
|
|
1627
|
+
const targetControl = controlsByTarget.get(target);
|
|
1628
|
+
const sliderControlType = getSliderDragControlType(targetControl);
|
|
1629
|
+
|
|
1630
|
+
if (targetScenarios.length === 0) {
|
|
982
1631
|
errors.push(
|
|
983
1632
|
`${target} must have a performance scenario because every visible control can affect app responsiveness.`,
|
|
984
1633
|
);
|
|
1634
|
+
} else if (sliderControlType && !hasControlDragScenario(targetScenarios)) {
|
|
1635
|
+
errors.push(
|
|
1636
|
+
`${target} is a ${sliderControlType} and must have a control-drag performance scenario proving live canvas/product feedback while dragging.`,
|
|
1637
|
+
);
|
|
985
1638
|
}
|
|
986
1639
|
}
|
|
987
1640
|
|
|
@@ -1039,10 +1692,24 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
1039
1692
|
}
|
|
1040
1693
|
}
|
|
1041
1694
|
|
|
1042
|
-
if (schema.canvas.upload
|
|
1043
|
-
|
|
1044
|
-
|
|
1695
|
+
if (schema.canvas.upload) {
|
|
1696
|
+
const mediaImportScenarios = config.scenarios.filter(
|
|
1697
|
+
(scenario) => scenario.interaction === "media-import",
|
|
1045
1698
|
);
|
|
1699
|
+
|
|
1700
|
+
if (mediaImportScenarios.length === 0) {
|
|
1701
|
+
errors.push(
|
|
1702
|
+
"Custom renderers with canvas upload must include a media-import performance scenario.",
|
|
1703
|
+
);
|
|
1704
|
+
} else if (
|
|
1705
|
+
!mediaImportScenarios.some(
|
|
1706
|
+
(scenario) => scenario.workload && scenario.stressFixture?.kind === "media",
|
|
1707
|
+
)
|
|
1708
|
+
) {
|
|
1709
|
+
errors.push(
|
|
1710
|
+
'Custom renderers with canvas upload must include a workload media-import performance scenario with stressFixture.kind "media".',
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1046
1713
|
}
|
|
1047
1714
|
|
|
1048
1715
|
if (hasKeyframeTimeline(schema)) {
|