Phase 5: ship layout QA budgets, import hardening, and fixture pack
Some checks are pending
CI / test (push) Waiting to run
@ -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: |
|
||||||
|
|||||||
@ -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
@ -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.
|
||||||
49
docs/phase5-execution-plan.md
Normal 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.
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
81
examples/i2c-sensor-hub.json
Normal 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
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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." }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 178 KiB |
@ -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(() => {});
|
||||||
|
|||||||