diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 02545e6..126abbf 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -17,7 +17,10 @@ concurrency:
cancel-in-progress: true
jobs:
+ verify:
+ uses: ./.github/workflows/tests.yml
deploy:
+ needs: verify
runs-on: ubuntu-latest
environment:
name: github-pages
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5322d08
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,22 @@
+name: Regression checks
+on:
+ pull_request:
+ workflow_call:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ shared:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - run: python -m pip install numpy pyfqmr
+ - run: python -m tests.test_vase
+ - run: python -m unittest tests.test_workspace -v
+ - run: node tests/test_web_state.cjs
diff --git a/README.md b/README.md
index af08c71..b82f946 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,52 @@ ready to slice.
## Download and run
+### Design library and profiles (desktop and web)
+
+Open **Designs → Library, profiles and Surprise locks** on desktop or in the browser.
+Both interfaces use the same Python rules and portable library format:
+
+* **Surprise locks:** preserve Dimensions (height and widest diameter), Silhouette (including its width),
+ Cross-section, Twist, or Colours while exploring new random variations. Printer settings are always kept.
+ Locks preserve the requested settings; softening can still alter the printable surface. Existing masks
+ and tissue-box modes are not locks: Surprise me still creates a vase.
+* **My designs:** save named variants, browse silhouette sketches, mark favourites, restore, or remove them.
+ Restoring a variant remains undoable. These are silhouette thumbnails, not full 3D renders.
+* **Printer and material profiles:** name and apply printer settings separately from material properties.
+ Printer profiles store bed/build height, line/layer widths, bottom, walls, overhang limit and mesh detail.
+ Material profiles store filament diameter, density, speed and maximum flow. Applying a profile keeps the
+ design's shape and colours and can be undone.
+* **Recovery:** retains up to 30 settled snapshots, beyond the current session's undo history. It is local
+ to this computer/browser. Export a library backup before clearing browser storage or moving computers.
+* **Export/import library:** transfer variants, profiles, recovery and locks between desktop and browser.
+ Import merges entries by identity, leaves existing entries intact, and combines locks. Each library can
+ hold up to 500 saved designs and 500 profiles. There is no automatic cloud synchronisation.
+
+In **View → Compare original and softened**, blue shows the requested side envelope and orange shows
+the softened envelope. Connecting strokes highlight changed widths; the maximum surface movement is
+also reported. This side view can overlap where changes are angular rather than changes in outer width.
+
+**File → Save clearance samples** exports five socket-and-pin pairs as one 3MF. Left to right, the radial
+clearances are 0.1, 0.2, 0.3, 0.4 and 0.5 mm (diametral clearances are double those values). Print with the
+intended material and settings, keep the pairs in order, and test the fit before committing to a larger
+print. These are general clearance samples, not replicas of the lid latches; they do not change a design's
+clearance automatically. The sample layout is approximately 92 × 33 × 3 mm.
+
+### Regression checks
+
+```text
+python -m tests.test_vase
+python -m unittest tests.test_workspace -v
+node tests/test_web_state.cjs
+python -m tests.test_app
+python -m unittest tests.test_design_tools -v
+```
+
+The desktop tests require the packages in `requirements.txt`. They can run with
+`QT_QPA_PLATFORM=offscreen`. The optional `node tests/test_browser.cjs` end-to-end test needs Playwright,
+a Chrome/Chromium executable in `BROWSER_PATH`, and a built site served at `http://127.0.0.1:8765`
+(or `VASE_URL`). Shared geometry, library and browser-state regressions run before Pages deployment.
+
* **In your browser:** – the whole app, Simple and Advanced:
every setting, the curves, drawing and tracing your own cross-section (in a full window too), your own
function, the Vase, Section, Overhangs and Print layout views, Spin and Watch it print, the side and
diff --git a/tests/test_browser.cjs b/tests/test_browser.cjs
new file mode 100644
index 0000000..ebf2a3a
--- /dev/null
+++ b/tests/test_browser.cjs
@@ -0,0 +1,40 @@
+// Optional end-to-end test: NODE_PATH points to Playwright; BROWSER_PATH to Chrome/Edge.
+const {chromium}=require('playwright');
+const assert=require('node:assert/strict');
+(async()=>{
+ const browser=await chromium.launch({headless:true,executablePath:process.env.BROWSER_PATH});
+ try {
+ const page=await browser.newPage({viewport:{width:1440,height:1000}});
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));
+ await page.goto(process.env.VASE_URL||'http://127.0.0.1:8765');
+ await page.waitForFunction(()=>window.vaseinator?.meta,{timeout:180000});
+ console.log('PASS: browser boots and builds');
+ await page.locator('#advancedbtn').click();
+ await page.locator('#set-line_width').evaluate(el=>{el.value='.8';el.dispatchEvent(new Event('change'))});
+ await page.waitForFunction(()=>window.vaseinator.params.line_width===.8);
+ await page.reload();await page.waitForFunction(()=>window.vaseinator?.meta,{timeout:180000});
+ assert.equal(await page.evaluate(()=>window.vaseinator.params.line_width),.8);
+ await page.locator('[data-do="library"]').evaluate(el=>el.click());
+ const dialog=page.locator('dialog.library');await dialog.getByText('My designs',{exact:true}).waitFor();
+ await dialog.getByLabel('Colours',{exact:true}).check();
+ page.once('dialog',d=>d.accept('Browser test variant'));
+ await dialog.getByRole('button',{name:'Save current variant',exact:true}).click();
+ await dialog.getByText(/Browser test variant —/).waitFor();
+ await dialog.getByRole('button',{name:'Favourite',exact:true}).click();
+ await dialog.getByRole('button',{name:'Unfavourite',exact:true}).waitFor();
+ page.once('dialog',d=>d.accept('Test printer'));
+ await dialog.getByRole('button',{name:'Save printer',exact:true}).click();
+ await dialog.getByText(/Test printer —/).waitFor();
+ await page.screenshot({path:'work-temp/browser-library.png'});
+ await dialog.getByRole('button',{name:'Close',exact:true}).click();
+ await page.locator('[data-do="compare"]').evaluate(el=>el.click());
+ await page.locator('#help[open] img').waitFor({timeout:180000});
+ await page.screenshot({path:'work-temp/browser-comparison.png'});
+ await page.locator('#help').evaluate(el=>el.close());
+ await page.locator('[data-do="calibration"]').evaluate(el=>el.click());
+ await page.locator('#steps[open]').waitFor({timeout:60000});
+ assert.equal(await page.locator('#stepsfile').getAttribute('download'),'clearance_samples.3mf');
+ assert.deepEqual(errors,[]);
+ console.log('PASS: printer reload, library, favourites, profile, locks, comparison and sample export; no browser errors');
+ } finally {await browser.close();}
+})().catch(e=>{console.error(e);process.exitCode=1});
diff --git a/tests/test_design_tools.py b/tests/test_design_tools.py
new file mode 100644
index 0000000..dcb9cf1
--- /dev/null
+++ b/tests/test_design_tools.py
@@ -0,0 +1,78 @@
+"""Desktop feature controls; runs without a display using QT_QPA_PLATFORM=offscreen."""
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from PySide6.QtCore import QSettings
+from PySide6.QtGui import QFontDatabase, QFont
+from PySide6.QtWidgets import QApplication, QCheckBox, QMessageBox
+from vaseinator.app import MainWindow
+from vaseinator.design_tools import DesignTools, save_calibration
+from vaseinator.threemf import read_3mf
+
+
+class DesktopToolsTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.app = QApplication.instance() or QApplication([])
+ font = Path("C:/Windows/Fonts/segoeui.ttf")
+ if font.exists():
+ ident = QFontDatabase.addApplicationFont(str(font))
+ families = QFontDatabase.applicationFontFamilies(ident)
+ if families:
+ cls.app.setFont(QFont(families[0], 10))
+
+ def test_library_profile_recovery_and_samples(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = QSettings(str(Path(tmp) / "test.ini"), QSettings.IniFormat)
+ win = MainWindow(settings)
+ win.params.facet_size = 2
+ dialog = DesignTools(win)
+ dialog.show()
+ self.app.processEvents()
+ colour = next(
+ c for c in dialog.findChildren(QCheckBox) if c.text() == "Colours"
+ )
+ colour.setChecked(True)
+ self.assertIn("Colours", win.workspace.data["locks"])
+ dialog.run("save", dict(category="designs", name="Variant"))
+ entry = win.workspace.data["designs"][0]
+ dialog.run("favorite", {"id": entry["id"]})
+ self.assertTrue(entry["favorite"])
+ win.params.line_width = 0.8
+ dialog.run(
+ "save", dict(category="profiles", name="Printer", kind="printer")
+ )
+ profile = win.workspace.data["profiles"][0]
+ win.params.line_width = 0.4
+ dialog.run("restore", dict(category="profiles", id=profile["id"]))
+ self.assertEqual(win.params.line_width, 0.8)
+ win.current_file = "old.vase"
+ dialog.run("restore", dict(category="designs", id=entry["id"]))
+ self.assertIsNone(win.current_file)
+ self.assertEqual(win.params.line_width, entry["params"]["line_width"])
+ self.assertTrue(win.workspace.data["recovery"])
+ self.assertEqual(
+ json.loads(settings.value("workspace")), win.workspace.data
+ )
+ path = str(Path(tmp) / "samples.3mf")
+ with (
+ patch(
+ "vaseinator.design_tools.QFileDialog.getSaveFileName",
+ return_value=(path, ""),
+ ),
+ patch.object(QMessageBox, "information"),
+ ):
+ save_calibration(win)
+ self.assertEqual(len(read_3mf(path)), 10)
+ dialog.grab().save("work-temp/desktop-library.png")
+ dialog.close()
+ win._dirty = False
+ win.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_web_state.cjs b/tests/test_web_state.cjs
new file mode 100644
index 0000000..7770252
--- /dev/null
+++ b/tests/test_web_state.cjs
@@ -0,0 +1,32 @@
+// Exercise the actual state functions with a delayed Python worker and minimal presentation stubs.
+const {readFileSync}=require('node:fs');
+const vm=require('node:vm');
+const assert=require('node:assert/strict');
+const source=readFileSync('web/app.js','utf8');
+const context=vm.createContext({console,setTimeout:()=>0,clearTimeout(){}});
+vm.runInContext(`
+let C={printer_keys:['line_width']}, K={REAL_VASE:0,FAKE_VASE:1}, params={height:180,color1:'blue',line_width:.6,vase_mode:1};
+let liveFrom=null,shown,reply,recoveryTimer=0;
+const undoStack=[],redoStack=[],saved={};
+const store={set(k,v){saved[k]=JSON.parse(JSON.stringify(v))},get(k){return saved[k]}};
+function pushUndo(s){undoStack.push(s);redoStack.length=0}
+function undoButtons(){} function status(){} function showProblem(e){throw new Error(e)}
+function refreshSimple(){} function refreshAdvanced(){} function rebuild(){}
+const helper={call(name,text,key,value){if(name==='shown')return Promise.resolve('[]');return new Promise(resolve=>{reply=()=>resolve(JSON.stringify({params:{...JSON.parse(text),[key]:JSON.parse(value)},shown:[]}))})}};
+`+source.slice(source.indexOf('let queue ='),source.indexOf('function pushUndo'))+
+source.slice(source.indexOf('function needsNormal'),source.indexOf('// ================================================================== building it'))+
+source.slice(source.indexOf('function rememberPrinter'),source.indexOf('async function fitHead')),context);
+const run=s=>vm.runInContext(s,context);
+const tick=()=>new Promise(resolve=>setImmediate(resolve));
+(async()=>{
+ run(`commit('height',200)`);await tick();run(`change({color1:'red'});reply()`);await run('queue');
+ assert.equal(run('params.height'),200);assert.equal(run('params.color1'),'red');
+ run(`commit('line_width',.8)`);await tick();run('reply()');await run('queue');
+ run(source.slice(source.indexOf(' const printer = store.get'),source.indexOf(' $("loading").hidden = true;')));
+ assert.equal(run('params.line_width'),.8);
+ run('step(undoStack,redoStack)');await run('queue');assert.equal(run('params.line_width'),.6);assert.equal(run('saved.printer.line_width'),.6);
+ run('step(redoStack,undoStack)');await run('queue');assert.equal(run('params.line_width'),.8);
+ run(`commit('height',250)`);await tick();run('step(undoStack,redoStack);reply()');await run('queue');assert.equal(run('params.height'),200);
+ run(`commit('height',260)`);await tick();run(`replace({height:99,color1:'green',line_width:.4,vase_mode:1});reply()`);await run('queue');assert.equal(run('params.height'),99);
+ console.log('PASS: delayed edits, colours, reload, undo/redo and replacement ordering');
+})().catch(e=>{console.error(e);process.exitCode=1});
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
new file mode 100644
index 0000000..fdef2e8
--- /dev/null
+++ b/tests/test_workspace.py
@@ -0,0 +1,136 @@
+"""Shared feature and export regression tests: python -m unittest tests.test_workspace."""
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+
+import numpy as np
+
+from vaseinator.params import VaseParams, clean_curve
+from vaseinator.presets import plain_design
+from vaseinator.geometry import VaseDesign, edge_check, mesh_volume, require_exportable
+from vaseinator.workspace import (
+ Workspace,
+ LOCKS,
+ locked_surprise,
+ comparison,
+ calibration_parts,
+)
+from vaseinator.threemf import write_3mf, read_3mf
+from web import bridge
+
+
+class WorkspaceTests(unittest.TestCase):
+ def test_bad_numbers(self):
+ for values in (
+ {"height": "NaN"},
+ {"height": "Infinity"},
+ {"count1": float("inf")},
+ ):
+ with self.subTest(values=values), self.assertRaises(ValueError):
+ VaseParams.from_dict(values)
+ self.assertIsNone(clean_curve([[0, 2], [1, float("nan")]]))
+
+ def test_export_gate(self):
+ for design in (
+ SimpleNamespace(errors=["bad"], closed=True),
+ SimpleNamespace(errors=[], closed=False),
+ ):
+ with self.assertRaises(ValueError):
+ require_exportable(design)
+
+ def test_export_gate_in_browser(self):
+ from unittest.mock import patch
+
+ with patch.object(
+ bridge, "VaseDesign", return_value=SimpleNamespace(errors=[], closed=False)
+ ):
+ for export in (bridge.export_stl, bridge.export_3mf, bridge.export_lid):
+ with self.assertRaises(ValueError):
+ export("{}")
+
+ def test_locks_and_bridge_parity(self):
+ p = VaseParams(height=123, line_width=0.8)
+ for group, keys in LOCKS.items():
+ q = locked_surprise(p, 42, [group])
+ for key in keys:
+ self.assertEqual(getattr(p, key), getattr(q, key))
+ self.assertEqual(q.line_width, 0.8)
+ q = locked_surprise(p, 42, ["Dimensions"])
+ self.assertAlmostEqual(
+ max(v for _, v in q.silhouette), max(v for _, v in p.silhouette)
+ )
+ q = locked_surprise(p, 42, list(LOCKS))
+ self.assertEqual(
+ q.to_dict(),
+ json.loads(
+ bridge.surprise_me(json.dumps(p.to_dict()), 42, json.dumps(list(LOCKS)))
+ ),
+ )
+
+ def test_library_roundtrip_profiles_favourites_and_recovery(self):
+ w = Workspace()
+ p = VaseParams(name="My variant", height=140, line_width=0.8, density=1.27)
+ entry = w.add("designs", p)
+ w.command("favorite", p, {"id": entry["id"]})
+ printer = w.add("profiles", p, "Printer", kind="printer")
+ material = w.add("profiles", p, "PETG", kind="material")
+ base = VaseParams(height=99, density=1.1)
+ q = w.apply_profile(base, printer["id"])
+ self.assertEqual(q.height, 99)
+ self.assertEqual(q.line_width, 0.8)
+ self.assertEqual(q.density, 1.1)
+ q = w.apply_profile(base, material["id"])
+ self.assertEqual(q.density, 1.27)
+ self.assertEqual(q.line_width, base.line_width)
+ for n in range(35):
+ w.add("recovery", VaseParams(height=100 + n))
+ w.add("recovery", VaseParams(height=134))
+ self.assertEqual(len(w.data["recovery"]), 30)
+ restored = Workspace(json.loads(w.dumps()))
+ self.assertEqual(restored.data, w.data)
+ restored.merge(w.data)
+ self.assertEqual(restored.data, w.data)
+ result = json.loads(
+ bridge.workspace_action(
+ w.dumps(),
+ json.dumps(base.to_dict()),
+ "restore",
+ json.dumps({"category": "designs", "id": entry["id"]}),
+ )
+ )
+ self.assertEqual(result["params"], p.to_dict())
+ self.assertTrue(result["workspace"]["designs"][0]["favorite"])
+ with self.assertRaises(ValueError):
+ Workspace({"format": "other"})
+
+ def test_comparison_and_calibration(self):
+ p = plain_design()
+ p.facet_size = 2
+ d = VaseDesign(p)
+ data = comparison(d)
+ self.assertEqual(data["moved"], 0)
+ self.assertIn("