diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0cac3fe..7e672cd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -39,6 +39,12 @@ jobs: - name: Run browser regression 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) if: always() run: | diff --git a/README.md b/README.md index 18a0536..6751c3f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Docs: - `docs/operations-runbook.md` - `docs/quality-gates.md` - `docs/phase4-execution-plan.md` +- `docs/phase5-execution-plan.md` +- `docs/fixtures.md` - `docs/api-mcp-contracts.md` CI: @@ -186,6 +188,9 @@ npx playwright install chromium npm run test:ui ``` +Metrics report artifact: +- `output/playwright/ui-metrics-report.json` + Refresh visual baselines intentionally: ```bash diff --git a/docs/fixtures.md b/docs/fixtures.md new file mode 100644 index 0000000..7485a6c --- /dev/null +++ b/docs/fixtures.md @@ -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. diff --git a/docs/phase5-execution-plan.md b/docs/phase5-execution-plan.md new file mode 100644 index 0000000..66d5900 --- /dev/null +++ b/docs/phase5-execution-plan.md @@ -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. diff --git a/docs/quality-gates.md b/docs/quality-gates.md index cab0fc8..5eb22e9 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -14,10 +14,13 @@ This document defines measurable release gates for Schemeta. - `npm run test:ui` passes. 2. Visual regression - No unexpected screenshot diffs in `tests/baselines/ui`. - - Dense analog fixture remains under threshold: - - crossings = `0` - - overlaps = `0` - - detour <= `2.5` + - UI budget thresholds (defaults in `tests/ui-regression-runner.js`) are met: + - sample: crossings <= `1`, overlaps <= `1`, detour <= `3.2` + - drag: crossings <= `3`, overlaps <= `3`, detour <= `3.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 - Selection/deselection/isolate/reset flow verified. - 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 - `npm test` - `npm run test:ui` + - verify `output/playwright/ui-metrics-report.json` was produced - Release candidate required: - checklist completion in `docs/release-checklist.md` - intentional baseline updates reviewed and approved diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d0c6aad..3bf3e0e 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -4,6 +4,7 @@ Use this checklist before cutting a release tag. Reference docs: - `docs/quality-gates.md` - `docs/phase4-execution-plan.md` +- `docs/phase5-execution-plan.md` ## Pre-merge @@ -30,7 +31,8 @@ Reference docs: - [ ] Visual baselines updated intentionally (`tests/baselines/ui`) and screenshot diff checks pass. - [ ] Labels remain legible at common zoom levels. - [ ] 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 diff --git a/examples/i2c-sensor-hub.json b/examples/i2c-sensor-hub.json new file mode 100644 index 0000000..7098309 --- /dev/null +++ b/examples/i2c-sensor-hub.json @@ -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 } + ] +} diff --git a/examples/power-tree.json b/examples/power-tree.json new file mode 100644 index 0000000..735d7c6 --- /dev/null +++ b/examples/power-tree.json @@ -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 } + ] +} diff --git a/frontend/app.js b/frontend/app.js index 6e39d55..94ee5d6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2341,13 +2341,9 @@ async function validateJsonEditor() { try { const parsed = JSON.parse(text); 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]; - 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) { const p = parseJsonPositionError(text, err); 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) { if (!state.model) { return; @@ -3570,6 +3588,11 @@ function setupEvents() { el.applyJsonBtn.addEventListener("click", async () => { try { 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; if (state.model) { pushHistory("apply-json"); @@ -3579,7 +3602,7 @@ function setupEvents() { state.selectedNet = null; state.selectedPin = null; 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) { const p = parseJsonPositionError(el.jsonEditor.value, err); el.jsonFeedback.textContent = `Apply failed: ${p.message}`; @@ -3634,6 +3657,12 @@ function setupEvents() { try { const content = await file.text(); 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) { pushHistory("import-json"); } @@ -3641,6 +3670,7 @@ function setupEvents() { state.selectedNet = null; state.selectedPin = null; await compileModel(parsed, { fit: true }); + el.jsonFeedback.textContent = `${summarizeModelDelta(before, state.model)} Imported from file (${analyzed.out.warnings.length} warnings).`; } catch (err) { setStatus(`Import failed: ${err.message}`, false); } diff --git a/frontend/sample.schemeta.json b/frontend/sample.schemeta.json index f478f22..0db5e1d 100644 --- a/frontend/sample.schemeta.json +++ b/frontend/sample.schemeta.json @@ -1,91 +1,161 @@ { "meta": { - "title": "ESP32 Audio Path" + "title": "ESP32 Smart Audio + Sensing Node" }, "symbols": { "esp32_s3_supermini": { "symbol_id": "esp32_s3_supermini", "category": "microcontroller", - "body": { "width": 160, "height": 240 }, + "body": { "width": 180, "height": 280 }, "pins": [ - { "name": "3V3", "number": "1", "side": "left", "offset": 30, "type": "power_in" }, - { "name": "GND", "number": "2", "side": "left", "offset": 60, "type": "ground" }, - { "name": "GPIO5", "number": "10", "side": "right", "offset": 40, "type": "output" }, - { "name": "GPIO6", "number": "11", "side": "right", "offset": 70, "type": "output" }, - { "name": "GPIO7", "number": "12", "side": "right", "offset": 100, "type": "output" } + { "name": "3V3", "number": "1", "side": "left", "offset": 24, "type": "power_in" }, + { "name": "GND", "number": "2", "side": "left", "offset": 56, "type": "ground" }, + { "name": "GPIO5", "number": "10", "side": "right", "offset": 36, "type": "output" }, + { "name": "GPIO6", "number": "11", "side": "right", "offset": 66, "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": { - "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 240 }] + "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 180, "h": 280 }] } }, "dac_i2s": { "symbol_id": "dac_i2s", "category": "audio", - "body": { "width": 140, "height": 180 }, + "body": { "width": 160, "height": 200 }, "pins": [ - { "name": "3V3", "number": "1", "side": "left", "offset": 20, "type": "power_in" }, - { "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" }, - { "name": "BCLK", "number": "3", "side": "left", "offset": 80, "type": "input" }, - { "name": "LRCLK", "number": "4", "side": "left", "offset": 110, "type": "input" }, - { "name": "DIN", "number": "5", "side": "left", "offset": 140, "type": "input" }, - { "name": "AOUT", "number": "6", "side": "right", "offset": 90, "type": "analog" } + { "name": "3V3", "number": "1", "side": "left", "offset": 24, "type": "power_in" }, + { "name": "GND", "number": "2", "side": "left", "offset": 54, "type": "ground" }, + { "name": "BCLK", "number": "3", "side": "left", "offset": 86, "type": "input" }, + { "name": "LRCLK", "number": "4", "side": "left", "offset": 116, "type": "input" }, + { "name": "DIN", "number": "5", "side": "left", "offset": 146, "type": "input" }, + { "name": "AOUT", "number": "6", "side": "right", "offset": 96, "type": "analog" } ], "graphics": { - "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 140, "h": 180 }] + "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 200 }] } }, "amp": { "symbol_id": "amp", "category": "output", - "body": { "width": 120, "height": 120 }, + "body": { "width": 130, "height": 130 }, "pins": [ - { "name": "5V", "number": "1", "side": "left", "offset": 20, "type": "power_in" }, - { "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" }, - { "name": "IN", "number": "3", "side": "left", "offset": 80, "type": "input" }, - { "name": "SPK", "number": "4", "side": "right", "offset": 70, "type": "output" } + { "name": "5V", "number": "1", "side": "left", "offset": 22, "type": "power_in" }, + { "name": "GND", "number": "2", "side": "left", "offset": 52, "type": "ground" }, + { "name": "IN", "number": "3", "side": "left", "offset": 84, "type": "input" }, + { "name": "SPK", "number": "4", "side": "right", "offset": 74, "type": "output" } ], "graphics": { - "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }] + "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 130, "h": 130 }] } }, "psu": { "symbol_id": "psu", "category": "power", - "body": { "width": 120, "height": 120 }, + "body": { "width": 140, "height": 140 }, "pins": [ - { "name": "5V_OUT", "number": "1", "side": "right", "offset": 30, "type": "power_out" }, - { "name": "3V3_OUT", "number": "2", "side": "right", "offset": 60, "type": "power_out" }, - { "name": "GND", "number": "3", "side": "right", "offset": 90, "type": "ground" } + { "name": "5V_OUT", "number": "1", "side": "right", "offset": 34, "type": "power_out" }, + { "name": "3V3_OUT", "number": "2", "side": "right", "offset": 70, "type": "power_out" }, + { "name": "GND", "number": "3", "side": "right", "offset": 104, "type": "ground" } ], "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": [ { "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": "U3", "symbol": "amp", "properties": { "value": "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": "U3", "symbol": "amp", "properties": { "value": "Class-D Amp" }, "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": [ - { "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": "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_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": "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": { "groups": [ - { "name": "power_stage", "members": ["U4"], "layout": "cluster" }, - { "name": "compute", "members": ["U1", "U2"], "layout": "cluster" } + { "name": "power_stage", "members": ["U4", "C1", "C2"], "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" }], - "near": [{ "component": "U2", "target_pin": { "ref": "U1", "pin": "GPIO5" } }] + "alignment": [ + { "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": [ - { "text": "I2S audio chain" } + { "text": "Smart audio + sensing node with I2S DAC, class-D amp, I2C peripherals, and mic ADC front-end." } ] } diff --git a/tests/baselines/ui/dense-analog.png b/tests/baselines/ui/dense-analog.png index a9cd11b..2c2df17 100644 Binary files a/tests/baselines/ui/dense-analog.png and b/tests/baselines/ui/dense-analog.png differ diff --git a/tests/baselines/ui/explicit-mode-auto-tidy.png b/tests/baselines/ui/explicit-mode-auto-tidy.png index 78d2e2c..969cb56 100644 Binary files a/tests/baselines/ui/explicit-mode-auto-tidy.png and b/tests/baselines/ui/explicit-mode-auto-tidy.png differ diff --git a/tests/baselines/ui/initial.png b/tests/baselines/ui/initial.png index 8e85b92..67683fa 100644 Binary files a/tests/baselines/ui/initial.png and b/tests/baselines/ui/initial.png differ diff --git a/tests/baselines/ui/laptop-viewport.png b/tests/baselines/ui/laptop-viewport.png index 8c71059..ef44518 100644 Binary files a/tests/baselines/ui/laptop-viewport.png and b/tests/baselines/ui/laptop-viewport.png differ diff --git a/tests/baselines/ui/post-migration-apply.png b/tests/baselines/ui/post-migration-apply.png index 487428a..1717806 100644 Binary files a/tests/baselines/ui/post-migration-apply.png and b/tests/baselines/ui/post-migration-apply.png differ diff --git a/tests/baselines/ui/selected-u2.png b/tests/baselines/ui/selected-u2.png index 6a1f086..71bf0f9 100644 Binary files a/tests/baselines/ui/selected-u2.png and b/tests/baselines/ui/selected-u2.png differ diff --git a/tests/ui-regression-runner.js b/tests/ui-regression-runner.js index bc0934a..86a78b4 100644 --- a/tests/ui-regression-runner.js +++ b/tests/ui-regression-runner.js @@ -10,11 +10,24 @@ import { chromium } from "playwright"; const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === "1"; 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 OUTPUT_DIR = join(process.cwd(), "output", "playwright"); const CURRENT_DIR = join(OUTPUT_DIR, "current"); const DIFF_DIR = join(OUTPUT_DIR, "diff"); const DENSE_ANALOG_PATH = join(process.cwd(), "examples", "dense-analog.json"); +const REPORT_PATH = join(OUTPUT_DIR, "ui-metrics-report.json"); async function getFreePort() { const probe = createNetServer(); @@ -138,13 +151,32 @@ async function run() { const srv = await startServer(port); const browser = await chromium.launch({ headless: true }); 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 { await page.goto(`${srv.baseUrl}/`); await page.waitForSelector("#compileStatus"); 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"); + report.snapshots.initial = "tests/baselines/ui/initial.png"; await page.keyboard.press("Control+k"); await page.getByRole("dialog", { name: "Command Palette" }).waitFor(); await page.locator("#commandInput").fill("fit"); @@ -161,6 +193,32 @@ async function run() { await page.getByRole("button", { name: "Reset view" }).click(); 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 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 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"); await page.locator("#jsonEditor").fill(dense); 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")); 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); + report.status.dense = { text: denseStatus, metrics, feedback: denseFeedback }; 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.ok(metrics.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`); - assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`); + assert.equal(metrics.errors, 0, `dense analog scene should compile with no errors (feedback: ${denseFeedback})`); + assert.ok(metrics.crossings <= DENSE_MAX_CROSSINGS, `dense analog crossings too high: ${metrics.crossings}`); + 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"); + report.snapshots.dense = "tests/baselines/ui/dense-analog.png"; await page.setViewportSize({ width: 1280, height: 720 }); await page.getByRole("button", { name: "Fit schematic to viewport" }).click(); await expectText(page, "#compileStatus", /Compiled/); assert.ok(await page.locator("#applyJsonBtn").isVisible(), "Apply JSON button should remain visible at 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 { + if (!report.result) { + report.result = "fail"; + } + await writeFile(REPORT_PATH, `${JSON.stringify(report, null, 2)}\n`); await page.close().catch(() => {}); await browser.close().catch(() => {}); await srv.stop().catch(() => {});