Phase 5: ship layout QA budgets, import hardening, and fixture pack
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 14:00:05 -05:00
parent 31a47346ea
commit 8b6c9593e1
17 changed files with 446 additions and 57 deletions

View File

@ -39,6 +39,12 @@ jobs:
- name: Run browser regression - name: Run browser regression
run: npm run test:ui run: npm run test:ui
- name: UI metrics report (always)
if: always()
run: |
echo "UI metrics report:"
cat output/playwright/ui-metrics-report.json || true
- name: Browser artifacts listing (always) - name: Browser artifacts listing (always)
if: always() if: always()
run: | run: |

View File

@ -40,6 +40,8 @@ Docs:
- `docs/operations-runbook.md` - `docs/operations-runbook.md`
- `docs/quality-gates.md` - `docs/quality-gates.md`
- `docs/phase4-execution-plan.md` - `docs/phase4-execution-plan.md`
- `docs/phase5-execution-plan.md`
- `docs/fixtures.md`
- `docs/api-mcp-contracts.md` - `docs/api-mcp-contracts.md`
CI: CI:
@ -186,6 +188,9 @@ npx playwright install chromium
npm run test:ui npm run test:ui
``` ```
Metrics report artifact:
- `output/playwright/ui-metrics-report.json`
Refresh visual baselines intentionally: Refresh visual baselines intentionally:
```bash ```bash

29
docs/fixtures.md Normal file
View File

@ -0,0 +1,29 @@
# Fixture Pack
Schemeta uses deterministic fixture circuits for compile/layout/readability QA.
## Core fixtures
1. `examples/esp32-audio.json`
- Baseline audio pipeline (MCU -> DAC -> amp).
2. `examples/dense-analog.json`
- High-density analog topology used for overlap/crossing stress.
3. `examples/i2c-sensor-hub.json`
- Shared bus topology with pull-ups and interrupt branches.
4. `examples/power-tree.json`
- Multi-domain power-distribution with digital branch nets.
## Usage
1. Manual import in UI:
- `Import JSON` then select fixture file.
2. API:
- `POST /compile` or `POST /analyze` with fixture payload.
3. Automated regression:
- `npm run test:ui` uses `dense-analog` for layout budget checks.
## Notes
1. Fixtures should stay deterministic (stable ordering and explicit naming).
2. Avoid avoidable warnings in default/demo fixtures.
3. Add a fixture whenever a routing/layout bug is fixed to prevent regressions.

View File

@ -0,0 +1,49 @@
# Phase 5 Execution Plan
Milestone: `Phase 5 - Layout Robustness and JSON Import UX Hardening`
## Goals
1. Make drag/re-layout behavior measurable and regression-resistant.
2. Improve JSON import/apply reliability with validation-first messaging.
3. Ship richer fixture coverage and enforce readability budgets in CI.
## Workstreams
1. QA harness and metrics artifact
- Extend `tests/ui-regression-runner.js` to capture:
- initial compile metrics
- post-drag metrics
- post-drag auto-tidy metrics
- dense fixture metrics
- Emit machine-readable artifact:
- `output/playwright/ui-metrics-report.json`
2. Layout/readability quality budgets
- Enforce thresholds in UI regression runner with env overrides:
- `UI_SAMPLE_MAX_*`
- `UI_DRAG_MAX_*`
- `UI_TIDY_MAX_*`
- `UI_DENSE_MAX_*`
- Keep defaults tuned to current deterministic behavior.
3. JSON import UX hardening
- Validate with `/analyze` before applying/importing JSON.
- Block apply/import when validation has errors.
- Surface first warning/error and path for faster correction.
- Keep deterministic change summary after successful apply.
4. Fixture pack expansion
- Add representative fixtures beyond the baseline audio example:
- `examples/dense-analog.json`
- `examples/i2c-sensor-hub.json`
- `examples/power-tree.json`
- Maintain warning-clean default sample (`frontend/sample.schemeta.json`).
## Done Definition
1. `npm test` passes.
2. `npm run test:ui` passes with quality budgets.
3. `output/playwright/ui-metrics-report.json` is generated on every UI run.
4. Import/apply validation behavior is deterministic and user-visible.
5. Phase 5 issues `#24-#29` are closed and milestone is closed.

View File

@ -14,10 +14,13 @@ This document defines measurable release gates for Schemeta.
- `npm run test:ui` passes. - `npm run test:ui` passes.
2. Visual regression 2. Visual regression
- No unexpected screenshot diffs in `tests/baselines/ui`. - No unexpected screenshot diffs in `tests/baselines/ui`.
- Dense analog fixture remains under threshold: - UI budget thresholds (defaults in `tests/ui-regression-runner.js`) are met:
- crossings = `0` - sample: crossings <= `1`, overlaps <= `1`, detour <= `3.2`
- overlaps = `0` - drag: crossings <= `3`, overlaps <= `3`, detour <= `3.5`
- detour <= `2.5` - drag+tidy: crossings <= `2`, overlaps <= `2`, detour <= `2.0`
- dense fixture: crossings <= `2`, overlaps <= `2`, detour <= `3.0`
- Machine-readable report generated:
- `output/playwright/ui-metrics-report.json`
3. Interaction reliability 3. Interaction reliability
- Selection/deselection/isolate/reset flow verified. - Selection/deselection/isolate/reset flow verified.
- Undo/redo parity verified for component, pin, net, and symbol edits. - Undo/redo parity verified for component, pin, net, and symbol edits.
@ -48,6 +51,7 @@ This document defines measurable release gates for Schemeta.
- syntax checks - syntax checks
- `npm test` - `npm test`
- `npm run test:ui` - `npm run test:ui`
- verify `output/playwright/ui-metrics-report.json` was produced
- Release candidate required: - Release candidate required:
- checklist completion in `docs/release-checklist.md` - checklist completion in `docs/release-checklist.md`
- intentional baseline updates reviewed and approved - intentional baseline updates reviewed and approved

