Ship Phase 3 browser QA, visual baselines, and UX consistency
This commit is contained in:
parent
347d547875
commit
d5836a20d1
@ -32,3 +32,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install chromium
|
||||||
|
|
||||||
|
- name: Run browser regression
|
||||||
|
run: npm run test:ui
|
||||||
|
|
||||||
|
- name: Browser artifacts listing (always)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Playwright output:"
|
||||||
|
ls -R output/playwright || true
|
||||||
|
|||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.playwright-cli/
|
||||||
|
output/playwright/
|
||||||
|
node_modules/
|
||||||
19
README.md
19
README.md
@ -40,6 +40,7 @@ Docs:
|
|||||||
|
|
||||||
CI:
|
CI:
|
||||||
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
|
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
|
||||||
|
- CI also runs browser regression (`npm run test:ui`) after installing Playwright Chromium.
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
@ -155,6 +156,7 @@ Tools:
|
|||||||
## Workspace behavior highlights
|
## Workspace behavior highlights
|
||||||
|
|
||||||
- Fit-to-view default on load/import/apply
|
- Fit-to-view default on load/import/apply
|
||||||
|
- `Reset Sample` one-click deterministic baseline restore for QA/demo loops
|
||||||
- Space + drag pan, wheel zoom, fit button
|
- Space + drag pan, wheel zoom, fit button
|
||||||
- Net/component/pin selection with dimming + isolate toggles
|
- Net/component/pin selection with dimming + isolate toggles
|
||||||
- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
|
- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
|
||||||
@ -166,3 +168,20 @@ Tools:
|
|||||||
- `Space` rotate selected components (or pan when no selection)
|
- `Space` rotate selected components (or pan when no selection)
|
||||||
- `Alt+Enter` apply current selection editor (component/pin/net)
|
- `Alt+Enter` apply current selection editor (component/pin/net)
|
||||||
- `Alt+C` connect selected pin to chosen net
|
- `Alt+C` connect selected pin to chosen net
|
||||||
|
|
||||||
|
## Browser Regression
|
||||||
|
|
||||||
|
Run browser interaction + visual regression checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh visual baselines intentionally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UPDATE_SNAPSHOTS=1 npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Baselines are stored in `tests/baselines/ui/`.
|
||||||
|
|||||||
@ -11,6 +11,7 @@ Use this checklist before cutting a release tag.
|
|||||||
## Validation Gates
|
## Validation Gates
|
||||||
|
|
||||||
- [ ] `npm test` passes.
|
- [ ] `npm test` passes.
|
||||||
|
- [ ] `npm run test:ui` passes.
|
||||||
- [ ] Core smoke flow tested in UI:
|
- [ ] Core smoke flow tested in UI:
|
||||||
- [ ] Load sample
|
- [ ] Load sample
|
||||||
- [ ] Edit component/pin/net/symbol
|
- [ ] Edit component/pin/net/symbol
|
||||||
@ -22,6 +23,7 @@ Use this checklist before cutting a release tag.
|
|||||||
## Visual Quality
|
## Visual Quality
|
||||||
|
|
||||||
- [ ] Representative circuits reviewed for routing readability.
|
- [ ] Representative circuits reviewed for routing readability.
|
||||||
|
- [ ] 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.
|
||||||
|
|
||||||
|
|||||||
151
examples/dense-analog.json
Normal file
151
examples/dense-analog.json
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "Electret mic + NPN preamp -> ESP32 ADC (dense analog)",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"symbols": {
|
||||||
|
"electret_mic_2pin": {
|
||||||
|
"symbol_id": "electret_mic_2pin",
|
||||||
|
"category": "analog",
|
||||||
|
"body": { "width": 90, "height": 56 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "MIC+", "number": "1", "side": "right", "offset": 20, "type": "analog" },
|
||||||
|
{ "name": "MIC-", "number": "2", "side": "right", "offset": 38, "type": "ground" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"npn_bjt_generic": {
|
||||||
|
"symbol_id": "npn_bjt_generic",
|
||||||
|
"category": "analog",
|
||||||
|
"body": { "width": 92, "height": 74 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "B", "number": "1", "side": "left", "offset": 37, "type": "analog" },
|
||||||
|
{ "name": "C", "number": "2", "side": "top", "offset": 46, "type": "analog" },
|
||||||
|
{ "name": "E", "number": "3", "side": "bottom", "offset": 46, "type": "analog" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mcu_adc_1pin": {
|
||||||
|
"symbol_id": "mcu_adc_1pin",
|
||||||
|
"category": "generic",
|
||||||
|
"body": { "width": 126, "height": 64 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "ADC_IN", "number": "1", "side": "left", "offset": 32, "type": "analog" },
|
||||||
|
{ "name": "3V3", "number": "2", "side": "top", "offset": 32, "type": "power_in" },
|
||||||
|
{ "name": "GND", "number": "3", "side": "bottom", "offset": 32, "type": "ground" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instances": [
|
||||||
|
{ "ref": "V1", "part": "connector", "properties": { "value": "3.3V/GND source" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "M1", "symbol": "electret_mic_2pin", "properties": { "value": "Electret mic" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "Q1", "symbol": "npn_bjt_generic", "properties": { "value": "2N3904" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "R1", "part": "resistor", "properties": { "value": "4.7k", "role": "mic bias" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C1", "part": "capacitor", "properties": { "value": "1uF", "role": "mic AC coupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "R2", "part": "resistor", "properties": { "value": "220k", "role": "base bias top" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "R3", "part": "resistor", "properties": { "value": "100k", "role": "base bias bottom" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "RC", "part": "resistor", "properties": { "value": "47k", "role": "collector resistor" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "RE", "part": "resistor", "properties": { "value": "1k", "role": "emitter resistor" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C2", "part": "capacitor", "properties": { "value": "1uF", "role": "output AC coupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "R4", "part": "resistor", "properties": { "value": "100k", "role": "ADC mid-rail top" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "R5", "part": "resistor", "properties": { "value": "100k", "role": "ADC mid-rail bottom" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C3", "part": "capacitor", "properties": { "value": "100nF", "role": "ADC mid-rail filter" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "C4", "part": "capacitor", "properties": { "value": "100nF", "role": "local decoupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U1", "symbol": "mcu_adc_1pin", "properties": { "value": "ESP32-S3 (ADC pin)" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
|
||||||
|
],
|
||||||
|
"nets": [
|
||||||
|
{
|
||||||
|
"name": "3V3",
|
||||||
|
"class": "power",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "V1", "pin": "3V3" },
|
||||||
|
{ "ref": "R1", "pin": "1" },
|
||||||
|
{ "ref": "R2", "pin": "1" },
|
||||||
|
{ "ref": "RC", "pin": "1" },
|
||||||
|
{ "ref": "R4", "pin": "1" },
|
||||||
|
{ "ref": "C4", "pin": "1" },
|
||||||
|
{ "ref": "U1", "pin": "3V3" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GND",
|
||||||
|
"class": "ground",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "V1", "pin": "GND" },
|
||||||
|
{ "ref": "M1", "pin": "MIC-" },
|
||||||
|
{ "ref": "R3", "pin": "2" },
|
||||||
|
{ "ref": "RE", "pin": "2" },
|
||||||
|
{ "ref": "R5", "pin": "2" },
|
||||||
|
{ "ref": "C3", "pin": "2" },
|
||||||
|
{ "ref": "C4", "pin": "2" },
|
||||||
|
{ "ref": "U1", "pin": "GND" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MIC_BIAS",
|
||||||
|
"class": "analog",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "R1", "pin": "2" },
|
||||||
|
{ "ref": "M1", "pin": "MIC+" },
|
||||||
|
{ "ref": "C1", "pin": "1" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BASE_NODE",
|
||||||
|
"class": "analog",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "C1", "pin": "2" },
|
||||||
|
{ "ref": "R2", "pin": "2" },
|
||||||
|
{ "ref": "R3", "pin": "1" },
|
||||||
|
{ "ref": "Q1", "pin": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "COLLECTOR_NODE",
|
||||||
|
"class": "analog",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "Q1", "pin": "C" },
|
||||||
|
{ "ref": "RC", "pin": "2" },
|
||||||
|
{ "ref": "C2", "pin": "1" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EMITTER_NODE",
|
||||||
|
"class": "analog",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "Q1", "pin": "E" },
|
||||||
|
{ "ref": "RE", "pin": "1" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ADC_MID",
|
||||||
|
"class": "analog",
|
||||||
|
"nodes": [
|
||||||
|
{ "ref": "R4", "pin": "2" },
|
||||||
|
{ "ref": "R5", "pin": "1" },
|
||||||
|
{ "ref": "C3", "pin": "1" },
|
||||||
|
{ "ref": "C2", "pin": "2" },
|
||||||
|
{ "ref": "U1", "pin": "ADC_IN" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"constraints": {
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": "MicFrontEndCluster",
|
||||||
|
"members": ["M1", "R1", "C1", "Q1", "R2", "R3", "RC", "RE", "C4"],
|
||||||
|
"layout": "cluster"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ADCBiasCluster",
|
||||||
|
"members": ["R4", "R5", "C3", "C2", "U1"],
|
||||||
|
"layout": "cluster"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"near": [
|
||||||
|
{ "component": "C4", "target_pin": { "ref": "Q1", "pin": "C" } },
|
||||||
|
{ "component": "C3", "target_pin": { "ref": "U1", "pin": "ADC_IN" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"annotations": [
|
||||||
|
{ "text": "Dense analog frontend + ADC bias test case." }
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -111,6 +111,7 @@ const el = {
|
|||||||
jsonEditor: document.getElementById("jsonEditor"),
|
jsonEditor: document.getElementById("jsonEditor"),
|
||||||
jsonFeedback: document.getElementById("jsonFeedback"),
|
jsonFeedback: document.getElementById("jsonFeedback"),
|
||||||
loadSampleBtn: document.getElementById("loadSampleBtn"),
|
loadSampleBtn: document.getElementById("loadSampleBtn"),
|
||||||
|
resetSampleBtn: document.getElementById("resetSampleBtn"),
|
||||||
newProjectBtn: document.getElementById("newProjectBtn"),
|
newProjectBtn: document.getElementById("newProjectBtn"),
|
||||||
importBtn: document.getElementById("importBtn"),
|
importBtn: document.getElementById("importBtn"),
|
||||||
exportBtn: document.getElementById("exportBtn"),
|
exportBtn: document.getElementById("exportBtn"),
|
||||||
@ -317,6 +318,11 @@ function setStatus(text, ok = true) {
|
|||||||
el.compileStatus.className = ok ? "status-ok" : "";
|
el.compileStatus.className = ok ? "status-ok" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCompileStatus(result) {
|
||||||
|
const m = result?.layout_metrics ?? {};
|
||||||
|
return `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings ?? 0} crossings, ${m.overlap_edges ?? 0} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultProject() {
|
function defaultProject() {
|
||||||
return {
|
return {
|
||||||
meta: { title: "Untitled Schemeta Project" },
|
meta: { title: "Untitled Schemeta Project" },
|
||||||
@ -1631,10 +1637,7 @@ async function compileModel(model, opts = {}) {
|
|||||||
fitView(result.layout);
|
fitView(result.layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
const m = result.layout_metrics;
|
setStatus(formatCompileStatus(result));
|
||||||
setStatus(
|
|
||||||
`Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings} crossings, ${m.overlap_edges} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Compile failed: ${err.message}`, false);
|
setStatus(`Compile failed: ${err.message}`, false);
|
||||||
el.issues.textContent = `Compile error: ${err.message}`;
|
el.issues.textContent = `Compile error: ${err.message}`;
|
||||||
@ -2119,29 +2122,56 @@ async function runLayoutAction(path) {
|
|||||||
renderAll();
|
renderAll();
|
||||||
fitView(out.compile.layout);
|
fitView(out.compile.layout);
|
||||||
saveSnapshot();
|
saveSnapshot();
|
||||||
setStatus(
|
setStatus(formatCompileStatus(out.compile));
|
||||||
`Compiled (${out.compile.errors.length}E, ${out.compile.warnings.length}W | ${out.compile.layout_metrics.crossings} crossings, ${out.compile.layout_metrics.overlap_edges} overlaps, ${out.compile.layout_metrics.total_bends ?? 0} bends, ${out.compile.layout_metrics.label_tie_routes ?? 0} tie-nets)`
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Layout action failed: ${err.message}`, false);
|
setStatus(`Layout action failed: ${err.message}`, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSample() {
|
async function fetchSampleModel() {
|
||||||
const res = await fetch("/sample.schemeta.json");
|
const res = await fetch("/sample.schemeta.json");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setStatus("Sample missing.", false);
|
throw new Error("Sample missing.");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
const model = await res.json();
|
async function resetToSample(opts = {}) {
|
||||||
if (state.model) {
|
const push = opts.pushHistory !== false;
|
||||||
pushHistory("load-sample");
|
const before = state.model ? clone(state.model) : null;
|
||||||
|
const model = await fetchSampleModel();
|
||||||
|
if (push && state.model) {
|
||||||
|
pushHistory("reset-sample");
|
||||||
}
|
}
|
||||||
setSelectedRefs([]);
|
setSelectedRefs([]);
|
||||||
state.selectedNet = null;
|
state.selectedNet = null;
|
||||||
state.selectedPin = null;
|
state.selectedPin = null;
|
||||||
|
state.isolateNet = false;
|
||||||
|
state.isolateComponent = false;
|
||||||
|
state.userAdjustedView = false;
|
||||||
|
state.renderMode = "schematic_stub";
|
||||||
|
el.renderModeSelect.value = "schematic_stub";
|
||||||
|
state.showLabels = true;
|
||||||
|
el.showLabelsInput.checked = true;
|
||||||
|
el.instanceFilter.value = "";
|
||||||
|
el.netFilter.value = "";
|
||||||
|
el.instanceList.scrollTop = 0;
|
||||||
|
el.netList.scrollTop = 0;
|
||||||
|
closeSchemaModal();
|
||||||
await compileModel(model, { fit: true });
|
await compileModel(model, { fit: true });
|
||||||
|
if (before) {
|
||||||
|
el.jsonFeedback.textContent = `Reset sample. ${summarizeModelDelta(before, state.model)}`;
|
||||||
|
} else {
|
||||||
|
el.jsonFeedback.textContent = "Reset sample baseline loaded.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSample() {
|
||||||
|
try {
|
||||||
|
await resetToSample({ pushHistory: true });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err?.message ?? "Sample missing."), false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEvents() {
|
function setupEvents() {
|
||||||
@ -3139,6 +3169,13 @@ function setupEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.loadSampleBtn.addEventListener("click", loadSample);
|
el.loadSampleBtn.addEventListener("click", loadSample);
|
||||||
|
el.resetSampleBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await resetToSample({ pushHistory: true });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Reset failed: ${err.message}`, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
el.autoLayoutBtn.addEventListener("click", async () => {
|
el.autoLayoutBtn.addEventListener("click", async () => {
|
||||||
await runLayoutAction("/layout/auto");
|
await runLayoutAction("/layout/auto");
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="newProjectBtn" aria-label="Create new project">New</button>
|
<button id="newProjectBtn" aria-label="Create new project">New</button>
|
||||||
<button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button>
|
<button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button>
|
||||||
|
<button id="resetSampleBtn" aria-label="Reset to deterministic sample baseline">Reset Sample</button>
|
||||||
<button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button>
|
<button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button>
|
||||||
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
|
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
|
||||||
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
|
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
|
||||||
|
|||||||
87
package-lock.json
generated
Normal file
87
package-lock.json
generated
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "schemeta",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "schemeta",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"pixelmatch": "^7.1.0",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
|
"pngjs": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pixelmatch": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"pngjs": "^7.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pixelmatch": "bin/pixelmatch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,13 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
|
"test:ui": "node tests/ui-regression-runner.js",
|
||||||
|
"test:ui:update-baselines": "UPDATE_SNAPSHOTS=1 node tests/ui-regression-runner.js",
|
||||||
"mcp": "node src/mcp-server.js"
|
"mcp": "node src/mcp-server.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"pixelmatch": "^7.1.0",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
|
"pngjs": "^7.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
tests/baselines/ui/dense-analog.png
Normal file
BIN
tests/baselines/ui/dense-analog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
tests/baselines/ui/explicit-mode-auto-tidy.png
Normal file
BIN
tests/baselines/ui/explicit-mode-auto-tidy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
tests/baselines/ui/initial.png
Normal file
BIN
tests/baselines/ui/initial.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
tests/baselines/ui/post-migration-apply.png
Normal file
BIN
tests/baselines/ui/post-migration-apply.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
BIN
tests/baselines/ui/selected-u2.png
Normal file
BIN
tests/baselines/ui/selected-u2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
227
tests/ui-regression-runner.js
Normal file
227
tests/ui-regression-runner.js
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { createServer as createNetServer } from "node:net";
|
||||||
|
import { PNG } from "pngjs";
|
||||||
|
import pixelmatch from "pixelmatch";
|
||||||
|
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 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");
|
||||||
|
|
||||||
|
async function getFreePort() {
|
||||||
|
const probe = createNetServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
probe.once("error", reject);
|
||||||
|
probe.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const addr = probe.address();
|
||||||
|
const port = typeof addr === "object" && addr ? addr.port : 8787;
|
||||||
|
await new Promise((resolve) => probe.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(predicate, timeoutMs = 10_000) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
try {
|
||||||
|
if (await predicate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(port) {
|
||||||
|
const child = spawn("node", ["src/server.js"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (d) => {
|
||||||
|
stdout += String(d);
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (d) => {
|
||||||
|
stderr += String(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
await waitFor(async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/health`);
|
||||||
|
return res.ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
async stop() {
|
||||||
|
if (child.exitCode !== null) return;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await waitFor(() => child.exitCode !== null, 4_000).catch(() => {
|
||||||
|
throw new Error(`Server did not stop cleanly.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await mkdir(BASELINE_DIR, { recursive: true });
|
||||||
|
await mkdir(CURRENT_DIR, { recursive: true });
|
||||||
|
await mkdir(DIFF_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compareScene(page, scene) {
|
||||||
|
const currentPath = join(CURRENT_DIR, `${scene}.png`);
|
||||||
|
const baselinePath = join(BASELINE_DIR, `${scene}.png`);
|
||||||
|
const diffPath = join(DIFF_DIR, `${scene}.png`);
|
||||||
|
|
||||||
|
await page.screenshot({ path: currentPath, fullPage: true });
|
||||||
|
|
||||||
|
if (UPDATE_SNAPSHOTS || !existsSync(baselinePath)) {
|
||||||
|
await copyFile(currentPath, baselinePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [actualBuf, baselineBuf] = await Promise.all([readFile(currentPath), readFile(baselinePath)]);
|
||||||
|
const actual = PNG.sync.read(actualBuf);
|
||||||
|
const baseline = PNG.sync.read(baselineBuf);
|
||||||
|
assert.equal(actual.width, baseline.width, `scene '${scene}' width changed`);
|
||||||
|
assert.equal(actual.height, baseline.height, `scene '${scene}' height changed`);
|
||||||
|
|
||||||
|
const diff = new PNG({ width: actual.width, height: actual.height });
|
||||||
|
const diffPixels = pixelmatch(actual.data, baseline.data, diff.data, actual.width, actual.height, {
|
||||||
|
threshold: 0.15,
|
||||||
|
includeAA: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (diffPixels > MAX_DIFF_PIXELS) {
|
||||||
|
await writeFile(diffPath, PNG.sync.write(diff));
|
||||||
|
assert.fail(`scene '${scene}' changed: ${diffPixels} differing pixels (max ${MAX_DIFF_PIXELS}). diff: ${diffPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatusMetrics(statusText) {
|
||||||
|
const m = /(\d+)E,\s*(\d+)W\s*\|\s*(\d+)\s*crossings,\s*(\d+)\s*overlaps,\s*(\d+)\s*bends,\s*(\d+)\s*tie-nets,\s*([0-9.]+)x\s*detour/.exec(
|
||||||
|
statusText
|
||||||
|
);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
errors: Number(m[1]),
|
||||||
|
warnings: Number(m[2]),
|
||||||
|
crossings: Number(m[3]),
|
||||||
|
overlaps: Number(m[4]),
|
||||||
|
bends: Number(m[5]),
|
||||||
|
tieNets: Number(m[6]),
|
||||||
|
detour: Number(m[7])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await ensureDirs();
|
||||||
|
const port = await getFreePort();
|
||||||
|
const srv = await startServer(port);
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${srv.baseUrl}/`);
|
||||||
|
await page.waitForSelector("#compileStatus");
|
||||||
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
||||||
|
|
||||||
|
await compareScene(page, "initial");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
|
||||||
|
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);
|
||||||
|
await compareScene(page, "selected-u2");
|
||||||
|
|
||||||
|
await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } });
|
||||||
|
await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "View Schema" }).click();
|
||||||
|
await page.getByRole("dialog", { name: "Schemeta JSON Schema" }).waitFor();
|
||||||
|
await page.getByRole("button", { name: "Close" }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#schemaModal")?.classList.contains("hidden"));
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
|
||||||
|
const rowForPin = await findSymbolPinRowIndex(page, "AOUT");
|
||||||
|
assert.ok(rowForPin >= 0, "AOUT pin row should exist");
|
||||||
|
|
||||||
|
page.once("dialog", (d) => d.dismiss());
|
||||||
|
await page.locator("#symbolPinsList .symbolPinRow").nth(rowForPin).getByRole("button", { name: "Remove" }).click();
|
||||||
|
assert.ok((await findSymbolPinRowIndex(page, "AOUT")) >= 0, "AOUT should remain after dismiss");
|
||||||
|
|
||||||
|
const rowForPin2 = await findSymbolPinRowIndex(page, "AOUT");
|
||||||
|
page.once("dialog", (d) => d.accept());
|
||||||
|
await page.locator("#symbolPinsList .symbolPinRow").nth(rowForPin2).getByRole("button", { name: "Remove" }).click();
|
||||||
|
assert.equal(await findSymbolPinRowIndex(page, "AOUT"), -1, "AOUT should be removed in draft");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Apply Symbol" }).click();
|
||||||
|
await expectText(page, "#jsonFeedback", /Destructive symbol edit detected/);
|
||||||
|
await page.getByRole("button", { name: "Preview Migration" }).click();
|
||||||
|
await page.getByRole("button", { name: "Apply Symbol" }).click();
|
||||||
|
|
||||||
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
||||||
|
await expectText(page, "#symbolMeta", /\(5 pins\)/);
|
||||||
|
await compareScene(page, "post-migration-apply");
|
||||||
|
|
||||||
|
await page.selectOption("#renderModeSelect", "explicit");
|
||||||
|
await page.getByRole("button", { name: "Run automatic tidy layout" }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
||||||
|
const explicitStatus = await page.locator("#compileStatus").textContent();
|
||||||
|
assert.ok(/x detour/.test(explicitStatus ?? ""), "status should include detour metric in explicit mode");
|
||||||
|
await compareScene(page, "explicit-mode-auto-tidy");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Reset to deterministic sample baseline" }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
||||||
|
await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
|
||||||
|
await expectText(page, "#compileStatus", /x detour/);
|
||||||
|
const mode = await page.locator("#renderModeSelect").inputValue();
|
||||||
|
assert.equal(mode, "schematic_stub");
|
||||||
|
|
||||||
|
const dense = await readFile(DENSE_ANALOG_PATH, "utf8");
|
||||||
|
await page.locator("#jsonEditor").fill(dense);
|
||||||
|
await page.getByRole("button", { name: "Apply JSON" }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
||||||
|
const denseStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
|
||||||
|
const metrics = parseStatusMetrics(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.ok(metrics.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`);
|
||||||
|
assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`);
|
||||||
|
await compareScene(page, "dense-analog");
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
await srv.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectText(page, selector, pattern) {
|
||||||
|
await waitFor(async () => {
|
||||||
|
const text = await page.locator(selector).textContent();
|
||||||
|
return pattern.test(String(text ?? ""));
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSymbolPinRowIndex(page, pinName) {
|
||||||
|
return page.locator("#symbolPinsList .symbolPinRow .pinName").evaluateAll(
|
||||||
|
(nodes, target) => nodes.findIndex((node) => (node).value?.trim() === target),
|
||||||
|
pinName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user