View File

@ -4,6 +4,7 @@ Use this checklist before cutting a release tag.
Reference docs: Reference docs:
- `docs/quality-gates.md` - `docs/quality-gates.md`
- `docs/phase4-execution-plan.md` - `docs/phase4-execution-plan.md`
- `docs/phase5-execution-plan.md`
## Pre-merge ## Pre-merge
@ -30,7 +31,8 @@ Reference docs:
- [ ] Visual baselines updated intentionally (`tests/baselines/ui`) and screenshot diff checks pass. - [ ] Visual baselines updated intentionally (`tests/baselines/ui`) and screenshot diff checks pass.
- [ ] Labels remain legible at common zoom levels. - [ ] Labels remain legible at common zoom levels.
- [ ] No major overlap/crossing regressions vs previous release baseline. - [ ] No major overlap/crossing regressions vs previous release baseline.
- [ ] Dense analog fixture meets gate thresholds (`crossings=0`, `overlaps=0`, detour target). - [ ] UI metrics report generated (`output/playwright/ui-metrics-report.json`).
- [ ] Sample/drag/tidy/dense fixture all meet the thresholds in `docs/quality-gates.md`.
## Security / Operations ## Security / Operations

View File

@ -0,0 +1,81 @@
{
"meta": {
"title": "I2C Sensor Hub",
"version": "1.0"
},
"symbols": {
"mcu": {
"symbol_id": "mcu",
"category": "microcontroller",
"body": { "width": 180, "height": 220 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 24, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 56, "type": "ground" },
{ "name": "SCL", "number": "3", "side": "right", "offset": 48, "type": "bidirectional" },
{ "name": "SDA", "number": "4", "side": "right", "offset": 78, "type": "bidirectional" },
{ "name": "INT1", "number": "5", "side": "right", "offset": 122, "type": "input" },
{ "name": "INT2", "number": "6", "side": "right", "offset": 152, "type": "input" }
]
},
"i2c_sensor": {
"symbol_id": "i2c_sensor",
"category": "sensor",
"body": { "width": 140, "height": 120 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 18, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 44, "type": "ground" },
{ "name": "SCL", "number": "3", "side": "left", "offset": 70, "type": "bidirectional" },
{ "name": "SDA", "number": "4", "side": "left", "offset": 96, "type": "bidirectional" },
{ "name": "INT", "number": "5", "side": "right", "offset": 58, "type": "output" }
]
},
"power_source": {
"symbol_id": "power_source",
"category": "power",
"body": { "width": 130, "height": 90 },
"pins": [
{ "name": "3V3", "number": "1", "side": "right", "offset": 26, "type": "power_out" },
{ "name": "GND", "number": "2", "side": "right", "offset": 56, "type": "ground" }
]
}
},
"instances": [
{ "ref": "U1", "symbol": "mcu", "properties": { "value": "ESP32-S3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U2", "symbol": "i2c_sensor", "properties": { "value": "IMU" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U3", "symbol": "i2c_sensor", "properties": { "value": "BME280" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U4", "symbol": "i2c_sensor", "properties": { "value": "Mag" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R1", "part": "resistor", "properties": { "value": "4.7k" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R2", "part": "resistor", "properties": { "value": "4.7k" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "V1", "symbol": "power_source", "properties": { "value": "3V3/GND source" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
],
"nets": [
{ "name": "3V3", "class": "power", "nodes": [
{ "ref": "V1", "pin": "3V3" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" },
{ "ref": "U3", "pin": "3V3" }, { "ref": "U4", "pin": "3V3" }, { "ref": "R1", "pin": "1" }, { "ref": "R2", "pin": "1" }
] },
{ "name": "GND", "class": "ground", "nodes": [
{ "ref": "V1", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" },
{ "ref": "U3", "pin": "GND" }, { "ref": "U4", "pin": "GND" }
] },
{ "name": "I2C_SCL", "class": "clock", "nodes": [
{ "ref": "U1", "pin": "SCL" }, { "ref": "U2", "pin": "SCL" }, { "ref": "U3", "pin": "SCL" },
{ "ref": "U4", "pin": "SCL" }, { "ref": "R1", "pin": "2" }
] },
{ "name": "I2C_SDA", "class": "signal", "nodes": [
{ "ref": "U1", "pin": "SDA" }, { "ref": "U2", "pin": "SDA" }, { "ref": "U3", "pin": "SDA" },
{ "ref": "U4", "pin": "SDA" }, { "ref": "R2", "pin": "2" }
] },
{ "name": "INT1", "class": "signal", "nodes": [ { "ref": "U2", "pin": "INT" }, { "ref": "U1", "pin": "INT1" } ] },
{ "name": "INT2", "class": "signal", "nodes": [ { "ref": "U3", "pin": "INT" }, { "ref": "U1", "pin": "INT2" } ] }
],
"constraints": {
"groups": [
{ "name": "controller", "members": ["U1"], "layout": "cluster" },
{ "name": "sensors", "members": ["U2", "U3", "U4"], "layout": "cluster" },
{ "name": "pullups", "members": ["R1", "R2"], "layout": "cluster" }
]
},
"annotations": [
{ "text": "Shared I2C bus with pull-ups and interrupt lines.", "x": 24, "y": 24 }
]
}

37
examples/power-tree.json Normal file
View File

@ -0,0 +1,37 @@
{
"meta": {
"title": "Dual-Rail Power Tree",
"version": "1.0"
},
"symbols": {},
"instances": [
{ "ref": "J1", "part": "connector", "properties": { "value": "VIN/GND" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U1", "part": "generic", "properties": { "value": "Buck 12V->5V" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U2", "part": "generic", "properties": { "value": "LDO 5V->3V3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U3", "part": "generic", "properties": { "value": "MCU" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U4", "part": "generic", "properties": { "value": "RF Module" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C1", "part": "capacitor", "properties": { "value": "22uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C2", "part": "capacitor", "properties": { "value": "10uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C3", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C4", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
],
"nets": [
{ "name": "VIN", "class": "power", "nodes": [ { "ref": "J1", "pin": "1" }, { "ref": "U1", "pin": "VIN" } ] },
{ "name": "5V", "class": "power", "nodes": [ { "ref": "U1", "pin": "VOUT" }, { "ref": "U2", "pin": "VIN" }, { "ref": "C1", "pin": "1" }, { "ref": "C2", "pin": "1" } ] },
{ "name": "3V3", "class": "power", "nodes": [ { "ref": "U2", "pin": "VOUT" }, { "ref": "U3", "pin": "VCC" }, { "ref": "U4", "pin": "VCC" }, { "ref": "C3", "pin": "1" }, { "ref": "C4", "pin": "1" } ] },
{ "name": "GND", "class": "ground", "nodes": [ { "ref": "J1", "pin": "2" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }, { "ref": "U4", "pin": "GND" }, { "ref": "C1", "pin": "2" }, { "ref": "C2", "pin": "2" }, { "ref": "C3", "pin": "2" }, { "ref": "C4", "pin": "2" } ] },
{ "name": "SPI_CLK", "class": "clock", "nodes": [ { "ref": "U3", "pin": "SCLK" }, { "ref": "U4", "pin": "SCLK" } ] },
{ "name": "SPI_MOSI", "class": "signal", "nodes": [ { "ref": "U3", "pin": "MOSI" }, { "ref": "U4", "pin": "MOSI" } ] },
{ "name": "SPI_MISO", "class": "signal", "nodes": [ { "ref": "U3", "pin": "MISO" }, { "ref": "U4", "pin": "MISO" } ] }
],
"constraints": {
"groups": [
{ "name": "source", "members": ["J1", "U1", "C1"], "layout": "cluster" },
{ "name": "regulation", "members": ["U2", "C2"], "layout": "cluster" },
{ "name": "load", "members": ["U3", "U4", "C3", "C4"], "layout": "cluster" }
]
},
"annotations": [
{ "text": "Power-chain fixture with SPI branch used for readability QA.", "x": 24, "y": 24 }
]
}

View File

@ -2341,13 +2341,9 @@ async function validateJsonEditor() {
try { try {
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
const out = await apiPost("/analyze", { payload: parsed }); const out = await apiPost("/analyze", { payload: parsed });
el.jsonFeedback.textContent = `Validation: ${out.errors.length} errors, ${out.warnings.length} warnings.`;
if (!out.errors.length && !out.warnings.length) {
return;
}
const first = out.errors[0] ?? out.warnings[0]; const first = out.errors[0] ?? out.warnings[0];
el.jsonFeedback.textContent += ` First issue: ${first.message}`; const firstPath = first?.path ? ` (${first.path})` : "";
el.jsonFeedback.textContent = `Validation: ${out.errors.length} errors, ${out.warnings.length} warnings.${first ? ` First issue: ${first.message}${firstPath}` : ""}`;
} catch (err) { } catch (err) {
const p = parseJsonPositionError(text, err); const p = parseJsonPositionError(text, err);
if (p.line != null) { if (p.line != null) {
@ -2365,6 +2361,28 @@ async function validateJsonEditor() {
} }
} }
async function analyzeModelForImport(parsed, sourceName = "JSON") {
const out = await apiPost("/analyze", { payload: parsed });
if (out.errors.length) {
const first = out.errors[0];
const path = first.path ? ` (${first.path})` : "";
const msg = `${sourceName} blocked by validation: ${out.errors.length} error(s), ${out.warnings.length} warning(s). First error: ${first.message}${path}`;
return { ok: false, out, message: msg };
}
const firstWarn = out.warnings[0];
if (firstWarn) {
const path = firstWarn.path ? ` (${firstWarn.path})` : "";
return {
ok: true,
out,
message: `${sourceName} validated: ${out.errors.length} errors, ${out.warnings.length} warnings. First warning: ${firstWarn.message}${path}`
};
}
return { ok: true, out, message: `${sourceName} validated: 0 errors, 0 warnings.` };
}
async function runLayoutAction(path) { async function runLayoutAction(path) {
if (!state.model) { if (!state.model) {
return; return;
@ -3570,6 +3588,11 @@ function setupEvents() {
el.applyJsonBtn.addEventListener("click", async () => { el.applyJsonBtn.addEventListener("click", async () => {
try { try {
const parsed = JSON.parse(el.jsonEditor.value); const parsed = JSON.parse(el.jsonEditor.value);
const analyzed = await analyzeModelForImport(parsed, "Apply JSON");
el.jsonFeedback.textContent = analyzed.message;
if (!analyzed.ok) {
return;
}
const before = state.model ? clone(state.model) : null; const before = state.model ? clone(state.model) : null;
if (state.model) { if (state.model) {
pushHistory("apply-json"); pushHistory("apply-json");
@ -3579,7 +3602,7 @@ function setupEvents() {
state.selectedNet = null; state.selectedNet = null;
state.selectedPin = null; state.selectedPin = null;
await compileModel(parsed, { fit: true }); await compileModel(parsed, { fit: true });
el.jsonFeedback.textContent = summarizeModelDelta(before, state.model); el.jsonFeedback.textContent = `${summarizeModelDelta(before, state.model)} ${analyzed.out.warnings.length ? `Validation warnings: ${analyzed.out.warnings.length}.` : "Validation clean."}`;
} catch (err) { } catch (err) {
const p = parseJsonPositionError(el.jsonEditor.value, err); const p = parseJsonPositionError(el.jsonEditor.value, err);
el.jsonFeedback.textContent = `Apply failed: ${p.message}`; el.jsonFeedback.textContent = `Apply failed: ${p.message}`;
@ -3634,6 +3657,12 @@ function setupEvents() {
try { try {
const content = await file.text(); const content = await file.text();
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
const analyzed = await analyzeModelForImport(parsed, "Import JSON");
if (!analyzed.ok) {
el.jsonFeedback.textContent = analyzed.message;
return;
}
const before = state.model ? clone(state.model) : null;
if (state.model) { if (state.model) {
pushHistory("import-json"); pushHistory("import-json");
} }
@ -3641,6 +3670,7 @@ function setupEvents() {
state.selectedNet = null; state.selectedNet = null;
state.selectedPin = null; state.selectedPin = null;
await compileModel(parsed, { fit: true }); await compileModel(parsed, { fit: true });
el.jsonFeedback.textContent = `${summarizeModelDelta(before, state.model)} Imported from file (${analyzed.out.warnings.length} warnings).`;
} catch (err) { } catch (err) {
setStatus(`Import failed: ${err.message}`, false); setStatus(`Import failed: ${err.message}`, false);
} }

View File

@ -1,91 +1,161 @@
{ {
"meta": { "meta": {
"title": "ESP32 Audio Path" "title": "ESP32 Smart Audio + Sensing Node"
}, },
"symbols": { "symbols": {
"esp32_s3_supermini": { "esp32_s3_supermini": {
"symbol_id": "esp32_s3_supermini", "symbol_id": "esp32_s3_supermini",
"category": "microcontroller", "category": "microcontroller",
"body": { "width": 160, "height": 240 }, "body": { "width": 180, "height": 280 },
"pins": [ "pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 30, "type": "power_in" }, { "name": "3V3", "number": "1", "side": "left", "offset": 24, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 60, "type": "ground" }, { "name": "GND", "number": "2", "side": "left", "offset": 56, "type": "ground" },
{ "name": "GPIO5", "number": "10", "side": "right", "offset": 40, "type": "output" }, { "name": "GPIO5", "number": "10", "side": "right", "offset": 36, "type": "output" },
{ "name": "GPIO6", "number": "11", "side": "right", "offset": 70, "type": "output" }, { "name": "GPIO6", "number": "11", "side": "right", "offset": 66, "type": "output" },
{ "name": "GPIO7", "number": "12", "side": "right", "offset": 100, "type": "output" } { "name": "GPIO7", "number": "12", "side": "right", "offset": 96, "type": "output" },
{ "name": "GPIO8", "number": "13", "side": "right", "offset": 126, "type": "input" },
{ "name": "GPIO9", "number": "14", "side": "right", "offset": 156, "type": "bidirectional" },
{ "name": "GPIO10", "number": "15", "side": "right", "offset": 186, "type": "bidirectional" },
{ "name": "GPIO11", "number": "16", "side": "right", "offset": 216, "type": "output" },
{ "name": "GPIO12", "number": "17", "side": "right", "offset": 246, "type": "output" }
], ],
"graphics": { "graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 240 }] "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 180, "h": 280 }]
} }
}, },
"dac_i2s": { "dac_i2s": {
"symbol_id": "dac_i2s", "symbol_id": "dac_i2s",
"category": "audio", "category": "audio",
"body": { "width": 140, "height": 180 }, "body": { "width": 160, "height": 200 },
"pins": [ "pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 20, "type": "power_in" }, { "name": "3V3", "number": "1", "side": "left", "offset": 24, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" }, { "name": "GND", "number": "2", "side": "left", "offset": 54, "type": "ground" },
{ "name": "BCLK", "number": "3", "side": "left", "offset": 80, "type": "input" }, { "name": "BCLK", "number": "3", "side": "left", "offset": 86, "type": "input" },
{ "name": "LRCLK", "number": "4", "side": "left", "offset": 110, "type": "input" }, { "name": "LRCLK", "number": "4", "side": "left", "offset": 116, "type": "input" },
{ "name": "DIN", "number": "5", "side": "left", "offset": 140, "type": "input" }, { "name": "DIN", "number": "5", "side": "left", "offset": 146, "type": "input" },
{ "name": "AOUT", "number": "6", "side": "right", "offset": 90, "type": "analog" } { "name": "AOUT", "number": "6", "side": "right", "offset": 96, "type": "analog" }
], ],
"graphics": { "graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 140, "h": 180 }] "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 200 }]
} }
}, },
"amp": { "amp": {
"symbol_id": "amp", "symbol_id": "amp",
"category": "output", "category": "output",
"body": { "width": 120, "height": 120 }, "body": { "width": 130, "height": 130 },
"pins": [ "pins": [
{ "name": "5V", "number": "1", "side": "left", "offset": 20, "type": "power_in" }, { "name": "5V", "number": "1", "side": "left", "offset": 22, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" }, { "name": "GND", "number": "2", "side": "left", "offset": 52, "type": "ground" },
{ "name": "IN", "number": "3", "side": "left", "offset": 80, "type": "input" }, { "name": "IN", "number": "3", "side": "left", "offset": 84, "type": "input" },
{ "name": "SPK", "number": "4", "side": "right", "offset": 70, "type": "output" } { "name": "SPK", "number": "4", "side": "right", "offset": 74, "type": "output" }
], ],
"graphics": { "graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }] "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 130, "h": 130 }]
} }
}, },
"psu": { "psu": {
"symbol_id": "psu", "symbol_id": "psu",
"category": "power", "category": "power",
"body": { "width": 120, "height": 120 }, "body": { "width": 140, "height": 140 },
"pins": [ "pins": [
{ "name": "5V_OUT", "number": "1", "side": "right", "offset": 30, "type": "power_out" }, { "name": "5V_OUT", "number": "1", "side": "right", "offset": 34, "type": "power_out" },
{ "name": "3V3_OUT", "number": "2", "side": "right", "offset": 60, "type": "power_out" }, { "name": "3V3_OUT", "number": "2", "side": "right", "offset": 70, "type": "power_out" },
{ "name": "GND", "number": "3", "side": "right", "offset": 90, "type": "ground" } { "name": "GND", "number": "3", "side": "right", "offset": 104, "type": "ground" }
], ],
"graphics": { "graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }] "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 140, "h": 140 }]
} }
},
"imu_i2c": {
"symbol_id": "imu_i2c",
"category": "sensor",
"body": { "width": 140, "height": 120 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 18, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 44, "type": "ground" },
{ "name": "SCL", "number": "3", "side": "left", "offset": 70, "type": "bidirectional" },
{ "name": "SDA", "number": "4", "side": "left", "offset": 96, "type": "bidirectional" }
]
},
"oled_i2c": {
"symbol_id": "oled_i2c",
"category": "display",
"body": { "width": 140, "height": 120 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 18, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 44, "type": "ground" },
{ "name": "SCL", "number": "3", "side": "left", "offset": 70, "type": "bidirectional" },
{ "name": "SDA", "number": "4", "side": "left", "offset": 96, "type": "bidirectional" }
]
},
"mic_pre": {
"symbol_id": "mic_pre",
"category": "analog",
"body": { "width": 150, "height": 120 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 48, "type": "ground" },
{ "name": "OUT", "number": "3", "side": "right", "offset": 62, "type": "analog" },
{ "name": "EN", "number": "4", "side": "left", "offset": 90, "type": "input" }
]
},
"debug_header": {
"symbol_id": "debug_header",
"category": "connector",
"body": { "width": 130, "height": 80 },
"pins": [
{ "name": "1", "number": "1", "side": "right", "offset": 24, "type": "bidirectional" },
{ "name": "GND", "number": "2", "side": "left", "offset": 24, "type": "ground" }
]
} }
}, },
"instances": [ "instances": [
{ "ref": "U1", "symbol": "esp32_s3_supermini", "properties": { "value": "ESP32-S3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, { "ref": "U1", "symbol": "esp32_s3_supermini", "properties": { "value": "ESP32-S3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U2", "symbol": "dac_i2s", "properties": { "value": "DAC" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, { "ref": "U2", "symbol": "dac_i2s", "properties": { "value": "DAC" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U3", "symbol": "amp", "properties": { "value": "Amp" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, { "ref": "U3", "symbol": "amp", "properties": { "value": "Class-D Amp" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U4", "symbol": "psu", "properties": { "value": "Power" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } } { "ref": "U4", "symbol": "psu", "properties": { "value": "5V/3V3 Power" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U5", "symbol": "imu_i2c", "properties": { "value": "IMU" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U6", "symbol": "oled_i2c", "properties": { "value": "OLED" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U7", "symbol": "mic_pre", "properties": { "value": "Mic Preamp" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "J1", "symbol": "debug_header", "properties": { "value": "Debug Header" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R1", "part": "resistor", "properties": { "value": "10k" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "R2", "part": "resistor", "properties": { "value": "10k" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C1", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C2", "part": "capacitor", "properties": { "value": "100nF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "C3", "part": "capacitor", "properties": { "value": "1uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
], ],
"nets": [ "nets": [
{ "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }] }, { "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }, { "ref": "U5", "pin": "3V3" }, { "ref": "U6", "pin": "3V3" }, { "ref": "U7", "pin": "3V3" }, { "ref": "R1", "pin": "1" }, { "ref": "R2", "pin": "1" }, { "ref": "C1", "pin": "1" }, { "ref": "C2", "pin": "1" }, { "ref": "C3", "pin": "1" }] },
{ "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] }, { "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] },
{ "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }] }, { "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }, { "ref": "U5", "pin": "GND" }, { "ref": "U6", "pin": "GND" }, { "ref": "U7", "pin": "GND" }, { "ref": "J1", "pin": "GND" }, { "ref": "R1", "pin": "2" }, { "ref": "R2", "pin": "2" }, { "ref": "C1", "pin": "2" }, { "ref": "C2", "pin": "2" }, { "ref": "C3", "pin": "2" }] },
{ "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] }, { "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] },
{ "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] }, { "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] },
{ "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] }, { "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] },
{ "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] } { "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] },
{ "name": "I2C_SCL", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO9" }, { "ref": "U5", "pin": "SCL" }, { "ref": "U6", "pin": "SCL" }] },
{ "name": "I2C_SDA", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO10" }, { "ref": "U5", "pin": "SDA" }, { "ref": "U6", "pin": "SDA" }] },
{ "name": "MIC_ADC", "class": "analog", "nodes": [{ "ref": "U7", "pin": "OUT" }, { "ref": "U1", "pin": "GPIO8" }, { "ref": "C3", "pin": "1" }] },
{ "name": "MIC_EN", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO11" }, { "ref": "U7", "pin": "EN" }] },
{ "name": "DEBUG_TX", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO12" }, { "ref": "J1", "pin": "1" }] }
], ],
"constraints": { "constraints": {
"groups": [ "groups": [
{ "name": "power_stage", "members": ["U4"], "layout": "cluster" }, { "name": "power_stage", "members": ["U4", "C1", "C2"], "layout": "cluster" },
{ "name": "compute", "members": ["U1", "U2"], "layout": "cluster" } { "name": "compute_audio", "members": ["U1", "U2", "U3", "U7"], "layout": "cluster" },
{ "name": "i2c_peripherals", "members": ["U5", "U6", "R1", "R2"], "layout": "cluster" },
{ "name": "debug", "members": ["J1"], "layout": "cluster" }
], ],
"alignment": [{ "left_of": "U1", "right_of": "U2" }], "alignment": [
"near": [{ "component": "U2", "target_pin": { "ref": "U1", "pin": "GPIO5" } }] { "left_of": "U1", "right_of": "U2" },
{ "left_of": "U2", "right_of": "U3" }
],
"near": [
{ "component": "C1", "target_pin": { "ref": "U1", "pin": "3V3" } },
{ "component": "C2", "target_pin": { "ref": "U2", "pin": "3V3" } },
{ "component": "C3", "target_pin": { "ref": "U7", "pin": "OUT" } }
]
}, },
"annotations": [ "annotations": [
{ "text": "I2S audio chain" } { "text": "Smart audio + sensing node with I2S DAC, class-D amp, I2C peripherals, and mic ADC front-end." }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 178 KiB

View File

@ -10,11 +10,24 @@ import { chromium } from "playwright";
const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === "1"; const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === "1";
const MAX_DIFF_PIXELS = Number(process.env.UI_MAX_DIFF_PIXELS ?? 220); const MAX_DIFF_PIXELS = Number(process.env.UI_MAX_DIFF_PIXELS ?? 220);
const SAMPLE_MAX_CROSSINGS = Number(process.env.UI_SAMPLE_MAX_CROSSINGS ?? 1);
const SAMPLE_MAX_OVERLAPS = Number(process.env.UI_SAMPLE_MAX_OVERLAPS ?? 1);
const SAMPLE_MAX_DETOUR = Number(process.env.UI_SAMPLE_MAX_DETOUR ?? 3.2);
const DRAG_MAX_CROSSINGS = Number(process.env.UI_DRAG_MAX_CROSSINGS ?? 3);
const DRAG_MAX_OVERLAPS = Number(process.env.UI_DRAG_MAX_OVERLAPS ?? 3);
const DRAG_MAX_DETOUR = Number(process.env.UI_DRAG_MAX_DETOUR ?? 3.5);
const TIDY_MAX_CROSSINGS = Number(process.env.UI_TIDY_MAX_CROSSINGS ?? 2);
const TIDY_MAX_OVERLAPS = Number(process.env.UI_TIDY_MAX_OVERLAPS ?? 2);
const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 2.0);
const DENSE_MAX_CROSSINGS = Number(process.env.UI_DENSE_MAX_CROSSINGS ?? 2);
const DENSE_MAX_OVERLAPS = Number(process.env.UI_DENSE_MAX_OVERLAPS ?? 2);
const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 3.0);
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui"); const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
const OUTPUT_DIR = join(process.cwd(), "output", "playwright"); const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
const CURRENT_DIR = join(OUTPUT_DIR, "current"); const CURRENT_DIR = join(OUTPUT_DIR, "current");
const DIFF_DIR = join(OUTPUT_DIR, "diff"); const DIFF_DIR = join(OUTPUT_DIR, "diff");
const DENSE_ANALOG_PATH = join(process.cwd(), "examples", "dense-analog.json"); const DENSE_ANALOG_PATH = join(process.cwd(), "examples", "dense-analog.json");
const REPORT_PATH = join(OUTPUT_DIR, "ui-metrics-report.json");
async function getFreePort() { async function getFreePort() {
const probe = createNetServer(); const probe = createNetServer();
@ -138,13 +151,32 @@ async function run() {
const srv = await startServer(port); const srv = await startServer(port);
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
const report = {
generated_at: new Date().toISOString(),
thresholds: {
sample: { crossings: SAMPLE_MAX_CROSSINGS, overlaps: SAMPLE_MAX_OVERLAPS, detour: SAMPLE_MAX_DETOUR },
drag: { crossings: DRAG_MAX_CROSSINGS, overlaps: DRAG_MAX_OVERLAPS, detour: DRAG_MAX_DETOUR },
tidy: { crossings: TIDY_MAX_CROSSINGS, overlaps: TIDY_MAX_OVERLAPS, detour: TIDY_MAX_DETOUR },
dense: { crossings: DENSE_MAX_CROSSINGS, overlaps: DENSE_MAX_OVERLAPS, detour: DENSE_MAX_DETOUR }
},
snapshots: {},
status: {}
};
try { try {
await page.goto(`${srv.baseUrl}/`); await page.goto(`${srv.baseUrl}/`);
await page.waitForSelector("#compileStatus"); await page.waitForSelector("#compileStatus");
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled")); await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const initialStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
const initialMetrics = parseStatusMetrics(initialStatus);
report.status.initial = { text: initialStatus, metrics: initialMetrics };
assert.ok(initialMetrics, `Unable to parse initial compile metrics from: ${initialStatus}`);
assert.ok(initialMetrics.crossings <= SAMPLE_MAX_CROSSINGS, `sample crossings too high: ${initialMetrics.crossings}`);
assert.ok(initialMetrics.overlaps <= SAMPLE_MAX_OVERLAPS, `sample overlaps too high: ${initialMetrics.overlaps}`);
assert.ok(initialMetrics.detour <= SAMPLE_MAX_DETOUR, `sample detour too high: ${initialMetrics.detour}`);
await compareScene(page, "initial"); await compareScene(page, "initial");
report.snapshots.initial = "tests/baselines/ui/initial.png";
await page.keyboard.press("Control+k"); await page.keyboard.press("Control+k");
await page.getByRole("dialog", { name: "Command Palette" }).waitFor(); await page.getByRole("dialog", { name: "Command Palette" }).waitFor();
await page.locator("#commandInput").fill("fit"); await page.locator("#commandInput").fill("fit");
@ -161,6 +193,32 @@ async function run() {
await page.getByRole("button", { name: "Reset view" }).click(); await page.getByRole("button", { name: "Reset view" }).click();
await compareScene(page, "selected-u2"); await compareScene(page, "selected-u2");
const target = page.locator('[data-ref="U2"]');
const box = await target.boundingBox();
assert.ok(box, "selected U2 should be draggable");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 320, box.y + box.height / 2 + 220, { steps: 12 });
await page.mouse.up();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const dragStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
const dragMetrics = parseStatusMetrics(dragStatus);
report.status.after_drag = { text: dragStatus, metrics: dragMetrics };
assert.ok(dragMetrics, `Unable to parse drag compile metrics from: ${dragStatus}`);
assert.ok(dragMetrics.crossings <= DRAG_MAX_CROSSINGS, `dragged layout crossings too high: ${dragMetrics.crossings}`);
assert.ok(dragMetrics.overlaps <= DRAG_MAX_OVERLAPS, `dragged layout overlaps too high: ${dragMetrics.overlaps}`);
assert.ok(dragMetrics.detour <= DRAG_MAX_DETOUR, `dragged layout detour too high: ${dragMetrics.detour}`);
await page.getByRole("button", { name: "Run automatic tidy layout" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const tidyStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
const tidyMetrics = parseStatusMetrics(tidyStatus);
report.status.after_drag_tidy = { text: tidyStatus, metrics: tidyMetrics };
assert.ok(tidyMetrics, `Unable to parse tidy compile metrics from: ${tidyStatus}`);
assert.ok(tidyMetrics.crossings <= TIDY_MAX_CROSSINGS, `drag+tidy crossings too high: ${tidyMetrics.crossings}`);
assert.ok(tidyMetrics.overlaps <= TIDY_MAX_OVERLAPS, `drag+tidy overlaps too high: ${tidyMetrics.overlaps}`);
assert.ok(tidyMetrics.detour <= TIDY_MAX_DETOUR, `drag+tidy detour too high: ${tidyMetrics.detour}`);
await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } }); await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } });
await expectText(page, "#selectedSummary", /Click a component, net, or pin/); await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
@ -210,29 +268,47 @@ async function run() {
await page.getByRole("button", { name: "Add Component" }).click(); await page.getByRole("button", { name: "Add Component" }).click();
await waitFor(async () => (await page.locator("[data-ref-item]").count()) >= beforeInstanceCount + 1); await waitFor(async () => (await page.locator("[data-ref-item]").count()) >= beforeInstanceCount + 1);
await page.locator("#newQuickNetClassSelect").selectOption("signal");
await page.locator("#newQuickNetNameInput").fill("UI_TEST_NET");
await page.getByRole("button", { name: "Add Net" }).click();
await expectText(page, "#netList", /UI_TEST_NET/);
const dense = await readFile(DENSE_ANALOG_PATH, "utf8"); const dense = await readFile(DENSE_ANALOG_PATH, "utf8");
await page.locator("#jsonEditor").fill(dense); await page.locator("#jsonEditor").fill(dense);
await page.getByRole("button", { name: "Apply JSON" }).click(); await page.getByRole("button", { name: "Apply JSON" }).click();
await expectText(page, "#jsonFeedback", /Applied JSON|blocked by validation/);
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled")); await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const denseStatus = String((await page.locator("#compileStatus").textContent()) ?? ""); const denseStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
const denseFeedback = String((await page.locator("#jsonFeedback").textContent()) ?? "");
assert.ok(!/blocked by validation/i.test(denseFeedback), `dense fixture apply unexpectedly blocked: ${denseFeedback}`);
const metrics = parseStatusMetrics(denseStatus); const metrics = parseStatusMetrics(denseStatus);
report.status.dense = { text: denseStatus, metrics, feedback: denseFeedback };
assert.ok(metrics, `Unable to parse compile status metrics from: ${denseStatus}`); assert.ok(metrics, `Unable to parse compile status metrics from: ${denseStatus}`);
assert.equal(metrics.errors, 0, "dense analog scene should compile with no errors"); assert.equal(metrics.errors, 0, `dense analog scene should compile with no errors (feedback: ${denseFeedback})`);
assert.ok(metrics.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`); assert.ok(metrics.crossings <= DENSE_MAX_CROSSINGS, `dense analog crossings too high: ${metrics.crossings}`);
assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`); assert.ok(metrics.overlaps <= DENSE_MAX_OVERLAPS, `dense analog overlaps too high: ${metrics.overlaps}`);
assert.ok(metrics.detour <= DENSE_MAX_DETOUR, `dense analog detour too high: ${metrics.detour}`);
await compareScene(page, "dense-analog"); await compareScene(page, "dense-analog");
report.snapshots.dense = "tests/baselines/ui/dense-analog.png";
await page.setViewportSize({ width: 1280, height: 720 }); await page.setViewportSize({ width: 1280, height: 720 });
await page.getByRole("button", { name: "Fit schematic to viewport" }).click(); await page.getByRole("button", { name: "Fit schematic to viewport" }).click();
await expectText(page, "#compileStatus", /Compiled/); await expectText(page, "#compileStatus", /Compiled/);
assert.ok(await page.locator("#applyJsonBtn").isVisible(), "Apply JSON button should remain visible at laptop viewport"); assert.ok(await page.locator("#applyJsonBtn").isVisible(), "Apply JSON button should remain visible at laptop viewport");
await compareScene(page, "laptop-viewport"); await compareScene(page, "laptop-viewport");
report.snapshots.laptop = "tests/baselines/ui/laptop-viewport.png";
const compileBeforeInvalidApply = String((await page.locator("#compileStatus").textContent()) ?? "");
await page.locator("#jsonEditor").fill("{\"meta\":{\"title\":\"invalid\"},\"instances\":[],\"nets\":[]}");
await page.getByRole("button", { name: "Apply JSON" }).click();
await expectText(page, "#jsonFeedback", /blocked by validation/i);
const compileAfterInvalidApply = String((await page.locator("#compileStatus").textContent()) ?? "");
assert.equal(
compileAfterInvalidApply,
compileBeforeInvalidApply,
"invalid JSON apply should not mutate compiled workspace state"
);
report.result = "pass";
} finally { } finally {
if (!report.result) {
report.result = "fail";
}
await writeFile(REPORT_PATH, `${JSON.stringify(report, null, 2)}\n`);
await page.close().catch(() => {}); await page.close().catch(() => {});
await browser.close().catch(() => {}); await browser.close().catch(() => {});
await srv.stop().catch(() => {}); await srv.stop().catch(() => {});