From b1b5f9bea6a587211a1447bb5a364a6315742266 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 20:49:13 -0400
Subject: [PATCH 01/24] fix(after-slicing): a watch reply only applies to the
folder and project it was asked about
A reply for an abandoned project, a changed folder or a superseded poll could
auto-open that job over the current one. Each look now takes a ticket and a
reply is shown only while it is still about the current folder and project.
Verified: new OrcaRoundTrip.stale.test.tsx 7/7 (3 failed on f19fee1 before the fix).
Co-Authored-By: Claude Sonnet 5.5
---
.../components/OrcaRoundTrip.stale.test.tsx | 104 ++++++++++++++++++
desktop/src/components/OrcaRoundTrip.tsx | 20 +++-
2 files changed, 120 insertions(+), 4 deletions(-)
create mode 100644 desktop/src/components/OrcaRoundTrip.stale.test.tsx
diff --git a/desktop/src/components/OrcaRoundTrip.stale.test.tsx b/desktop/src/components/OrcaRoundTrip.stale.test.tsx
new file mode 100644
index 00000000..0ef19240
--- /dev/null
+++ b/desktop/src/components/OrcaRoundTrip.stale.test.tsx
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, cleanup, render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/api", () => ({ watchFolder: vi.fn(), doctor: vi.fn(), convert: vi.fn(), diff: vi.fn() }));
+
+import { watchFolder } from "@/api";
+import { OrcaRoundTrip } from "@/components/OrcaRoundTrip";
+import { useSession } from "@/store/session";
+import { useSliced } from "@/store/sliced";
+import { useWatch } from "@/store/watch";
+
+const FOLDER_1 = "C:\\exports";
+const FOLDER_2 = "C:\\other-exports";
+const file = (name: string) => ({ path: `C:\\projects\\${name}.3mf`, name: `${name}.3mf`, ext: "3mf" });
+const reply = (best: string | null) =>
+ ({ schema_version: "watch/1", available: true, folder: FOLDER_1, best, best_verdict: best ? "confirmed" : null, candidates: [], summary: "" }) as any;
+
+/** A watch call the test settles by hand, keyed by the (folder, project) it was made for. */
+function deferred() {
+ const pending: Record void> = {};
+ vi.mocked(watchFolder).mockImplementation((folder, project) =>
+ new Promise((resolve) => { pending[`${folder}|${project}`] = resolve; }));
+ return pending;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ localStorage.clear();
+ useSession.setState({ file: file("A") });
+ useSliced.setState({ path: null, name: null });
+ useWatch.setState({ folder: FOLDER_1, autoOpen: true });
+});
+afterEach(() => { cleanup(); vi.useRealTimers(); });
+
+describe("the watch response only ever applies to the job it was asked about", () => {
+ it("control: a current response opens its matched job", async () => {
+ vi.mocked(watchFolder).mockResolvedValue(reply("C:\\exports\\A.gcode"));
+ render();
+ await act(async () => {});
+ expect(useSliced.getState().path).toBe("C:\\exports\\A.gcode");
+ });
+
+ it("control: an ambiguous response chooses nothing", async () => {
+ vi.mocked(watchFolder).mockResolvedValue(reply(null));
+ render();
+ await act(async () => {});
+ expect(useSliced.getState().path).toBeNull();
+ });
+
+ it("R1: a late answer for project A cannot open A's job once B is selected", async () => {
+ const pending = deferred();
+ render();
+ await act(async () => {});
+ act(() => useSession.setState({ file: file("B") }));
+ await act(async () => {});
+ await act(async () => pending[`${FOLDER_1}|${file("A").path}`](reply("C:\\exports\\A.gcode")));
+ expect(useSliced.getState().path).toBeNull();
+ await act(async () => pending[`${FOLDER_1}|${file("B").path}`](reply("C:\\exports\\B.gcode")));
+ expect(useSliced.getState().path).toBe("C:\\exports\\B.gcode");
+ });
+
+ it("a late answer for the old folder cannot open a job after the folder changes", async () => {
+ const pending = deferred();
+ render();
+ await act(async () => {});
+ act(() => useWatch.setState({ folder: FOLDER_2 }));
+ await act(async () => {});
+ await act(async () => pending[`${FOLDER_1}|${file("A").path}`](reply("C:\\exports\\A.gcode")));
+ expect(useSliced.getState().path).toBeNull();
+ });
+
+ it("a late answer after the page is left opens nothing", async () => {
+ const pending = deferred();
+ const view = render();
+ await act(async () => {});
+ view.unmount();
+ await act(async () => pending[`${FOLDER_1}|${file("A").path}`](reply("C:\\exports\\A.gcode")));
+ expect(useSliced.getState().path).toBeNull();
+ });
+
+ it("an answer that was superseded by a newer poll for the same job is dropped", async () => {
+ let calls = 0;
+ const settle: Array<(value: any) => void> = [];
+ vi.mocked(watchFolder).mockImplementation(() => new Promise((resolve) => { calls += 1; settle.push(resolve); }));
+ render();
+ await act(async () => {});
+ await act(async () => { vi.advanceTimersByTime(5000); });
+ expect(calls).toBe(2);
+ await act(async () => settle[1](reply("C:\\exports\\newer.gcode")));
+ await act(async () => settle[0](reply("C:\\exports\\older.gcode")));
+ expect(useSliced.getState().path).toBe("C:\\exports\\newer.gcode");
+ });
+
+ it("auto-open off: a matched job is listed, never opened", async () => {
+ useWatch.setState({ autoOpen: false });
+ vi.mocked(watchFolder).mockResolvedValue(reply("C:\\exports\\A.gcode"));
+ render();
+ await act(async () => {});
+ expect(useSliced.getState().path).toBeNull();
+ });
+});
diff --git a/desktop/src/components/OrcaRoundTrip.tsx b/desktop/src/components/OrcaRoundTrip.tsx
index 2d17c022..12b6750e 100644
--- a/desktop/src/components/OrcaRoundTrip.tsx
+++ b/desktop/src/components/OrcaRoundTrip.tsx
@@ -34,19 +34,31 @@ export function OrcaRoundTrip() {
const openPath = useSliced((s) => s.path);
const [typed, setTyped] = useState(folder ?? "");
- const [result, setResult] = useState(null);
+ const [answer, setAnswer] = useState<{ folder: string; project: string | undefined; data: WatchResult } | null>(null);
const [busy, setBusy] = useState(false);
const opened = useRef(null);
+ // Each look takes a ticket. A reply counts only if its ticket is still the
+ // newest, so a slow answer about project A, a folder since changed, or a page
+ // since left can never be applied to whatever is open now.
+ const ticket = useRef(0);
const look = useCallback(() => {
if (!folder) return;
+ const mine = ++ticket.current;
+ const asked = { folder, project: project?.path };
setBusy(true);
watchFolder(folder, project?.path)
- .then(setResult)
- .catch(() => setResult(null))
- .finally(() => setBusy(false));
+ .then((data) => { if (ticket.current === mine) setAnswer({ ...asked, data }); })
+ .catch(() => { if (ticket.current === mine) setAnswer(null); })
+ .finally(() => { if (ticket.current === mine) setBusy(false); });
}, [folder, project?.path]);
+ // Invalidate whatever is in flight when the question changes or the page goes.
+ useEffect(() => () => { ticket.current += 1; }, [folder, project?.path]);
+
+ // An answer is shown only while it is still about the current folder and project.
+ const result = answer && answer.folder === folder && answer.project === project?.path ? answer.data : null;
+
useEffect(() => {
if (!folder) return;
look();
From 276a5630d79f1d6463c9187d9b9f44950711e120 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 20:59:52 -0400
Subject: [PATCH 02/24] feat(engine): job_check - is a saved job's file still
the file it was made from?
Read-only size and SHA-256 comparison for a job's original, prepared and sliced
files: same, changed, missing, unreadable, or unverified (too large to hash or no
recorded hash - never reported as the same). POST /job_check.
Verified: backend/tests/test_job_identity.py 12 passed; full backend suite 3110 passed, 49 skipped.
Co-Authored-By: Claude Sonnet 5.5
---
backend/snapstudio_api/server.py | 8 ++
backend/snapstudio_api/service.py | 6 +
backend/snapstudio_core/job_identity.py | 94 +++++++++++++++
backend/tests/test_job_identity.py | 149 ++++++++++++++++++++++++
4 files changed, 257 insertions(+)
create mode 100644 backend/snapstudio_core/job_identity.py
create mode 100644 backend/tests/test_job_identity.py
diff --git a/backend/snapstudio_api/server.py b/backend/snapstudio_api/server.py
index 01d2cee9..2c5c326d 100644
--- a/backend/snapstudio_api/server.py
+++ b/backend/snapstudio_api/server.py
@@ -345,6 +345,14 @@ def do_POST(self):
self._send(400, {"error": str(e)})
except Exception as exc:
self._send_exception(exc)
+ elif self.path == "/job_check":
+ try:
+ files = data.get("files") if isinstance(data, dict) else None
+ self._send(200, service.job_check(files))
+ except (ValidationError, ValueError) as e:
+ self._send(400, {"error": str(e)})
+ except Exception as exc:
+ self._send_exception(exc)
elif self.path == "/slice_provenance":
try:
self._send(200, service.slice_provenance(
diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py
index 717968f8..62113870 100644
--- a/backend/snapstudio_api/service.py
+++ b/backend/snapstudio_api/service.py
@@ -986,6 +986,12 @@ def watch_folder(folder: str, project_path: str | None = None) -> dict:
return wf.match_project(folder, project_path)
+def job_check(files) -> dict:
+ """Are the files of a saved job still the files it was made from? Read-only."""
+ from snapstudio_core import job_identity
+ return job_identity.check(files)
+
+
def slice_provenance(project_path: str, gcode_path: str) -> dict:
"""Is this G-code the slice of that project?"""
from snapstudio_core import gcode, project_traits, provenance
diff --git a/backend/snapstudio_core/job_identity.py b/backend/snapstudio_core/job_identity.py
new file mode 100644
index 00000000..008e8971
--- /dev/null
+++ b/backend/snapstudio_core/job_identity.py
@@ -0,0 +1,94 @@
+"""Is a saved job's file still the file the job was made from?
+
+After a restart Studio can offer to pick a job back up: the project that came in,
+the prepared copy it wrote, and the sliced G-code that came back. Any of them may
+have been moved, deleted or overwritten since. This compares each against the
+size and SHA-256 recorded when the job was linked, and reports what it can vouch
+for. A file it cannot read, or is too large to hash, is never reported as the same.
+
+It only reads. It does not prepare, slice, send or open anything.
+"""
+from __future__ import annotations
+
+import hashlib
+import os
+
+SCHEMA_VERSION = "job-check/1"
+
+ROLES = ("original", "prepared", "sliced")
+
+#: A job is three files, not a library.
+MAX_ENTRIES = 8
+
+#: Hashing is a full read. Past this, identity is size-only and says so.
+MAX_HASH_BYTES = 1024 * 1024 * 1024
+
+_CHUNK = 1024 * 1024
+
+
+def _sha256(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as handle:
+ while True:
+ block = handle.read(_CHUNK)
+ if not block:
+ break
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def _expectation(raw) -> dict | None:
+ if raw is None:
+ return None
+ if not isinstance(raw, dict):
+ raise ValueError("expected must be an object")
+ size, sha = raw.get("size_bytes"), raw.get("sha256")
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0:
+ raise ValueError("expected.size_bytes must be a non-negative integer")
+ if sha is not None and not (isinstance(sha, str) and len(sha) == 64):
+ raise ValueError("expected.sha256 must be a 64-character hex string or null")
+ return {"size_bytes": size, "sha256": sha}
+
+
+def _check_one(role: str, path: str, expected: dict | None) -> dict:
+ out = {"role": role, "path": path}
+ try:
+ if not os.path.exists(path):
+ return {**out, "status": "missing"}
+ if not os.path.isfile(path):
+ return {**out, "status": "unreadable", "reason": "not a regular file"}
+ size = os.stat(path).st_size
+ # A size that differs is already a different file; no need to read it.
+ if expected is not None and size != expected["size_bytes"]:
+ return {**out, "status": "changed", "observed": {"size_bytes": size, "sha256": None}}
+ sha = _sha256(path) if size <= MAX_HASH_BYTES else None
+ observed = {"size_bytes": size, "sha256": sha}
+ except OSError as exc:
+ return {**out, "status": "unreadable", "reason": exc.__class__.__name__}
+
+ if expected is None:
+ return {**out, "status": "recorded", "observed": observed}
+ if sha is None or expected["sha256"] is None:
+ return {**out, "status": "unverified", "observed": observed}
+ return {**out, "status": "same" if sha == expected["sha256"] else "changed", "observed": observed}
+
+
+def check(entries: list[dict]) -> dict:
+ """Report, per file and in the order given, whether it is still what was recorded.
+
+ Each entry is `{role, path, expected?}` with `expected = {size_bytes, sha256}`.
+ Without `expected` the file is only fingerprinted (status `recorded`).
+ """
+ if not isinstance(entries, list) or len(entries) > MAX_ENTRIES:
+ raise ValueError(f"at most {MAX_ENTRIES} files can be checked")
+ files = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ raise ValueError("each file must be an object")
+ role, path = entry.get("role"), entry.get("path")
+ if role not in ROLES:
+ raise ValueError(f"role must be one of {', '.join(ROLES)}")
+ if not isinstance(path, str) or not path.strip():
+ raise ValueError("path must be a non-empty string")
+ files.append(_check_one(role, path, _expectation(entry.get("expected"))))
+ return {"schema_version": SCHEMA_VERSION, "files": files}
diff --git a/backend/tests/test_job_identity.py b/backend/tests/test_job_identity.py
new file mode 100644
index 00000000..087aa8d2
--- /dev/null
+++ b/backend/tests/test_job_identity.py
@@ -0,0 +1,149 @@
+"""A saved job is trusted only as far as its files still are what they were.
+
+Resuming a job after a restart reuses three files: the project that came in, the
+prepared copy Studio wrote, and the sliced G-code that came back. Any of them can
+be moved, deleted, regenerated or overwritten in between. This checks each file
+against the size and hash recorded earlier, and says which of them it can vouch for.
+It only reads; it never repeats preparation, slicing or sending.
+"""
+from __future__ import annotations
+
+import hashlib
+import os
+
+import pytest
+
+from snapstudio_core import job_identity
+
+
+def _write(path, data: bytes):
+ path.write_bytes(data)
+ return {"size_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
+
+
+def _one(result, role="original"):
+ return next(item for item in result["files"] if item["role"] == role)
+
+
+def test_recording_a_file_returns_its_size_and_hash(tmp_path):
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"project bytes")
+ out = job_identity.check([{"role": "original", "path": str(f)}])
+ item = _one(out)
+ assert item["status"] == "recorded"
+ assert item["observed"] == expected
+
+
+def test_an_unchanged_file_is_same(tmp_path):
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"project bytes")
+ item = _one(job_identity.check([{"role": "original", "path": str(f), "expected": expected}]))
+ assert item["status"] == "same"
+
+
+def test_a_file_changed_in_place_is_changed_even_at_the_same_size(tmp_path):
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"aaaa")
+ f.write_bytes(b"bbbb")
+ item = _one(job_identity.check([{"role": "original", "path": str(f), "expected": expected}]))
+ assert item["status"] == "changed"
+ assert item["observed"]["size_bytes"] == expected["size_bytes"]
+
+
+def test_a_missing_file_is_missing_not_changed(tmp_path):
+ expected = {"size_bytes": 3, "sha256": hashlib.sha256(b"abc").hexdigest()}
+ item = _one(job_identity.check([{"role": "sliced", "path": str(tmp_path / "gone.gcode"), "expected": expected}]), "sliced")
+ assert item["status"] == "missing"
+ assert "observed" not in item or item["observed"] is None
+
+
+def test_a_directory_in_its_place_is_unreadable(tmp_path):
+ d = tmp_path / "a.3mf"
+ d.mkdir()
+ expected = {"size_bytes": 3, "sha256": hashlib.sha256(b"abc").hexdigest()}
+ item = _one(job_identity.check([{"role": "original", "path": str(d), "expected": expected}]))
+ assert item["status"] == "unreadable"
+
+
+def test_a_file_too_large_to_hash_is_unverified_never_same(tmp_path, monkeypatch):
+ monkeypatch.setattr(job_identity, "MAX_HASH_BYTES", 4)
+ f = tmp_path / "big.gcode"
+ f.write_bytes(b"0123456789")
+ item = _one(job_identity.check([{"role": "sliced", "path": str(f), "expected": {"size_bytes": 10, "sha256": None}}]), "sliced")
+ assert item["status"] == "unverified"
+ assert item["observed"] == {"size_bytes": 10, "sha256": None}
+
+
+def test_a_recorded_hash_with_a_different_size_is_changed_without_hashing(tmp_path):
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"abc")
+ f.write_bytes(b"abcd")
+ item = _one(job_identity.check([{"role": "original", "path": str(f), "expected": expected}]))
+ assert item["status"] == "changed"
+
+
+def test_roles_stay_apart_and_order_is_kept(tmp_path):
+ a = tmp_path / "a.3mf"; b = tmp_path / "b.3mf"; c = tmp_path / "c.gcode"
+ ea, eb, ec = _write(a, b"1"), _write(b, b"2"), _write(c, b"3")
+ out = job_identity.check([
+ {"role": "original", "path": str(a), "expected": ea},
+ {"role": "prepared", "path": str(b), "expected": ec}, # wrong identity on purpose
+ {"role": "sliced", "path": str(c), "expected": ec},
+ ])
+ assert [i["role"] for i in out["files"]] == ["original", "prepared", "sliced"]
+ assert [i["status"] for i in out["files"]] == ["same", "changed", "same"]
+
+
+def test_unknown_role_and_too_many_entries_are_refused(tmp_path):
+ with pytest.raises(ValueError):
+ job_identity.check([{"role": "other", "path": str(tmp_path / "x")}])
+ with pytest.raises(ValueError):
+ job_identity.check([{"role": "original", "path": str(tmp_path / "x")}] * (job_identity.MAX_ENTRIES + 1))
+
+
+def test_a_malformed_expectation_is_refused_not_treated_as_a_match(tmp_path):
+ f = tmp_path / "a.3mf"; f.write_bytes(b"x")
+ with pytest.raises(ValueError):
+ job_identity.check([{"role": "original", "path": str(f), "expected": {"size_bytes": "big", "sha256": 5}}])
+
+
+def test_checking_never_modifies_the_file(tmp_path):
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"keep me")
+ before = (f.read_bytes(), os.stat(f).st_mtime_ns)
+ job_identity.check([{"role": "original", "path": str(f), "expected": expected}])
+ assert (f.read_bytes(), os.stat(f).st_mtime_ns) == before
+
+
+def test_the_http_route_answers_and_refuses_a_bad_request(tmp_path):
+ """The route is wired: a good call returns the report, a malformed one is a 400, not a 500."""
+ import json
+ import threading
+ import urllib.error
+ import urllib.request
+
+ from snapstudio_api import server
+
+ httpd, token = server.build_server("127.0.0.1", 0)
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ try:
+ url = f"http://127.0.0.1:{httpd.server_address[1]}/job_check"
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"bytes")
+
+ def post(body):
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST",
+ headers={"Content-Type": "application/json", "X-Auth-Token": token})
+ return urllib.request.urlopen(req, timeout=5)
+
+ ok = json.loads(post({"files": [{"role": "original", "path": str(f), "expected": expected}]}).read())
+ assert ok["files"][0]["status"] == "same"
+ with pytest.raises(urllib.error.HTTPError) as bad:
+ post({"files": [{"role": "nonsense", "path": str(f)}]})
+ assert bad.value.code == 400
+ with pytest.raises(urllib.error.HTTPError) as missing:
+ post({})
+ assert missing.value.code == 400
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
From 169bc40f4a6506ea55ed87d413b0478e2e353ee0 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 20:59:52 -0400
Subject: [PATCH 03/24] feat(desktop): a job keeps its original, prepared and
sliced identities and can be reopened after a restart
Links the project, the prepared copy and the sliced G-code as the person works,
with size and hash from the engine. After a restart a card offers to check and
reopen them: unchanged files only, a changed one only when named, a missing one
never, and a changed project is not presented as the source of the old prepared
copy or G-code. Reopening never prepares, slices or sends.
Verified: desktop vitest 78 files / 899 tests pass (new: jobRecord 23, job store
13, card 9, stale-response 7); tsc and vite build pass; source-independence 0 violations.
Co-Authored-By: Claude Sonnet 5.5
---
desktop/src/App.tsx | 4 +
desktop/src/api.ts | 20 ++
.../src/components/JobWorkspaceCard.test.tsx | 112 +++++++++
desktop/src/components/JobWorkspaceCard.tsx | 136 +++++++++++
desktop/src/lib/jobRecord.test.ts | 131 +++++++++++
desktop/src/lib/jobRecord.ts | 144 ++++++++++++
desktop/src/routes/AfterSlicing.tsx | 3 +
desktop/src/routes/Dashboard.tsx | 3 +
desktop/src/store/job.test.ts | 217 ++++++++++++++++++
desktop/src/store/job.ts | 171 ++++++++++++++
10 files changed, 941 insertions(+)
create mode 100644 desktop/src/components/JobWorkspaceCard.test.tsx
create mode 100644 desktop/src/components/JobWorkspaceCard.tsx
create mode 100644 desktop/src/lib/jobRecord.test.ts
create mode 100644 desktop/src/lib/jobRecord.ts
create mode 100644 desktop/src/store/job.test.ts
create mode 100644 desktop/src/store/job.ts
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index 507727cf..fa8ad8e1 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -27,6 +27,7 @@ import NotFound from "@/routes/NotFound";
import { ModelDownloadListener } from "@/components/ModelDownloadListener";
import { useTheme } from "@/store/theme";
import { useSession } from "@/store/session";
+import { trackJob } from "@/store/job";
import { launchFile, maybeAutoCheckUpdate } from "@/api";
import { useSliced } from "@/store/sliced";
import { useUpdateCheckStore } from "@/store/updateCheck";
@@ -42,6 +43,9 @@ export default function App() {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
+ // Note which files belong to the job in hand, so it can be picked up after a restart.
+ useEffect(() => trackJob(), []);
+
// A model handed to Studio on the command line — a file association, "Open
// with", or an automated run — should be open when the window appears, rather
// than making the user find it again through the picker.
diff --git a/desktop/src/api.ts b/desktop/src/api.ts
index fb2c130f..d2f0eefc 100644
--- a/desktop/src/api.ts
+++ b/desktop/src/api.ts
@@ -1498,6 +1498,26 @@ export function watchFolder(folder: string, projectPath?: string): Promise {
+ return post("/job_check", { files }, "job check");
+}
+
export function sliceProvenance(projectPath: string, gcodePath: string): Promise {
return post("/slice_provenance", { project_path: projectPath, gcode_path: gcodePath },
"provenance");
diff --git a/desktop/src/components/JobWorkspaceCard.test.tsx b/desktop/src/components/JobWorkspaceCard.test.tsx
new file mode 100644
index 00000000..75f6344a
--- /dev/null
+++ b/desktop/src/components/JobWorkspaceCard.test.tsx
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue({ port: 4312, token: "test" }) }));
+vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
+vi.mock("@/api", () => ({ jobCheck: vi.fn(), doctor: vi.fn().mockResolvedValue({}), convert: vi.fn(), diff: vi.fn() }));
+
+import * as api from "@/api";
+import { JobWorkspaceCard } from "@/components/JobWorkspaceCard";
+import { useJob } from "@/store/job";
+import { useSession } from "@/store/session";
+import { useSliced } from "@/store/sliced";
+import type { JobRecord } from "@/lib/jobRecord";
+
+const fp = (c: string, n: number) => ({ size_bytes: n, sha256: c.repeat(64) });
+const RECORD: JobRecord = {
+ schema: "job/1", savedAt: "2026-01-01T00:00:00.000Z",
+ original: { path: "C:\\work\\A.3mf", name: "A.3mf", fingerprint: fp("a", 10) },
+ prepared: { path: "C:\\work\\A_U1.3mf", name: "A_U1.3mf", fingerprint: fp("b", 20), mode: "preserve" },
+ sliced: { path: "C:\\out\\A.gcode", name: "A.gcode", fingerprint: fp("c", 30) },
+};
+
+const report = (states: Record) => {
+ vi.mocked(api.jobCheck).mockResolvedValue({
+ schema_version: "job-check/1",
+ files: Object.entries(states).map(([role, status]) => ({ role, path: "x", status })) as any,
+ });
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(api.doctor).mockResolvedValue({} as any);
+ useJob.setState({ record: RECORD, phase: "idle", check: {}, error: null });
+ useSession.setState({ file: null });
+ useSliced.setState({ path: null, name: null });
+});
+afterEach(cleanup);
+
+describe("the job card", () => {
+ it("shows nothing when there is no saved job", () => {
+ useJob.setState({ record: null });
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("offers a check, not a restore, before anything has been verified", () => {
+ render();
+ expect(screen.getByText("Pick up your last job")).toBeTruthy();
+ expect(screen.getByText("A.3mf")).toBeTruthy();
+ expect(screen.getByText("A_U1.3mf")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Check these files" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /^Reopen|Nothing to reopen/ })).toBeNull();
+ expect(useSession.getState().file).toBeNull();
+ });
+
+ it("checks, then reopens unchanged files without preparing or slicing anything", async () => {
+ report({ original: "same", prepared: "same", sliced: "same" });
+ render();
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Check these files" })); });
+ expect(screen.getAllByText("Unchanged since it was recorded")).toHaveLength(3);
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Reopen project and sliced g-code" })); });
+ expect(useSession.getState().file?.path).toBe("C:\\work\\A.3mf");
+ expect(useSliced.getState().path).toBe("C:\\out\\A.gcode");
+ expect(api.convert).not.toHaveBeenCalled();
+ });
+
+ it("a changed G-code is flagged and reopened only after it is named", async () => {
+ report({ original: "same", prepared: "same", sliced: "changed" });
+ render();
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Check these files" })); });
+ expect(screen.getByText("Changed since it was recorded")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Reopen project" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("checkbox"));
+ expect(screen.getByRole("button", { name: "Reopen project and sliced g-code" })).toBeTruthy();
+ });
+
+ it("a missing file is explained and cannot be named", async () => {
+ report({ original: "same", prepared: "missing", sliced: "missing" });
+ render();
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Check these files" })); });
+ expect(screen.getAllByText("Not found where it was")).toHaveLength(2);
+ expect(screen.queryByRole("checkbox")).toBeNull();
+ });
+
+ it("nothing to reopen is said plainly and the button is disabled", async () => {
+ report({ original: "missing", prepared: "missing", sliced: "missing" });
+ render();
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Check these files" })); });
+ expect((screen.getByRole("button", { name: "Nothing to reopen" }) as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it("with the job's own project open it shows the linked files and offers no restore", () => {
+ useSession.setState({ file: { path: "C:\\work\\A.3mf", name: "A.3mf", ext: "3mf" } });
+ render();
+ expect(screen.getByText("This job")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Check these files" })).toBeNull();
+ });
+
+ it("with a different project open it stays out of the way", () => {
+ useSession.setState({ file: { path: "C:\\work\\B.3mf", name: "B.3mf", ext: "3mf" } });
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("forgetting the job removes it", () => {
+ render();
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Forget this job" })); });
+ expect(useJob.getState().record).toBeNull();
+ });
+});
diff --git a/desktop/src/components/JobWorkspaceCard.tsx b/desktop/src/components/JobWorkspaceCard.tsx
new file mode 100644
index 00000000..eb838163
--- /dev/null
+++ b/desktop/src/components/JobWorkspaceCard.tsx
@@ -0,0 +1,136 @@
+import { useState } from "react";
+import { History, Link2 } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { useJob } from "@/store/job";
+import { useSession } from "@/store/session";
+import { restorable, JOB_ROLES, type FileState, type JobRole, type LinkedFile } from "@/lib/jobRecord";
+
+const LABEL: Record = {
+ original: "Project",
+ prepared: "Prepared copy",
+ sliced: "Sliced G-code",
+};
+
+const STATE_TEXT: Record = {
+ same: "Unchanged since it was recorded",
+ changed: "Changed since it was recorded",
+ missing: "Not found where it was",
+ unreadable: "Could not be read",
+ unverified: "Cannot be confirmed as the same file",
+};
+
+const short = (file: LinkedFile) => (file.fingerprint?.sha256 ? file.fingerprint.sha256.slice(0, 8) : null);
+
+/**
+ * One job, three files — and what Studio can still vouch for after a restart.
+ *
+ * Shows which project, prepared copy and sliced G-code belong together. After a
+ * restart it offers to reopen them, but only after checking each against the
+ * size and hash recorded earlier, and never by preparing, slicing or sending
+ * anything again.
+ */
+export function JobWorkspaceCard() {
+ const record = useJob((s) => s.record);
+ const phase = useJob((s) => s.phase);
+ const check = useJob((s) => s.check);
+ const error = useJob((s) => s.error);
+ const runCheck = useJob((s) => s.runCheck);
+ const restore = useJob((s) => s.restore);
+ const forget = useJob((s) => s.forget);
+ const project = useSession((s) => s.file);
+ const [named, setNamed] = useState>(new Set());
+
+ if (!record) return null;
+
+ const resuming = !project;
+ const current = project?.path === record.original.path;
+ if (!resuming && !current) return null;
+
+ const toggle = (role: JobRole) => {
+ const next = new Set(named);
+ if (next.has(role)) next.delete(role); else next.add(role);
+ setNamed(next);
+ };
+
+ // The prepared copy is shown for reference; there is nothing to reopen for it.
+ const plan = phase === "done" ? restorable(check, named).filter((role) => role !== "prepared") : [];
+ const rows = JOB_ROLES.flatMap((role) => (record[role] ? [{ role, file: record[role] as LinkedFile }] : []));
+
+ return (
+
+
+
+
+
+ {resuming ? : }
+ {resuming ? "Pick up your last job" : "This job"}
+
+
+ {resuming
+ ? "Studio kept which files belonged together. Check them before reopening — files can move or change."
+ : "The project, the copy Studio prepared from it and the G-code that came back, kept together."}
+
Not yet recorded — it cannot be confirmed later until it is.
+ )}
+ {canName && (
+
+ )}
+
+ );
+ })}
+
+
+ {error &&
{error}
}
+
+ {resuming && phase === "done" && (
+
+
+
+ Reopening only shows these files again. It does not prepare, slice or send anything.
+
+
+ )}
+
+
+ );
+}
diff --git a/desktop/src/lib/jobRecord.test.ts b/desktop/src/lib/jobRecord.test.ts
new file mode 100644
index 00000000..61fa1545
--- /dev/null
+++ b/desktop/src/lib/jobRecord.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vitest";
+import {
+ classify, clearSliced, linkPrepared, linkSliced, openProject, parseRecord, restorable, withFingerprint,
+ type JobRecord, type JobRole,
+} from "./jobRecord";
+
+const NOW = "2026-01-01T00:00:00.000Z";
+const fp = (n: number) => ({ size_bytes: n, sha256: "a".repeat(63) + String(n % 10) });
+const A = "C:\\work\\A.3mf";
+const B = "C:\\work\\B.3mf";
+
+function fullJob(): JobRecord {
+ let r: JobRecord | null = openProject(null, A, NOW);
+ r = withFingerprint(r, "original", A, fp(1));
+ r = linkPrepared(r, A, "C:\\work\\A_U1.3mf", "preserve", NOW);
+ r = withFingerprint(r, "prepared", "C:\\work\\A_U1.3mf", fp(2));
+ r = linkSliced(r, A, "C:\\out\\A.gcode", NOW);
+ r = withFingerprint(r, "sliced", "C:\\out\\A.gcode", fp(3));
+ return r!;
+}
+
+describe("linking the three identities", () => {
+ it("re-opening the same project keeps its prepared copy and sliced job", () => {
+ const r = fullJob();
+ expect(openProject(r, A, NOW)).toBe(r);
+ });
+
+ it("opening a different project starts a new job with nothing carried over", () => {
+ const r = openProject(fullJob(), B, NOW);
+ expect(r.original.path).toBe(B);
+ expect(r.prepared).toBeNull();
+ expect(r.sliced).toBeNull();
+ });
+
+ it("a prepared copy is not linked to a project that is not the job's original", () => {
+ const r = openProject(null, A, NOW);
+ expect(linkPrepared(r, B, "C:\\work\\B_U1.3mf", "preserve", NOW)).toBe(r);
+ });
+
+ it("a new prepared copy invalidates the earlier sliced job", () => {
+ const r = linkPrepared(fullJob(), A, "C:\\work\\A_U1_2.3mf", "recommended", NOW)!;
+ expect(r.prepared?.mode).toBe("recommended");
+ expect(r.sliced).toBeNull();
+ });
+
+ it("G-code opened with no project, or beside another project, is not tied to this job", () => {
+ const r = openProject(null, A, NOW);
+ expect(linkSliced(r, null, "C:\\out\\x.gcode", NOW)).toBe(r);
+ expect(linkSliced(r, B, "C:\\out\\x.gcode", NOW)).toBe(r);
+ expect(linkSliced(r, A, "C:\\out\\x.gcode", NOW)?.sliced?.name).toBe("x.gcode");
+ });
+
+ it("a late fingerprint for a replaced file is dropped", () => {
+ const r = fullJob();
+ const after = withFingerprint(linkSliced(r, A, "C:\\out\\new.gcode", NOW), "sliced", "C:\\out\\A.gcode", fp(9))!;
+ expect(after.sliced?.path).toBe("C:\\out\\new.gcode");
+ expect(after.sliced?.fingerprint).toBeNull();
+ });
+
+ it("clearing the sliced job leaves the original and prepared copy", () => {
+ const r = clearSliced(fullJob())!;
+ expect(r.sliced).toBeNull();
+ expect(r.prepared).not.toBeNull();
+ });
+});
+
+describe("what a check lets Studio restore", () => {
+ const report = (...pairs: Array<[JobRole, string]>) => pairs.map(([role, status]) => ({ role, status }));
+
+ it("unchanged files restore without being asked twice", () => {
+ const check = classify(fullJob(), report(["original", "same"], ["prepared", "same"], ["sliced", "same"]));
+ expect(restorable(check, new Set())).toEqual(["original", "prepared", "sliced"]);
+ });
+
+ it("a changed file restores only when the person names it", () => {
+ const check = classify(fullJob(), report(["original", "same"], ["prepared", "same"], ["sliced", "changed"]));
+ expect(restorable(check, new Set())).toEqual(["original", "prepared"]);
+ expect(restorable(check, new Set(["sliced"]))).toEqual(["original", "prepared", "sliced"]);
+ });
+
+ it("a missing or unreadable file never restores, even if named", () => {
+ const check = classify(fullJob(), report(["original", "missing"], ["prepared", "unreadable"], ["sliced", "same"]));
+ expect(check).toMatchObject({ original: "missing", prepared: "unreadable", sliced: "same" });
+ expect(restorable(check, new Set(["original", "prepared"]))).toEqual([]);
+ expect(restorable(check, new Set(["sliced"]))).toEqual(["sliced"]);
+ });
+
+ it("what was made from a project that is no longer provably the same is not offered back unasked", () => {
+ const check = classify(fullJob(), report(["original", "changed"], ["prepared", "same"], ["sliced", "same"]));
+ expect(restorable(check, new Set())).toEqual([]);
+ });
+
+ it("a file with no recorded fingerprint is unverified, never same", () => {
+ const r = openProject(null, A, NOW);
+ expect(classify(r, report(["original", "same"]))).toEqual({ original: "unverified" });
+ expect(restorable({ original: "unverified" }, new Set())).toEqual([]);
+ });
+
+ it("a size-only comparison is unverified, never same", () => {
+ expect(classify(fullJob(), report(["original", "unverified"])).original).toBe("unverified");
+ });
+
+ it("a file the engine did not report on is unreadable, not assumed fine", () => {
+ expect(classify(fullJob(), report(["original", "same"])).sliced).toBe("unreadable");
+ });
+});
+
+describe("reading a saved record back", () => {
+ it("round-trips", () => {
+ const r = fullJob();
+ expect(parseRecord(JSON.stringify(r))).toEqual(r);
+ });
+
+ it.each([null, "", "not json", "{}", '{"schema":"job/2"}', '{"schema":"job/1","savedAt":"x"}',
+ '{"schema":"job/1","savedAt":"x","original":{"path":""}}'])("treats %j as no saved job", (raw) => {
+ expect(parseRecord(raw as string | null)).toBeNull();
+ });
+
+ it("drops a malformed fingerprint rather than trusting it", () => {
+ const raw = JSON.stringify({ ...fullJob(), original: { path: A, name: "x", fingerprint: { size_bytes: "big", sha256: 5 } } });
+ expect(parseRecord(raw)?.original.fingerprint).toBeNull();
+ });
+});
+
+describe("re-linking the same file", () => {
+ it("keeps the recorded fingerprint instead of forgetting it", () => {
+ const r = fullJob();
+ expect(linkSliced(r, A, "C:\\out\\A.gcode", NOW)).toBe(r);
+ expect(linkPrepared(r, A, "C:\\work\\A_U1.3mf", "preserve", NOW)).toBe(r);
+ });
+});
diff --git a/desktop/src/lib/jobRecord.ts b/desktop/src/lib/jobRecord.ts
new file mode 100644
index 00000000..c5ecbca6
--- /dev/null
+++ b/desktop/src/lib/jobRecord.ts
@@ -0,0 +1,144 @@
+/**
+ * One job, three files, linked.
+ *
+ * A job is the project that came in (original), the copy Studio prepared from it
+ * (prepared) and the G-code the slicer produced (sliced). Studio keeps their
+ * identities together, so after a restart it can offer to pick the job back up —
+ * and say honestly which of the three it can still vouch for.
+ *
+ * Pure: no storage, no network. The store does those and calls these.
+ */
+
+export const JOB_SCHEMA = "job/1";
+
+export type JobRole = "original" | "prepared" | "sliced";
+export const JOB_ROLES: JobRole[] = ["original", "prepared", "sliced"];
+
+export interface Fingerprint {
+ size_bytes: number;
+ /** null when the file was too large to hash: identity is then size-only and never "same". */
+ sha256: string | null;
+}
+
+export interface LinkedFile {
+ path: string;
+ name: string;
+ /** null until the engine has read the file; a file without one cannot be vouched for. */
+ fingerprint: Fingerprint | null;
+}
+
+export interface JobRecord {
+ schema: typeof JOB_SCHEMA;
+ savedAt: string;
+ original: LinkedFile;
+ prepared: (LinkedFile & { mode: string }) | null;
+ sliced: LinkedFile | null;
+}
+
+/** What the engine reported for a file, plus the one state only the record knows. */
+export type FileState = "same" | "changed" | "missing" | "unreadable" | "unverified";
+
+export type JobCheck = Partial>;
+
+const nameOf = (path: string) => path.split(/[\\/]/).pop() || path;
+
+export const linked = (path: string, fingerprint: Fingerprint | null = null): LinkedFile =>
+ ({ path, name: nameOf(path), fingerprint });
+
+const same = (a: string, b: string) => a === b;
+
+/** A project is opened. The same project keeps its job; any other starts a new one. */
+export function openProject(prev: JobRecord | null, path: string, now: string): JobRecord {
+ if (prev && same(prev.original.path, path)) return prev;
+ return { schema: JOB_SCHEMA, savedAt: now, original: linked(path), prepared: null, sliced: null };
+}
+
+/** A prepared copy is linked only to the project it was made from. */
+export function linkPrepared(prev: JobRecord | null, projectPath: string, preparedPath: string, mode: string, now: string): JobRecord | null {
+ if (!prev || !same(prev.original.path, projectPath)) return prev;
+ if (prev.prepared && same(prev.prepared.path, preparedPath) && prev.prepared.mode === mode) return prev;
+ return { ...prev, savedAt: now, prepared: { ...linked(preparedPath), mode }, sliced: null };
+}
+
+/**
+ * A sliced job is linked only while the project it belongs to is open. G-code
+ * opened with no project, or beside a different one, is not tied to this job.
+ */
+export function linkSliced(prev: JobRecord | null, projectPath: string | null, slicedPath: string, now: string): JobRecord | null {
+ if (!prev || !projectPath || !same(prev.original.path, projectPath)) return prev;
+ if (prev.sliced && same(prev.sliced.path, slicedPath)) return prev;
+ return { ...prev, savedAt: now, sliced: linked(slicedPath) };
+}
+
+/** The engine's fingerprint arrives later; it applies only to the file it was read from. */
+export function withFingerprint(prev: JobRecord | null, role: JobRole, path: string, fingerprint: Fingerprint): JobRecord | null {
+ if (!prev) return prev;
+ const entry = prev[role];
+ if (!entry || !same(entry.path, path)) return prev;
+ return { ...prev, [role]: { ...entry, fingerprint } };
+}
+
+export function clearSliced(prev: JobRecord | null): JobRecord | null {
+ return prev ? { ...prev, sliced: null } : prev;
+}
+
+/** Turn the engine's report into one state per linked file. */
+export function classify(record: JobRecord, files: Array<{ role: JobRole; status: string }>): JobCheck {
+ const out: JobCheck = {};
+ for (const role of JOB_ROLES) {
+ const entry = record[role];
+ if (!entry) continue;
+ const report = files.find((f) => f.role === role);
+ if (!entry.fingerprint) { out[role] = report?.status === "missing" ? "missing" : "unverified"; continue; }
+ switch (report?.status) {
+ case "same": out[role] = "same"; break;
+ case "changed": out[role] = "changed"; break;
+ case "missing": out[role] = "missing"; break;
+ case "unverified": out[role] = "unverified"; break;
+ default: out[role] = "unreadable";
+ }
+ }
+ return out;
+}
+
+/**
+ * Which files restoring would reopen. Unchanged files only, unless the person
+ * names a changed or unverifiable one; missing and unreadable files never.
+ */
+export function restorable(check: JobCheck, accepted: ReadonlySet): JobRole[] {
+ return JOB_ROLES.filter((role) => {
+ const state = check[role];
+ // What was made from the project is offered back only while the project is
+ // provably what it was; otherwise only if the person names it.
+ if (state === "same") return role === "original" || check.original === "same" || accepted.has(role);
+ return (state === "changed" || state === "unverified") && accepted.has(role);
+ });
+}
+
+/** Defensive parse of what local storage holds: anything off-shape is "no saved job". */
+export function parseRecord(raw: string | null): JobRecord | null {
+ if (!raw) return null;
+ try {
+ const v = JSON.parse(raw);
+ if (!v || v.schema !== JOB_SCHEMA || typeof v.savedAt !== "string") return null;
+ const file = (x: unknown): LinkedFile | null => {
+ const f = x as LinkedFile | null;
+ if (!f || typeof f.path !== "string" || !f.path) return null;
+ const fp = f.fingerprint;
+ const ok = fp && typeof fp.size_bytes === "number" && fp.size_bytes >= 0 &&
+ (fp.sha256 === null || (typeof fp.sha256 === "string" && fp.sha256.length === 64));
+ return { path: f.path, name: nameOf(f.path), fingerprint: ok ? { size_bytes: fp.size_bytes, sha256: fp.sha256 } : null };
+ };
+ const original = file(v.original);
+ if (!original) return null;
+ const prepared = file(v.prepared);
+ const sliced = file(v.sliced);
+ return {
+ schema: JOB_SCHEMA, savedAt: v.savedAt, original,
+ prepared: prepared ? { ...prepared, mode: typeof v.prepared?.mode === "string" ? v.prepared.mode : "unknown" } : null,
+ sliced,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/desktop/src/routes/AfterSlicing.tsx b/desktop/src/routes/AfterSlicing.tsx
index abbc78fd..174a3d55 100644
--- a/desktop/src/routes/AfterSlicing.tsx
+++ b/desktop/src/routes/AfterSlicing.tsx
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { PostSliceCard } from "@/components/PostSliceCard";
import { MaterialPlanCard, PrintPlanCard, SendReadyCard } from "@/components/PostSlicePanels";
import { OrcaRoundTrip } from "@/components/OrcaRoundTrip";
+import { JobWorkspaceCard } from "@/components/JobWorkspaceCard";
import { useSliced } from "@/store/sliced";
import { useSession } from "@/store/session";
import { POST_SLICE_EXPLAINER } from "@/lib/postSlice";
@@ -34,6 +35,8 @@ export default function AfterSlicing() {
{POST_SLICE_EXPLAINER}
+
+
{!path && (
diff --git a/desktop/src/routes/Dashboard.tsx b/desktop/src/routes/Dashboard.tsx
index a62ab8cf..9c841a3e 100644
--- a/desktop/src/routes/Dashboard.tsx
+++ b/desktop/src/routes/Dashboard.tsx
@@ -22,6 +22,7 @@ import { useSession } from "@/store/session";
import { useMode } from "@/store/mode";
import { usePrinter } from "@/store/printer";
import { useOpenFile } from "@/hooks/useOpenFile";
+import { JobWorkspaceCard } from "@/components/JobWorkspaceCard";
function timeAgo(iso: string | null): string {
if (!iso) return "";
@@ -120,6 +121,8 @@ export default function Dashboard() {
+
+
{/* Demo Mode — full Intelligence Report, no file or printer needed */}
{demoOn && demo.data?.available && (
diff --git a/desktop/src/store/job.test.ts b/desktop/src/store/job.test.ts
new file mode 100644
index 00000000..a692eec5
--- /dev/null
+++ b/desktop/src/store/job.test.ts
@@ -0,0 +1,217 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue({ port: 4312, token: "test" }) }));
+vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
+vi.mock("@/api", () => ({ jobCheck: vi.fn(), doctor: vi.fn(), convert: vi.fn(), diff: vi.fn() }));
+
+import * as api from "@/api";
+
+const A = "C:\\work\\A.3mf";
+const P = "C:\\work\\A_U1.3mf";
+const G = "C:\\out\\A.gcode";
+const hex = (c: string) => c.repeat(64);
+
+/** A pretend engine: fingerprints by path, and answers checks from a table the test edits. */
+function engine(disk: Record) {
+ vi.mocked(api.jobCheck).mockImplementation(async (files) => ({
+ schema_version: "job-check/1",
+ files: files.map((f) => {
+ const seen = disk[f.path];
+ if (!seen) return { role: f.role, path: f.path, status: "missing" as const };
+ if (!f.expected) return { role: f.role, path: f.path, status: "recorded" as const, observed: seen };
+ const same = seen.size_bytes === f.expected.size_bytes && seen.sha256 === f.expected.sha256;
+ return { role: f.role, path: f.path, status: same ? "same" as const : "changed" as const, observed: seen };
+ }),
+ }));
+}
+
+async function fresh() {
+ vi.resetModules();
+ const job = await import("./job");
+ const session = await import("./session");
+ const sliced = await import("./sliced");
+ return { ...job, useSession: session.useSession, useSliced: sliced.useSliced };
+}
+
+const settle = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ vi.mocked(api.doctor).mockResolvedValue({} as any);
+});
+afterEach(() => vi.restoreAllMocks());
+
+async function linkedJob(disk: Parameters[0]) {
+ engine(disk);
+ const m = await fresh();
+ const stop = m.trackJob();
+ m.useSession.getState().setFile(A);
+ await settle();
+ m.useSession.setState({ convert: { status: "done", error: null, data: { output_path: P, output_name: "A_U1.3mf", validated_ok: true, prepare_mode: "preserve", settings_summary: {} } as any } });
+ await settle();
+ m.useSliced.getState().setSliced(G);
+ await settle();
+ return { ...m, stop };
+}
+
+const DISK = () => ({ [A]: { size_bytes: 10, sha256: hex("a") }, [P]: { size_bytes: 20, sha256: hex("b") }, [G]: { size_bytes: 30, sha256: hex("c") } });
+
+describe("linking as the person works", () => {
+ it("ties original, prepared copy and sliced job together with their fingerprints", async () => {
+ const m = await linkedJob(DISK());
+ const r = m.useJob.getState().record!;
+ expect([r.original.path, r.prepared?.path, r.sliced?.path]).toEqual([A, P, G]);
+ expect(r.original.fingerprint).toEqual({ size_bytes: 10, sha256: hex("a") });
+ expect(r.prepared?.fingerprint?.size_bytes).toBe(20);
+ expect(r.sliced?.fingerprint?.size_bytes).toBe(30);
+ });
+
+ it("a blocked or unvalidated preparation is not linked as a prepared copy", async () => {
+ engine(DISK());
+ const m = await fresh();
+ m.trackJob();
+ m.useSession.getState().setFile(A);
+ await settle();
+ m.useSession.setState({ convert: { status: "done", error: null, data: { output_path: P, validated_ok: false, prepare_mode: "preserve" } as any } });
+ m.useSession.setState({ convert: { status: "done", error: null, data: { output_path: P, validated_ok: true, blocked: true, prepare_mode: "preserve" } as any } });
+ await settle();
+ expect(m.useJob.getState().record?.prepared).toBeNull();
+ });
+
+ it("opening another project starts a new job and drops the old links", async () => {
+ const m = await linkedJob(DISK());
+ m.useSession.getState().setFile("C:\\work\\B.3mf");
+ await settle();
+ const r = m.useJob.getState().record!;
+ expect(r.original.path).toBe("C:\\work\\B.3mf");
+ expect(r.prepared).toBeNull();
+ expect(r.sliced).toBeNull();
+ });
+
+ it("choosing another G-code clears the sliced link", async () => {
+ const m = await linkedJob(DISK());
+ m.useSliced.getState().clear();
+ expect(m.useJob.getState().record?.sliced).toBeNull();
+ });
+});
+
+describe("after a restart", () => {
+ it("the job survives module reinitialisation, and is only offered, not reopened", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ const m = await fresh();
+ expect(m.useJob.getState().record?.original.path).toBe(A);
+ expect(m.useSession.getState().file).toBeNull();
+ expect(m.useSliced.getState().path).toBeNull();
+ expect(api.convert).not.toHaveBeenCalled();
+ });
+
+ it("unchanged files restore the project and G-code, and nothing is prepared or re-sliced", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ engine(DISK());
+ const m = await fresh();
+ await m.useJob.getState().runCheck();
+ expect(m.useJob.getState().check).toEqual({ original: "same", prepared: "same", sliced: "same" });
+ m.useJob.getState().restore(new Set());
+ expect(m.useSession.getState().file?.path).toBe(A);
+ expect(m.useSliced.getState().path).toBe(G);
+ expect(api.convert).not.toHaveBeenCalled();
+ });
+
+ it("a changed G-code is not reopened unless the person names it", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ engine({ ...DISK(), [G]: { size_bytes: 31, sha256: hex("d") } });
+ const m = await fresh();
+ await m.useJob.getState().runCheck();
+ expect(m.useJob.getState().check.sliced).toBe("changed");
+ m.useJob.getState().restore(new Set());
+ expect(m.useSliced.getState().path).toBeNull();
+ expect(m.useSession.getState().file?.path).toBe(A);
+ });
+
+ it("naming a changed G-code reopens it and adopts what is on disk as its identity", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ const moved = { size_bytes: 31, sha256: hex("d") };
+ engine({ ...DISK(), [G]: moved });
+ const m = await fresh();
+ await m.useJob.getState().runCheck();
+ m.useJob.getState().restore(new Set(["sliced"]));
+ await settle();
+ expect(m.useSliced.getState().path).toBe(G);
+ expect(m.useJob.getState().record?.sliced?.fingerprint).toEqual(moved);
+ });
+
+ it("a missing G-code is never reopened, even if named", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ const disk = DISK(); delete (disk as any)[G];
+ engine(disk);
+ const m = await fresh();
+ await m.useJob.getState().runCheck();
+ expect(m.useJob.getState().check.sliced).toBe("missing");
+ m.useJob.getState().restore(new Set(["sliced"]));
+ expect(m.useSliced.getState().path).toBeNull();
+ });
+
+ it("a changed original reopens only when named, and its old prepared copy and G-code are no longer presented as its results", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ engine({ ...DISK(), [A]: { size_bytes: 11, sha256: hex("e") } });
+ const m = await fresh();
+ await m.useJob.getState().runCheck();
+ m.useJob.getState().restore(new Set());
+ expect(m.useSession.getState().file).toBeNull();
+ await m.useJob.getState().runCheck();
+ m.useJob.getState().restore(new Set(["original", "sliced"]));
+ await settle();
+ expect(m.useSession.getState().file?.path).toBe(A);
+ expect(m.useSliced.getState().path).toBeNull();
+ const r = m.useJob.getState().record!;
+ expect(r.prepared).toBeNull();
+ expect(r.sliced).toBeNull();
+ });
+
+ it("a file with no recorded fingerprint is unverified and not reopened unnamed", async () => {
+ vi.mocked(api.jobCheck).mockRejectedValue(new Error("engine down"));
+ const first = await fresh();
+ first.trackJob();
+ first.useSession.getState().setFile(A);
+ await settle();
+ expect(first.useJob.getState().record?.original.fingerprint).toBeNull();
+ engine(DISK());
+ const m = await fresh();
+ await m.useJob.getState().runCheck();
+ expect(m.useJob.getState().check.original).toBe("unverified");
+ m.useJob.getState().restore(new Set());
+ expect(m.useSession.getState().file).toBeNull();
+ });
+
+ it("a check that comes back after the job was forgotten is dropped", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ let release!: (v: any) => void;
+ vi.mocked(api.jobCheck).mockImplementation(() => new Promise((resolve) => { release = resolve; }));
+ const m = await fresh();
+ const pending = m.useJob.getState().runCheck();
+ m.useJob.getState().forget();
+ release({ schema_version: "job-check/1", files: [{ role: "original", path: A, status: "same" }] });
+ await pending;
+ expect(m.useJob.getState().record).toBeNull();
+ expect(m.useJob.getState().phase).toBe("idle");
+ expect(localStorage.getItem("snapstudio.job.v1")).toBeNull();
+ });
+
+ it("restore does nothing until a check has been run", async () => {
+ const first = await linkedJob(DISK());
+ first.stop();
+ const m = await fresh();
+ m.useJob.getState().restore(new Set(["original", "sliced", "prepared"]));
+ expect(m.useSession.getState().file).toBeNull();
+ expect(m.useSliced.getState().path).toBeNull();
+ });
+});
diff --git a/desktop/src/store/job.ts b/desktop/src/store/job.ts
new file mode 100644
index 00000000..5c970180
--- /dev/null
+++ b/desktop/src/store/job.ts
@@ -0,0 +1,171 @@
+import { create } from "zustand";
+import { jobCheck, type JobCheckEntry } from "@/api";
+import {
+ classify, clearSliced, linkPrepared, linkSliced, openProject, parseRecord, restorable, withFingerprint,
+ JOB_SCHEMA, linked, type JobCheck, type JobRecord, type JobRole,
+} from "@/lib/jobRecord";
+import { useSession } from "@/store/session";
+import { useSliced } from "@/store/sliced";
+
+const KEY = "snapstudio.job.v1";
+
+function read(): JobRecord | null {
+ try {
+ return parseRecord(window.localStorage.getItem(KEY));
+ } catch {
+ return null; // private mode, or no storage at all
+ }
+}
+
+function write(record: JobRecord | null) {
+ try {
+ if (record) window.localStorage.setItem(KEY, JSON.stringify(record));
+ else window.localStorage.removeItem(KEY);
+ } catch {
+ /* resuming is a convenience, never a requirement */
+ }
+}
+
+export type CheckPhase = "idle" | "checking" | "done" | "error";
+
+/**
+ * The job in hand: original, prepared copy and sliced G-code, linked, and kept
+ * on this machine so it can be picked up after a restart.
+ *
+ * Restoring only reopens files for review. It never prepares, slices or sends,
+ * and it never trusts a file it could not read back as the one that was recorded.
+ */
+interface JobState {
+ record: JobRecord | null;
+ phase: CheckPhase;
+ check: JobCheck;
+ error: string | null;
+ /** Compare the saved job's files with what was recorded. Read-only. */
+ runCheck: () => Promise;
+ /** Reopen the unchanged files, plus any changed one the person named. */
+ restore: (accepted: ReadonlySet) => void;
+ forget: () => void;
+}
+
+const idle = { phase: "idle" as CheckPhase, check: {} as JobCheck, error: null };
+let checkGeneration = 0;
+
+export const useJob = create((set, get) => ({
+ record: read(),
+ ...idle,
+
+ runCheck: async () => {
+ const record = get().record;
+ if (!record) return;
+ const generation = ++checkGeneration;
+ set({ phase: "checking", check: {}, error: null });
+ const entries: JobCheckEntry[] = (["original", "prepared", "sliced"] as JobRole[]).flatMap((role) => {
+ const entry = record[role];
+ return entry ? [{ role, path: entry.path, ...(entry.fingerprint ? { expected: entry.fingerprint } : {}) }] : [];
+ });
+ try {
+ const report = await jobCheck(entries);
+ // The saved job may have been replaced or forgotten while the engine read its files.
+ if (checkGeneration !== generation || get().record !== record) return;
+ set({ phase: "done", check: classify(record, report.files), error: null });
+ } catch (e: any) {
+ if (checkGeneration !== generation || get().record !== record) return;
+ set({ phase: "error", check: {}, error: String(e?.message ?? e) });
+ }
+ },
+
+ restore: (accepted) => {
+ const { record, check, phase } = get();
+ if (!record || phase !== "done") return;
+ let plan = restorable(check, accepted);
+ let next = record;
+ // A changed original means the prepared copy and sliced job were not made from
+ // what is on disk now. Reopen the file, but do not present them as its results.
+ if (plan.includes("original") && check.original !== "same") {
+ next = { schema: JOB_SCHEMA, savedAt: new Date().toISOString(), original: linked(record.original.path), prepared: null, sliced: null };
+ plan = plan.filter((role) => role === "original");
+ set({ record: next });
+ write(next);
+ void fingerprint("original", next.original.path);
+ }
+ // Naming a changed G-code adopts what is on disk now as the sliced identity.
+ const adoptSliced = plan.includes("sliced") && next.sliced && check.sliced !== "same";
+ if (adoptSliced && next.sliced) {
+ next = { ...next, sliced: { ...next.sliced, fingerprint: null } };
+ set({ record: next });
+ write(next);
+ }
+ if (plan.includes("original")) useSession.getState().setFile(record.original.path);
+ if (plan.includes("sliced") && next.sliced) useSliced.getState().setSliced(next.sliced.path);
+ if (adoptSliced && next.sliced) void fingerprint("sliced", next.sliced.path);
+ set({ ...idle });
+ },
+
+ forget: () => {
+ checkGeneration++;
+ write(null);
+ set({ record: null, ...idle });
+ },
+}));
+
+function update(next: JobRecord | null) {
+ if (next === useJob.getState().record) return;
+ write(next);
+ useJob.setState({ record: next, ...idle });
+}
+
+/** Read the file's size and hash; it applies only if that file is still the one linked. */
+async function fingerprint(role: JobRole, path: string) {
+ try {
+ const report = await jobCheck([{ role, path }]);
+ const observed = report.files[0]?.observed;
+ if (report.files[0]?.status !== "recorded" || !observed) return;
+ const next = withFingerprint(useJob.getState().record, role, path, observed);
+ if (next !== useJob.getState().record) {
+ write(next);
+ useJob.setState({ record: next });
+ }
+ } catch {
+ /* no fingerprint means "cannot be vouched for", which is the honest default */
+ }
+}
+
+/**
+ * Link the files as the person works. Call once, at startup; returns the unsubscribe.
+ * Nothing here starts work: it only notes which files belong together.
+ */
+export function trackJob(): () => void {
+ const now = () => new Date().toISOString();
+ const offSession = useSession.subscribe((s, prev) => {
+ if (s.file && s.file.path !== prev.file?.path) {
+ const before = useJob.getState().record;
+ const next = openProject(before, s.file.path, now());
+ update(next);
+ if (next !== before) void fingerprint("original", s.file.path);
+ }
+ const out = s.convert.status === "done" && s.convert.data && !s.convert.data.blocked && s.convert.data.validated_ok
+ ? s.convert.data.output_path : null;
+ const was = prev.convert.status === "done" ? prev.convert.data?.output_path : null;
+ if (out && out !== was && s.file) {
+ const next = linkPrepared(useJob.getState().record, s.file.path, out, String(s.convert.data?.prepare_mode ?? "unknown"), now());
+ if (next !== useJob.getState().record) {
+ update(next);
+ void fingerprint("prepared", out);
+ }
+ }
+ });
+ const offSliced = useSliced.subscribe((s, prev) => {
+ if (s.path === prev.path) return;
+ const before = useJob.getState().record;
+ if (!s.path) {
+ update(clearSliced(before));
+ return;
+ }
+ const next = linkSliced(before, useSession.getState().file?.path ?? null, s.path, now());
+ if (next !== before) {
+ update(next);
+ void fingerprint("sliced", s.path);
+ }
+ });
+ return () => { offSession(); offSliced(); };
+}
From b9b83f57ea6502cb9dc9146f62e507f89e1bfc36 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 21:22:13 -0400
Subject: [PATCH 04/24] fix(job): consolidated review repair for the job
workspace
Blockers from the Opus+Sol review of 169bc40:
- opening the same project by hand no longer presents an old prepared copy and
G-code as its results: it is checked against its fingerprint first, and a
changed or uncheckable project starts the job over
- fingerprint answers are bound to the request that asked, so a late answer
cannot overwrite a newer identity even for the same path
- missing/unreadable files are never eligible, with or without a fingerprint;
the engine opens the file before reporting changed/unverified/recorded
- reopening a changed original clears an already-open G-code
- the card and store share one reopen plan; an unnamed G-code also needs its
prepared copy intact
- the poll no longer stacks scans on a slow one; a project switch drops the old answer
Verified: desktop vitest 78 files / 911 tests; tsc; vite build; source-independence 0; backend job_identity 15 + post_slice + watch adversarial pass.
Co-Authored-By: Claude Sonnet 5.5
---
backend/snapstudio_core/job_identity.py | 45 ++++---
backend/tests/test_job_identity.py | 31 +++++
desktop/src/components/JobWorkspaceCard.tsx | 13 +-
.../components/OrcaRoundTrip.stale.test.tsx | 25 +++-
desktop/src/components/OrcaRoundTrip.tsx | 16 ++-
desktop/src/lib/jobRecord.test.ts | 19 +++
desktop/src/lib/jobRecord.ts | 26 +++-
desktop/src/store/job.test.ts | 113 ++++++++++++++++++
desktop/src/store/job.ts | 86 +++++++++++--
9 files changed, 324 insertions(+), 50 deletions(-)
diff --git a/backend/snapstudio_core/job_identity.py b/backend/snapstudio_core/job_identity.py
index 008e8971..98aa2018 100644
--- a/backend/snapstudio_core/job_identity.py
+++ b/backend/snapstudio_core/job_identity.py
@@ -12,6 +12,7 @@
import hashlib
import os
+import stat
SCHEMA_VERSION = "job-check/1"
@@ -26,14 +27,13 @@
_CHUNK = 1024 * 1024
-def _sha256(path: str) -> str:
+def _sha256(handle) -> str:
digest = hashlib.sha256()
- with open(path, "rb") as handle:
- while True:
- block = handle.read(_CHUNK)
- if not block:
- break
- digest.update(block)
+ while True:
+ block = handle.read(_CHUNK)
+ if not block:
+ break
+ digest.update(block)
return digest.hexdigest()
@@ -53,19 +53,24 @@ def _expectation(raw) -> dict | None:
def _check_one(role: str, path: str, expected: dict | None) -> dict:
out = {"role": role, "path": path}
try:
- if not os.path.exists(path):
- return {**out, "status": "missing"}
- if not os.path.isfile(path):
- return {**out, "status": "unreadable", "reason": "not a regular file"}
- size = os.stat(path).st_size
- # A size that differs is already a different file; no need to read it.
- if expected is not None and size != expected["size_bytes"]:
- return {**out, "status": "changed", "observed": {"size_bytes": size, "sha256": None}}
- sha = _sha256(path) if size <= MAX_HASH_BYTES else None
- observed = {"size_bytes": size, "sha256": sha}
+ # Opening the file is what proves it can be read; every verdict that lets a
+ # file be reopened (same, changed, recorded, unverified) comes after it.
+ with open(path, "rb") as handle:
+ info = os.fstat(handle.fileno())
+ if not stat.S_ISREG(info.st_mode):
+ return {**out, "status": "unreadable", "reason": "not a regular file"}
+ size = info.st_size
+ # A size that differs is already a different file; no need to hash it.
+ if expected is not None and size != expected["size_bytes"]:
+ return {**out, "status": "changed", "observed": {"size_bytes": size, "sha256": None}}
+ sha = _sha256(handle) if size <= MAX_HASH_BYTES else None
+ except FileNotFoundError:
+ return {**out, "status": "missing"}
+ except IsADirectoryError:
+ return {**out, "status": "unreadable", "reason": "not a regular file"}
except OSError as exc:
return {**out, "status": "unreadable", "reason": exc.__class__.__name__}
-
+ observed = {"size_bytes": size, "sha256": sha}
if expected is None:
return {**out, "status": "recorded", "observed": observed}
if sha is None or expected["sha256"] is None:
@@ -79,7 +84,9 @@ def check(entries: list[dict]) -> dict:
Each entry is `{role, path, expected?}` with `expected = {size_bytes, sha256}`.
Without `expected` the file is only fingerprinted (status `recorded`).
"""
- if not isinstance(entries, list) or len(entries) > MAX_ENTRIES:
+ if not isinstance(entries, list):
+ raise ValueError("files must be a list")
+ if len(entries) > MAX_ENTRIES:
raise ValueError(f"at most {MAX_ENTRIES} files can be checked")
files = []
for entry in entries:
diff --git a/backend/tests/test_job_identity.py b/backend/tests/test_job_identity.py
index 087aa8d2..dd2d258f 100644
--- a/backend/tests/test_job_identity.py
+++ b/backend/tests/test_job_identity.py
@@ -147,3 +147,34 @@ def post(body):
finally:
httpd.shutdown()
httpd.server_close()
+
+
+def test_a_file_that_cannot_be_opened_is_unreadable_even_when_its_size_differs(tmp_path, monkeypatch):
+ f = tmp_path / "a.3mf"
+ expected = _write(f, b"abc")
+ f.write_bytes(b"abcd")
+ real_open = open
+
+ def denied(path, *a, **k):
+ if str(path) == str(f):
+ raise PermissionError("denied")
+ return real_open(path, *a, **k)
+
+ monkeypatch.setattr("builtins.open", denied)
+ item = _one(job_identity.check([{"role": "original", "path": str(f), "expected": expected}]))
+ assert item["status"] == "unreadable"
+
+
+def test_an_oversize_file_that_cannot_be_opened_is_unreadable_not_unverified(tmp_path, monkeypatch):
+ f = tmp_path / "big.gcode"
+ f.write_bytes(b"0123456789")
+ monkeypatch.setattr(job_identity, "MAX_HASH_BYTES", 4)
+ real_open = open
+ monkeypatch.setattr("builtins.open", lambda p, *a, **k: (_ for _ in ()).throw(PermissionError()) if str(p) == str(f) else real_open(p, *a, **k))
+ item = _one(job_identity.check([{"role": "sliced", "path": str(f), "expected": {"size_bytes": 10, "sha256": None}}]), "sliced")
+ assert item["status"] == "unreadable"
+
+
+def test_a_missing_files_key_is_named_plainly():
+ with pytest.raises(ValueError, match="list"):
+ job_identity.check(None)
diff --git a/desktop/src/components/JobWorkspaceCard.tsx b/desktop/src/components/JobWorkspaceCard.tsx
index eb838163..6d880756 100644
--- a/desktop/src/components/JobWorkspaceCard.tsx
+++ b/desktop/src/components/JobWorkspaceCard.tsx
@@ -4,7 +4,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useJob } from "@/store/job";
import { useSession } from "@/store/session";
-import { restorable, JOB_ROLES, type FileState, type JobRole, type LinkedFile } from "@/lib/jobRecord";
+import { planFor, JOB_ROLES, type FileState, type JobRole, type LinkedFile } from "@/lib/jobRecord";
const LABEL: Record = {
original: "Project",
@@ -34,6 +34,7 @@ export function JobWorkspaceCard() {
const record = useJob((s) => s.record);
const phase = useJob((s) => s.phase);
const check = useJob((s) => s.check);
+ const verified = useJob((s) => s.verified);
const error = useJob((s) => s.error);
const runCheck = useJob((s) => s.runCheck);
const restore = useJob((s) => s.restore);
@@ -53,9 +54,9 @@ export function JobWorkspaceCard() {
setNamed(next);
};
- // The prepared copy is shown for reference; there is nothing to reopen for it.
- const plan = phase === "done" ? restorable(check, named).filter((role) => role !== "prepared") : [];
- const rows = JOB_ROLES.flatMap((role) => (record[role] ? [{ role, file: record[role] as LinkedFile }] : []));
+ const plan = phase === "done" ? planFor(check, named) : [];
+ // What was made from the project is listed as its results only once the project is confirmed.
+ const rows = JOB_ROLES.flatMap((role) => (record[role] && (resuming || verified || role === "original") ? [{ role, file: record[role] as LinkedFile }] : []));
return (
@@ -118,6 +119,10 @@ export function JobWorkspaceCard() {
})}
+ {!resuming && !verified && (record.prepared || record.sliced) && (
+
Checking that the project is unchanged before listing its earlier prepared copy and G-code…
+ )}
+
{error &&
{error}
}
{resuming && phase === "done" && (
diff --git a/desktop/src/components/OrcaRoundTrip.stale.test.tsx b/desktop/src/components/OrcaRoundTrip.stale.test.tsx
index 0ef19240..f1d3e2b4 100644
--- a/desktop/src/components/OrcaRoundTrip.stale.test.tsx
+++ b/desktop/src/components/OrcaRoundTrip.stale.test.tsx
@@ -81,17 +81,30 @@ describe("the watch response only ever applies to the job it was asked about", (
expect(useSliced.getState().path).toBeNull();
});
- it("an answer that was superseded by a newer poll for the same job is dropped", async () => {
+ it("a slow scan is not starved: the timer does not stack a second scan on it", async () => {
let calls = 0;
const settle: Array<(value: any) => void> = [];
vi.mocked(watchFolder).mockImplementation(() => new Promise((resolve) => { calls += 1; settle.push(resolve); }));
render();
await act(async () => {});
- await act(async () => { vi.advanceTimersByTime(5000); });
- expect(calls).toBe(2);
- await act(async () => settle[1](reply("C:\\exports\\newer.gcode")));
- await act(async () => settle[0](reply("C:\\exports\\older.gcode")));
- expect(useSliced.getState().path).toBe("C:\\exports\\newer.gcode");
+ await act(async () => { vi.advanceTimersByTime(15000); });
+ expect(calls).toBe(1);
+ await act(async () => settle[0](reply("C:\exports\slow.gcode")));
+ expect(useSliced.getState().path).toBe("C:\exports\slow.gcode");
+ });
+
+ it("switching A to B and back does not reuse A's old answer", async () => {
+ const settle: Record void>> = {};
+ vi.mocked(watchFolder).mockImplementation((_f, project) => new Promise((resolve) => { (settle[project as string] ||= []).push(resolve); }));
+ render();
+ await act(async () => {});
+ await act(async () => settle[file("A").path][0](reply(null)));
+ act(() => useSession.setState({ file: file("B") }));
+ await act(async () => {});
+ act(() => useSession.setState({ file: file("A") }));
+ await act(async () => {});
+ expect(useSliced.getState().path).toBeNull();
+ expect(settle[file("A").path].length).toBe(2);
});
it("auto-open off: a matched job is listed, never opened", async () => {
diff --git a/desktop/src/components/OrcaRoundTrip.tsx b/desktop/src/components/OrcaRoundTrip.tsx
index 12b6750e..f8eea954 100644
--- a/desktop/src/components/OrcaRoundTrip.tsx
+++ b/desktop/src/components/OrcaRoundTrip.tsx
@@ -41,20 +41,26 @@ export function OrcaRoundTrip() {
// newest, so a slow answer about project A, a folder since changed, or a page
// since left can never be applied to whatever is open now.
const ticket = useRef(0);
+ // The question currently being asked, so the timer does not stack a second scan on
+ // a slow first one (and starve it of ever being the newest).
+ const inFlight = useRef(null);
- const look = useCallback(() => {
+ const look = useCallback((fromTimer = false) => {
if (!folder) return;
+ const key = `${folder}|${project?.path ?? ""}`;
+ if (fromTimer && inFlight.current === key) return;
+ inFlight.current = key;
const mine = ++ticket.current;
const asked = { folder, project: project?.path };
setBusy(true);
watchFolder(folder, project?.path)
.then((data) => { if (ticket.current === mine) setAnswer({ ...asked, data }); })
.catch(() => { if (ticket.current === mine) setAnswer(null); })
- .finally(() => { if (ticket.current === mine) setBusy(false); });
+ .finally(() => { if (ticket.current === mine) { setBusy(false); inFlight.current = null; } });
}, [folder, project?.path]);
// Invalidate whatever is in flight when the question changes or the page goes.
- useEffect(() => () => { ticket.current += 1; }, [folder, project?.path]);
+ useEffect(() => () => { ticket.current += 1; inFlight.current = null; setAnswer(null); }, [folder, project?.path]);
// An answer is shown only while it is still about the current folder and project.
const result = answer && answer.folder === folder && answer.project === project?.path ? answer.data : null;
@@ -62,7 +68,7 @@ export function OrcaRoundTrip() {
useEffect(() => {
if (!folder) return;
look();
- const timer = window.setInterval(look, POLL_MS);
+ const timer = window.setInterval(() => look(true), POLL_MS);
return () => window.clearInterval(timer);
}, [folder, look]);
@@ -127,7 +133,7 @@ export function OrcaRoundTrip() {
{folder}
-
-
-
-
-
+
+
+
+
>
)}
diff --git a/desktop/src/store/job.ts b/desktop/src/store/job.ts
index 59b3e0ec..3e656d40 100644
--- a/desktop/src/store/job.ts
+++ b/desktop/src/store/job.ts
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { jobCheck, type JobCheckEntry } from "@/api";
import {
classify, clearSliced, linkPrepared, linkSliced, openProject, parseRecord, planFor, withFingerprint,
- JOB_SCHEMA, linked, type JobCheck, type JobRecord, type JobRole,
+ JOB_SCHEMA, linked, type Fingerprint, type JobCheck, type JobRecord, type JobRole,
} from "@/lib/jobRecord";
import { useSession } from "@/store/session";
import { useSliced } from "@/store/sliced";
@@ -191,9 +191,11 @@ export function trackJob(): () => void {
if (s.path === prev.path) return;
const before = useJob.getState().record;
if (!s.path) {
+ sliceView = null;
update(clearSliced(before));
return;
}
+ watchSliceView(s.path);
const next = linkSliced(before, useSession.getState().file?.path ?? null, s.path, now());
if (next !== before) {
update(next);
@@ -231,3 +233,67 @@ async function verifyOpened(record: JobRecord) {
if (useSession.getState().file?.path === path) fresh();
}
}
+
+/**
+ * What Studio last read from the open G-code: its identity and when. A path alone
+ * is not an identity — Orca writes each new export over the same name — so the
+ * watcher's sightings of this path are compared with this.
+ */
+let sliceView: { path: string; fp: Fingerprint | null; at: number; seq: number } | null = null;
+let viewSeq = 0;
+/** Clock slack between the file system's timestamps and ours. */
+const TIMESTAMP_SLACK_MS = 2000;
+
+function watchSliceView(path: string) {
+ const seq = ++viewSeq;
+ sliceView = { path, fp: null, at: Date.now(), seq };
+ jobCheck([{ role: "sliced", path }]).then((report) => {
+ const observed = report.files[0]?.observed;
+ if (sliceView?.seq === seq && report.files[0]?.status === "recorded" && observed) sliceView.fp = observed;
+ }).catch(() => { /* no identity yet: the next sighting cannot be compared, so nothing is claimed */ });
+}
+
+export interface SightedExport {
+ path: string;
+ size_bytes: number;
+ age_seconds: number;
+ complete: boolean;
+ /** Provenance verdict against the open project, when the watcher computed one. */
+ verdict?: string;
+}
+
+/**
+ * The folder watcher saw a file at the path that is open. Only a completed file
+ * (the watcher's own settle check) that is a different size, or was written after
+ * the open copy was read, is re-read; nothing is hashed on a routine poll. When
+ * the bytes really differ, the saved identity is replaced and everything shown
+ * from the old bytes is read again. A late answer never replaces a newer one.
+ */
+export async function observeSliced(sighting: SightedExport, now = Date.now()) {
+ const view = sliceView;
+ if (!view || view.path !== sighting.path || !sighting.complete || !view.fp) return;
+ const wroteAt = now - sighting.age_seconds * 1000;
+ if (sighting.size_bytes === view.fp.size_bytes && wroteAt <= view.at + TIMESTAMP_SLACK_MS) return;
+ const seq = ++viewSeq;
+ view.seq = seq;
+ view.at = now;
+ try {
+ const report = await jobCheck([{ role: "sliced", path: sighting.path }]);
+ const observed = report.files[0]?.observed;
+ if (sliceView?.seq !== seq || useSliced.getState().path !== sighting.path) return;
+ if (report.files[0]?.status !== "recorded" || !observed) return;
+ const before = sliceView.fp;
+ sliceView.fp = observed;
+ if (before && observed.size_bytes === before.size_bytes && observed.sha256 === before.sha256) return;
+ const record = useJob.getState().record;
+ if (record?.sliced && record.sliced.path === sighting.path) {
+ // A replacement that is another project's slice is not this job's result.
+ update(sighting.verdict === "no_match"
+ ? clearSliced(record)
+ : { ...record, sliced: { ...record.sliced, fingerprint: observed } });
+ }
+ useSliced.getState().refresh();
+ } catch {
+ /* unreadable now: keep what is shown; the next sighting tries again */
+ }
+}
diff --git a/desktop/src/store/sliced.ts b/desktop/src/store/sliced.ts
index 4ed82fb6..dec0459f 100644
--- a/desktop/src/store/sliced.ts
+++ b/desktop/src/store/sliced.ts
@@ -11,17 +11,25 @@ import { create } from "zustand";
interface SlicedState {
path: string | null;
name: string | null;
+ /**
+ * Bumped when the file at `path` is replaced by a different completed export, so
+ * everything read from the old bytes is read again. A file name is not an identity.
+ */
+ revision: number;
setSliced: (path: string) => void;
clear: () => void;
+ refresh: () => void;
}
export const useSliced = create((set) => ({
path: null,
name: null,
+ revision: 0,
setSliced: (path: string) =>
// Split on both separators. Getting this wrong meant `name` stayed the whole
// Windows path, and the page — and the recorded demo — showed a home
// directory where a file name belonged.
set({ path, name: path.split(/[\\/]/).pop() || path }),
clear: () => set({ path: null, name: null }),
+ refresh: () => set((s) => ({ revision: s.revision + 1 })),
}));
From c05d4b9c868bcada65ed02430c5aaa04d45cd58a Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 22:51:28 -0400
Subject: [PATCH 06/24] fix(job): consolidated round-3 repair - exact
modification identity, single sliced read, shared cards
From the Opus+Sol review of fd57982:
- the watcher reports each file's exact modification time and the engine reports the
same for a read, so a rewrite is noticed by identity (size, exact mtime) instead of
'opened time + 2 s'; an equal-size rewrite moments after opening is now caught
- one sliced read at a time, bound to the open view; it fills the job record and the
baseline together, so a late initial read cannot overwrite a newer identity
- a failed read, or a baseline that never formed, is retried on the next completed
sighting; a size-only identity is never treated as the same bytes
- SlicedJobCards: one place keys the four G-code checks by revision; the After
slicing page and the This print page both use it
- tests for each, plus own tests for the two stale-reply guards
Co-Authored-By: Claude Sonnet 5.5
---
backend/snapstudio_core/job_identity.py | 5 +-
backend/snapstudio_core/watch_folder.py | 7 +-
backend/tests/test_job_identity.py | 17 +-
.../tests/test_watch_folder_adversarial.py | 8 +
desktop/src/api.ts | 4 +-
.../components/OrcaRoundTrip.stale.test.tsx | 28 +++
desktop/src/components/OrcaRoundTrip.tsx | 2 +-
.../components/SamePathReplacement.test.tsx | 235 ++++++++++++++----
desktop/src/components/SlicedJobCards.tsx | 21 ++
desktop/src/lib/jobRecord.test.ts | 11 +
desktop/src/lib/jobRecord.ts | 5 +
desktop/src/routes/AfterSlicing.tsx | 9 +-
desktop/src/routes/Cockpit.tsx | 8 +-
desktop/src/store/job.ts | 112 +++++----
14 files changed, 349 insertions(+), 123 deletions(-)
create mode 100644 desktop/src/components/SlicedJobCards.tsx
diff --git a/backend/snapstudio_core/job_identity.py b/backend/snapstudio_core/job_identity.py
index 98aa2018..a330b537 100644
--- a/backend/snapstudio_core/job_identity.py
+++ b/backend/snapstudio_core/job_identity.py
@@ -60,9 +60,10 @@ def _check_one(role: str, path: str, expected: dict | None) -> dict:
if not stat.S_ISREG(info.st_mode):
return {**out, "status": "unreadable", "reason": "not a regular file"}
size = info.st_size
+ mtime_ns = str(info.st_mtime_ns)
# A size that differs is already a different file; no need to hash it.
if expected is not None and size != expected["size_bytes"]:
- return {**out, "status": "changed", "observed": {"size_bytes": size, "sha256": None}}
+ return {**out, "status": "changed", "observed": {"size_bytes": size, "sha256": None, "mtime_ns": mtime_ns}}
sha = _sha256(handle) if size <= MAX_HASH_BYTES else None
except FileNotFoundError:
return {**out, "status": "missing"}
@@ -70,7 +71,7 @@ def _check_one(role: str, path: str, expected: dict | None) -> dict:
return {**out, "status": "unreadable", "reason": "not a regular file"}
except OSError as exc:
return {**out, "status": "unreadable", "reason": exc.__class__.__name__}
- observed = {"size_bytes": size, "sha256": sha}
+ observed = {"size_bytes": size, "sha256": sha, "mtime_ns": mtime_ns}
if expected is None:
return {**out, "status": "recorded", "observed": observed}
if sha is None or expected["sha256"] is None:
diff --git a/backend/snapstudio_core/watch_folder.py b/backend/snapstudio_core/watch_folder.py
index 7c05325d..9ccc811f 100644
--- a/backend/snapstudio_core/watch_folder.py
+++ b/backend/snapstudio_core/watch_folder.py
@@ -207,7 +207,7 @@ def scan(folder: str | Path, *, since: float | None = None,
continue
if stat.st_mtime < cutoff:
continue
- recent.append((stat.st_mtime, stat.st_size, str(path), path))
+ recent.append((stat.st_mtime, stat.st_size, str(path), path, stat.st_mtime_ns))
recent.sort(key=lambda item: (item[0], item[1], item[2]), reverse=True)
now = time.time()
@@ -215,13 +215,16 @@ def scan(folder: str | Path, *, since: float | None = None,
out["seen"] = len(entries)
if unreadable:
out["unreadable"] = unreadable
- for modified, size, _text, path in recent[:(limit or MAX_CANDIDATES)]:
+ for modified, size, _text, path, modified_ns in recent[:(limit or MAX_CANDIDATES)]:
complete, why = _looks_finished(path, size, modified, now)
out["candidates"].append({
"path": str(path),
"name": path.name,
"size_bytes": size,
"modified": modified,
+ # Exact modification time as text (a JSON number would lose digits). Lets a caller
+ # tell "the same file" from "rewritten" without reading it.
+ "modified_ns": str(modified_ns),
"age_seconds": int(max(0, now - modified)),
"complete": complete,
"state": why,
diff --git a/backend/tests/test_job_identity.py b/backend/tests/test_job_identity.py
index dd2d258f..d78d16b5 100644
--- a/backend/tests/test_job_identity.py
+++ b/backend/tests/test_job_identity.py
@@ -31,7 +31,8 @@ def test_recording_a_file_returns_its_size_and_hash(tmp_path):
out = job_identity.check([{"role": "original", "path": str(f)}])
item = _one(out)
assert item["status"] == "recorded"
- assert item["observed"] == expected
+ assert {k: item["observed"][k] for k in ("size_bytes", "sha256")} == expected
+ assert item["observed"]["mtime_ns"].isdigit()
def test_an_unchanged_file_is_same(tmp_path):
@@ -71,7 +72,7 @@ def test_a_file_too_large_to_hash_is_unverified_never_same(tmp_path, monkeypatch
f.write_bytes(b"0123456789")
item = _one(job_identity.check([{"role": "sliced", "path": str(f), "expected": {"size_bytes": 10, "sha256": None}}]), "sliced")
assert item["status"] == "unverified"
- assert item["observed"] == {"size_bytes": 10, "sha256": None}
+ assert (item["observed"]["size_bytes"], item["observed"]["sha256"]) == (10, None)
def test_a_recorded_hash_with_a_different_size_is_changed_without_hashing(tmp_path):
@@ -178,3 +179,15 @@ def test_an_oversize_file_that_cannot_be_opened_is_unreadable_not_unverified(tmp
def test_a_missing_files_key_is_named_plainly():
with pytest.raises(ValueError, match="list"):
job_identity.check(None)
+
+
+def test_a_rewrite_with_the_same_size_changes_the_modification_identity_but_not_the_name(tmp_path):
+ f = tmp_path / "a.gcode"
+ f.write_bytes(b"aaaa")
+ first = _one(job_identity.check([{"role": "sliced", "path": str(f)}]), "sliced")["observed"]
+ f.write_bytes(b"bbbb")
+ os.utime(f, ns=(1_700_000_000_000_000_000, 1_700_000_000_000_000_000))
+ second = _one(job_identity.check([{"role": "sliced", "path": str(f)}]), "sliced")["observed"]
+ assert (first["size_bytes"], second["size_bytes"]) == (4, 4)
+ assert first["sha256"] != second["sha256"]
+ assert first["mtime_ns"] != second["mtime_ns"]
diff --git a/backend/tests/test_watch_folder_adversarial.py b/backend/tests/test_watch_folder_adversarial.py
index 2ba7f11e..414a8820 100644
--- a/backend/tests/test_watch_folder_adversarial.py
+++ b/backend/tests/test_watch_folder_adversarial.py
@@ -290,3 +290,11 @@ def test_what_the_watcher_remembers_is_bounded(tmp_path, monkeypatch):
write(tmp_path, f"job{index}.gcode")
watch_folder.scan(tmp_path)
assert len(watch_folder._OBSERVED) <= 4
+
+
+def test_a_candidate_carries_its_exact_modification_time_as_text(tmp_path):
+ f = tmp_path / "job.gcode"
+ f.write_text(COMPLETE)
+ out = watch_folder.match_project(str(tmp_path), None)
+ candidate = next(c for c in out["candidates"] if c["name"] == "job.gcode")
+ assert candidate["modified_ns"] == str(os.stat(f).st_mtime_ns)
diff --git a/desktop/src/api.ts b/desktop/src/api.ts
index d2f0eefc..4b7ab332 100644
--- a/desktop/src/api.ts
+++ b/desktop/src/api.ts
@@ -1470,6 +1470,8 @@ export interface WatchCandidate {
name: string;
size_bytes: number;
age_seconds: number;
+ /** Exact modification time as text (nanoseconds). */
+ modified_ns?: string;
complete: boolean;
state: string;
job?: {
@@ -1509,7 +1511,7 @@ export interface JobCheckFile {
role: JobCheckEntry["role"];
path: string;
status: "recorded" | "same" | "changed" | "missing" | "unreadable" | "unverified";
- observed?: { size_bytes: number; sha256: string | null };
+ observed?: { size_bytes: number; sha256: string | null; mtime_ns?: string };
reason?: string;
}
diff --git a/desktop/src/components/OrcaRoundTrip.stale.test.tsx b/desktop/src/components/OrcaRoundTrip.stale.test.tsx
index f1d3e2b4..f3a70098 100644
--- a/desktop/src/components/OrcaRoundTrip.stale.test.tsx
+++ b/desktop/src/components/OrcaRoundTrip.stale.test.tsx
@@ -107,6 +107,34 @@ describe("the watch response only ever applies to the job it was asked about", (
expect(settle[file("A").path].length).toBe(2);
});
+ it("N5a: an older scan of the SAME project, released after a newer one was asked, opens nothing", async () => {
+ const asked: Array<(value: any) => void> = [];
+ vi.mocked(watchFolder).mockImplementation(() => new Promise((resolve) => { asked.push(resolve); }));
+ render();
+ await act(async () => {}); // A1 asked
+ act(() => useSession.setState({ file: file("B") }));
+ await act(async () => {}); // B asked
+ act(() => useSession.setState({ file: file("A") }));
+ await act(async () => {}); // A2 asked
+ expect(asked.length).toBe(3);
+ await act(async () => asked[2](reply(null)));
+ await act(async () => asked[0](reply("C:\\exports\\old-A.gcode"))); // matches folder+project: only the ticket can stop it
+ expect(useSliced.getState().path).toBeNull();
+ });
+
+ it("N5b: switching project in the same render that turns auto-open on does not open the old project's matched job", async () => {
+ useWatch.setState({ autoOpen: false });
+ const asked: Record void>> = {};
+ vi.mocked(watchFolder).mockImplementation((_f, project) => new Promise((resolve) => { (asked[project as string] ||= []).push(resolve); }));
+ render();
+ await act(async () => {});
+ await act(async () => asked[file("A").path][0](reply("C:\\exports\\A.gcode"))); // matched, but auto-open is off
+ expect(useSliced.getState().path).toBeNull();
+ act(() => { useSession.setState({ file: file("B") }); useWatch.setState({ autoOpen: true }); });
+ await act(async () => {});
+ expect(useSliced.getState().path).toBeNull();
+ });
+
it("auto-open off: a matched job is listed, never opened", async () => {
useWatch.setState({ autoOpen: false });
vi.mocked(watchFolder).mockResolvedValue(reply("C:\\exports\\A.gcode"));
diff --git a/desktop/src/components/OrcaRoundTrip.tsx b/desktop/src/components/OrcaRoundTrip.tsx
index 2d386c56..b1d0638b 100644
--- a/desktop/src/components/OrcaRoundTrip.tsx
+++ b/desktop/src/components/OrcaRoundTrip.tsx
@@ -86,7 +86,7 @@ export function OrcaRoundTrip() {
if (!openPath || !result) return;
for (const c of result.candidates ?? []) {
if (c.path === openPath) {
- void observeSliced({ path: c.path, size_bytes: c.size_bytes, age_seconds: c.age_seconds, complete: c.complete, verdict: c.provenance?.verdict });
+ void observeSliced({ path: c.path, size_bytes: c.size_bytes, modified_ns: c.modified_ns, complete: c.complete, verdict: c.provenance?.verdict });
}
}
}, [result, openPath]);
diff --git a/desktop/src/components/SamePathReplacement.test.tsx b/desktop/src/components/SamePathReplacement.test.tsx
index 41c6fe7f..52af23e5 100644
--- a/desktop/src/components/SamePathReplacement.test.tsx
+++ b/desktop/src/components/SamePathReplacement.test.tsx
@@ -5,10 +5,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue({ port: 4312, token: "test" }) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
-vi.mock("@/api", () => ({ watchFolder: vi.fn(), jobCheck: vi.fn(), doctor: vi.fn().mockResolvedValue({}), convert: vi.fn(), diff: vi.fn() }));
+vi.mock("@/api", () => ({
+ watchFolder: vi.fn(), jobCheck: vi.fn(), doctor: vi.fn().mockResolvedValue({}), convert: vi.fn(), diff: vi.fn(),
+ postSlice: vi.fn().mockResolvedValue({}), slicedCost: vi.fn().mockResolvedValue({}), sendCheck: vi.fn().mockResolvedValue({}),
+ materialPlan: vi.fn().mockResolvedValue({}), printPlan: vi.fn().mockResolvedValue({}),
+}));
import * as api from "@/api";
import { OrcaRoundTrip } from "@/components/OrcaRoundTrip";
+import { SlicedJobCards } from "@/components/SlicedJobCards";
import { observeSliced, trackJob, useJob } from "@/store/job";
import { useSession } from "@/store/session";
import { useSliced } from "@/store/sliced";
@@ -17,16 +22,21 @@ import { useWatch } from "@/store/watch";
const A = "C:\\work\\A.3mf";
const P = "C:\\out\\A.gcode";
const hex = (c: string) => c.repeat(64);
-const FP_A = { size_bytes: 30, sha256: hex("a") }; // the G-code Studio read first
-const FP_B = { size_bytes: 31, sha256: hex("b") }; // a different, completed export at the same path
+
+// Authored identities. mtime is the exact modification time the engine/watcher reports (text).
+const V1 = { size_bytes: 30, sha256: hex("a"), mtime_ns: "1000" }; // the G-code Studio read first
+const V2 = { size_bytes: 31, sha256: hex("b"), mtime_ns: "2000" }; // a different completed export, different size
+const V3 = { size_bytes: 30, sha256: hex("c"), mtime_ns: "1500" }; // different bytes, SAME size, written moments later
const recorded = (observed: any) => ({ schema_version: "job-check/1", files: [{ role: "sliced", path: P, status: "recorded", observed }] }) as any;
-const settle = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
-const sighting = (over: Partial[0]> = {}) =>
- ({ path: P, size_bytes: 31, age_seconds: 1, complete: true, ...over });
+const settle = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
+const sighting = (v: { size_bytes: number; mtime_ns: string }, over: Partial[0]> = {}) =>
+ ({ path: P, size_bytes: v.size_bytes, modified_ns: v.mtime_ns, complete: true, ...over });
+const sliceCalls = () => vi.mocked(api.jobCheck).mock.calls.filter(([files]: any) => files[0]?.role === "sliced").length;
let stop: () => void;
let onDisk: any;
+let failNext: boolean;
beforeEach(async () => {
vi.clearAllMocks();
@@ -34,11 +44,15 @@ beforeEach(async () => {
useJob.setState({ record: null, verified: false, phase: "idle", check: {}, error: null });
useSliced.setState({ path: null, name: null, revision: 0 });
useSession.setState({ file: null });
- onDisk = FP_A;
- vi.mocked(api.jobCheck).mockImplementation(async (files: any) => recorded(onDisk) && ({
- schema_version: "job-check/1",
- files: files.map((f: any) => ({ role: f.role, path: f.path, status: "recorded", observed: f.role === "sliced" ? onDisk : { size_bytes: 10, sha256: hex("o") } })),
- }));
+ onDisk = V1;
+ failNext = false;
+ vi.mocked(api.jobCheck).mockImplementation(async (files: any) => {
+ if (failNext && files[0].role === "sliced") { failNext = false; throw new Error("engine busy"); }
+ return {
+ schema_version: "job-check/1",
+ files: files.map((f: any) => ({ role: f.role, path: f.path, status: "recorded", observed: f.role === "sliced" ? onDisk : { size_bytes: 10, sha256: hex("o"), mtime_ns: "1" } })),
+ };
+ });
stop = trackJob();
useSession.getState().setFile(A);
await settle();
@@ -48,77 +62,190 @@ beforeEach(async () => {
afterEach(() => { stop(); cleanup(); });
describe("a new export written over the open G-code's path", () => {
+ it("opening the G-code reads it once and records that identity", () => {
+ expect(sliceCalls()).toBe(1);
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ size_bytes: 30, sha256: hex("a") });
+ });
+
it("replaces the saved identity and makes everything read from the old bytes read again", async () => {
- expect(useJob.getState().record?.sliced?.fingerprint).toEqual(FP_A);
- const before = useSliced.getState().revision;
- onDisk = FP_B;
- await observeSliced(sighting());
- expect(useJob.getState().record?.sliced?.fingerprint).toEqual(FP_B);
- expect(useSliced.getState().revision).toBe(before + 1);
+ onDisk = V2;
+ observeSliced(sighting(V2));
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ size_bytes: 31, sha256: hex("b") });
+ expect(useSliced.getState().revision).toBe(1);
expect(useSliced.getState().path).toBe(P);
});
- it("a rewrite with identical bytes is re-read once and changes nothing", async () => {
- const before = useSliced.getState().revision;
- await observeSliced(sighting({ size_bytes: 30, age_seconds: 0 }));
- expect(useSliced.getState().revision).toBe(before);
- expect(useJob.getState().record?.sliced?.fingerprint).toEqual(FP_A);
+ it("S1: a different export of the SAME size written moments after the first is still noticed", async () => {
+ onDisk = V3;
+ observeSliced(sighting(V3));
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ sha256: hex("c") });
+ expect(useSliced.getState().revision).toBe(1);
+ });
+
+ it("an untouched file is never read again, however often it is sighted", async () => {
+ for (let i = 0; i < 6; i++) { observeSliced(sighting(V1)); await settle(); }
+ expect(sliceCalls()).toBe(1);
+ expect(useSliced.getState().revision).toBe(0);
+ });
+
+ it("a rewrite with identical bytes is read once, changes nothing, and is then remembered", async () => {
+ const same = { ...V1, mtime_ns: "5000" };
+ onDisk = same;
+ observeSliced(sighting(same));
+ await settle();
+ expect(useSliced.getState().revision).toBe(0);
+ observeSliced(sighting(same));
+ await settle();
+ expect(sliceCalls()).toBe(2);
});
it("an incomplete file at that path is never read as the replacement", async () => {
- onDisk = FP_B;
- vi.mocked(api.jobCheck).mockClear();
- await observeSliced(sighting({ complete: false }));
- expect(api.jobCheck).not.toHaveBeenCalled();
- expect(useJob.getState().record?.sliced?.fingerprint).toEqual(FP_A);
+ onDisk = V2;
+ observeSliced(sighting(V2, { complete: false }));
+ await settle();
+ expect(sliceCalls()).toBe(1);
expect(useSliced.getState().revision).toBe(0);
});
- it("a routine poll that sees the same untouched file hashes nothing", async () => {
- vi.mocked(api.jobCheck).mockClear();
- for (let i = 0; i < 5; i++) await observeSliced(sighting({ size_bytes: 30, age_seconds: 600 }));
- expect(api.jobCheck).not.toHaveBeenCalled();
+ it("S3: a failed read is retried on the next completed sighting, even for an equal-size rewrite", async () => {
+ onDisk = V3;
+ failNext = true;
+ observeSliced(sighting(V3));
+ await settle();
+ expect(useSliced.getState().revision).toBe(0);
+ observeSliced(sighting(V3));
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ sha256: hex("c") });
+ expect(useSliced.getState().revision).toBe(1);
+ });
+
+ it("S4: if the first read never established an identity, the next completed sighting reads and refreshes", async () => {
+ useSliced.getState().clear(); // unlinks the first read while tracking
+ await settle();
+ stop();
+ failNext = true;
+ stop = trackJob();
+ useSliced.getState().setSliced(P); // initial read throws
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint ?? null).toBeNull();
+ observeSliced(sighting(V1));
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ sha256: hex("a") });
+ expect(useSliced.getState().revision).toBe(1);
+ });
+
+ it("S2: a read still in flight when a replacement is sighted cannot be overwritten by, or overwrite, it", async () => {
+ stop();
+ useSliced.getState().clear();
+ const releases: Array<(v: any) => void> = [];
+ vi.mocked(api.jobCheck).mockImplementation(() => new Promise((resolve) => { releases.push(resolve); }));
+ stop = trackJob();
+ useSliced.getState().setSliced(P); // initial read #1 pending with the OLD bytes
+ await settle();
+ observeSliced(sighting(V2)); // sighted meanwhile: no second read is stacked
+ await settle();
+ expect(releases.length).toBe(1);
+ releases[0](recorded(V1)); // old bytes land
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ sha256: hex("a") });
+ onDisk = V2;
+ vi.mocked(api.jobCheck).mockImplementation(async () => recorded(V2));
+ observeSliced(sighting(V2)); // the replacement is now read, and wins
+ await settle();
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ sha256: hex("b") });
+ });
+
+ it("a read for a path that is no longer open is discarded", async () => {
+ stop();
+ useSliced.getState().clear();
+ const releases: Array<(v: any) => void> = [];
+ vi.mocked(api.jobCheck).mockImplementation(() => new Promise((resolve) => { releases.push(resolve); }));
+ stop = trackJob();
+ useSliced.getState().setSliced(P);
+ await settle();
+ useSliced.getState().clear();
+ releases[0](recorded(V2));
+ await settle();
+ expect(useJob.getState().record?.sliced ?? null).toBeNull();
+ expect(useSliced.getState().revision).toBe(0);
});
- it("a late observation cannot overwrite the replacement that was read after it", async () => {
+ it("S2b: a replacement read still in flight when the G-code is closed refreshes nothing", async () => {
const releases: Array<(v: any) => void> = [];
vi.mocked(api.jobCheck).mockImplementation(() => new Promise((resolve) => { releases.push(resolve); }));
- const first = observeSliced(sighting({ size_bytes: 31 }));
- const second = observeSliced(sighting({ size_bytes: 32 }));
- const FP_C = { size_bytes: 32, sha256: hex("c") };
- releases[1](recorded(FP_C));
- await second;
- releases[0](recorded(FP_B));
- await first;
- expect(useJob.getState().record?.sliced?.fingerprint).toEqual(FP_C);
+ observeSliced(sighting(V2)); // replacement read pending
+ await settle();
+ expect(releases.length).toBe(1);
+ useSliced.getState().clear(); // the person closes the G-code
+ releases[0](recorded(V2));
+ await settle();
+ expect(useSliced.getState().revision).toBe(0);
});
it("a replacement that is another project's slice is not kept as this job's result, but is no longer shown as the old one", async () => {
- onDisk = FP_B;
- await observeSliced(sighting({ verdict: "no_match" }));
+ onDisk = V2;
+ observeSliced(sighting(V2, { verdict: "no_match" }));
+ await settle();
expect(useJob.getState().record?.sliced).toBeNull();
expect(useSliced.getState().revision).toBe(1);
});
+ it("N6: a size-only identity (no hash) is never treated as the same bytes", async () => {
+ stop();
+ useSliced.getState().clear();
+ onDisk = { size_bytes: 30, sha256: null, mtime_ns: "1000" };
+ stop = trackJob();
+ useSliced.getState().setSliced(P);
+ await settle();
+ onDisk = { size_bytes: 30, sha256: null, mtime_ns: "9000" };
+ observeSliced(sighting({ size_bytes: 30, mtime_ns: "9000" }));
+ await settle();
+ expect(useSliced.getState().revision).toBe(1);
+ });
+
it("a sighting of some other path is ignored", async () => {
- vi.mocked(api.jobCheck).mockClear();
- await observeSliced(sighting({ path: "C:\\out\\other.gcode" }));
- expect(api.jobCheck).not.toHaveBeenCalled();
+ observeSliced(sighting(V2, { path: "C:\\out\\other.gcode" }));
+ await settle();
+ expect(sliceCalls()).toBe(1);
+ });
+});
+
+describe("every page that shows the G-code checks re-reads them on a replacement", () => {
+ it("the shared cards read again when the revision changes, and not otherwise", async () => {
+ render();
+ await act(async () => { await settle(); });
+ const first = vi.mocked(api.postSlice).mock.calls.length;
+ expect(first).toBe(1);
+ await act(async () => { useSliced.getState().refresh(); await settle(); });
+ expect(vi.mocked(api.postSlice).mock.calls.length).toBe(2);
+ await act(async () => { await settle(); });
+ expect(vi.mocked(api.postSlice).mock.calls.length).toBe(2);
+ });
+
+ it("both pages use the shared cards instead of mounting the four cards themselves", () => {
+ const pages = import.meta.glob("../routes/{AfterSlicing,Cockpit}.tsx", { query: "?raw", import: "default", eager: true }) as Record;
+ expect(Object.keys(pages).length).toBe(2);
+ for (const [page, text] of Object.entries(pages)) {
+ expect(text, page).toContain(" {
- const candidate = (over: any = {}) => ({ path: P, name: "A.gcode", size_bytes: 31, age_seconds: 1, complete: true, state: "complete", ...over });
+ const candidate = (v: { size_bytes: number; mtime_ns: string }, over: any = {}) =>
+ ({ path: P, name: "A.gcode", size_bytes: v.size_bytes, modified_ns: v.mtime_ns, age_seconds: 1, complete: true, state: "complete", ...over });
const reply = (candidates: any[], best: string | null = null) =>
({ schema_version: "watch/1", available: true, folder: "C:\\out", best, candidates, summary: "" }) as any;
it("a completed export of the open path refreshes it; ambiguity elsewhere still opens nothing", async () => {
- useSliced.getState().clear();
- useSliced.getState().setSliced(P);
- await settle();
useWatch.setState({ folder: "C:\\out", autoOpen: true });
- onDisk = FP_B;
- vi.mocked(api.watchFolder).mockResolvedValue(reply([candidate(), candidate({ path: "C:\\out\\x.gcode", name: "x.gcode" })], null));
+ onDisk = V3;
+ vi.mocked(api.watchFolder).mockResolvedValue(reply([candidate(V3), candidate(V2, { path: "C:\\out\\x.gcode", name: "x.gcode" })], null));
render();
await act(async () => { await settle(); });
expect(useSliced.getState().revision).toBe(1);
@@ -127,11 +254,11 @@ describe("through the folder watcher", () => {
it("an export still being written leaves the open job untouched", async () => {
useWatch.setState({ folder: "C:\\out", autoOpen: true });
- onDisk = FP_B;
- vi.mocked(api.watchFolder).mockResolvedValue(reply([candidate({ complete: false, state: "still being written" })]));
+ onDisk = V2;
+ vi.mocked(api.watchFolder).mockResolvedValue(reply([candidate(V2, { complete: false, state: "still being written" })]));
render();
await act(async () => { await settle(); });
expect(useSliced.getState().revision).toBe(0);
- expect(useJob.getState().record?.sliced?.fingerprint).toEqual(FP_A);
+ expect(useJob.getState().record?.sliced?.fingerprint).toMatchObject({ sha256: hex("a") });
});
});
diff --git a/desktop/src/components/SlicedJobCards.tsx b/desktop/src/components/SlicedJobCards.tsx
new file mode 100644
index 00000000..3878a401
--- /dev/null
+++ b/desktop/src/components/SlicedJobCards.tsx
@@ -0,0 +1,21 @@
+import { PostSliceCard } from "@/components/PostSliceCard";
+import { MaterialPlanCard, PrintPlanCard, SendReadyCard } from "@/components/PostSlicePanels";
+import { useSliced } from "@/store/sliced";
+
+/**
+ * The four checks of an open G-code, read again whenever the file at that path is
+ * replaced by a different finished export. Every page that shows them goes through
+ * this one place, so none can keep presenting the old bytes' analysis as current.
+ */
+export function SlicedJobCards({ path, projectPath }: { path: string; projectPath?: string }) {
+ const revision = useSliced((s) => s.revision);
+ const key = `${path}#${revision}`;
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/desktop/src/lib/jobRecord.test.ts b/desktop/src/lib/jobRecord.test.ts
index 3a831c9a..8255f98b 100644
--- a/desktop/src/lib/jobRecord.test.ts
+++ b/desktop/src/lib/jobRecord.test.ts
@@ -148,3 +148,14 @@ describe("review repair round (lib)", () => {
expect(parseRecord(raw)?.original.fingerprint).toBeNull();
});
});
+
+describe("re-reading the same bytes", () => {
+ it("does not make a new record", () => {
+ const r = fullJob();
+ expect(withFingerprint(r, "original", A, { ...fp(1), mtime_ns: "9" })).toBe(r);
+ });
+ it("different bytes do", () => {
+ const r = fullJob();
+ expect(withFingerprint(r, "original", A, fp(7))).not.toBe(r);
+ });
+});
diff --git a/desktop/src/lib/jobRecord.ts b/desktop/src/lib/jobRecord.ts
index 6bd75d46..18d62f37 100644
--- a/desktop/src/lib/jobRecord.ts
+++ b/desktop/src/lib/jobRecord.ts
@@ -18,6 +18,8 @@ export interface Fingerprint {
size_bytes: number;
/** null when the file was too large to hash: identity is then size-only and never "same". */
sha256: string | null;
+ /** Exact modification time (text), when the engine reported it. Not part of identity; used to notice a rewrite. */
+ mtime_ns?: string;
}
export interface LinkedFile {
@@ -75,6 +77,9 @@ export function withFingerprint(prev: JobRecord | null, role: JobRole, path: str
if (!prev) return prev;
const entry = prev[role];
if (!entry || !same(entry.path, path)) return prev;
+ const had = entry.fingerprint;
+ // Re-reading the same bytes changes nothing worth a new record (and must not disturb a check in progress).
+ if (had && had.sha256 !== null && had.sha256 === fingerprint.sha256 && had.size_bytes === fingerprint.size_bytes) return prev;
return { ...prev, [role]: { ...entry, fingerprint } };
}
diff --git a/desktop/src/routes/AfterSlicing.tsx b/desktop/src/routes/AfterSlicing.tsx
index c3b10f4b..edff3085 100644
--- a/desktop/src/routes/AfterSlicing.tsx
+++ b/desktop/src/routes/AfterSlicing.tsx
@@ -2,8 +2,7 @@ import { useState } from "react";
import { FileCheck2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
-import { PostSliceCard } from "@/components/PostSliceCard";
-import { MaterialPlanCard, PrintPlanCard, SendReadyCard } from "@/components/PostSlicePanels";
+import { SlicedJobCards } from "@/components/SlicedJobCards";
import { OrcaRoundTrip } from "@/components/OrcaRoundTrip";
import { JobWorkspaceCard } from "@/components/JobWorkspaceCard";
import { useSliced } from "@/store/sliced";
@@ -23,7 +22,6 @@ import { POST_SLICE_EXPLAINER } from "@/lib/postSlice";
export default function AfterSlicing() {
const path = useSliced((s) => s.path);
const name = useSliced((s) => s.name);
- const revision = useSliced((s) => s.revision);
const setSliced = useSliced((s) => s.setSliced);
const clear = useSliced((s) => s.clear);
const project = useSession((s) => s.file);
@@ -97,10 +95,7 @@ export default function AfterSlicing() {
Choose another file
-
-
-
-
+
>
)}
diff --git a/desktop/src/routes/Cockpit.tsx b/desktop/src/routes/Cockpit.tsx
index 783f28bb..6ad42676 100644
--- a/desktop/src/routes/Cockpit.tsx
+++ b/desktop/src/routes/Cockpit.tsx
@@ -9,8 +9,7 @@ import { FidelityCard } from "@/components/FidelityCard";
import { FixLedgerCard } from "@/components/FixLedgerCard";
import { OrcaHandoff } from "@/components/OrcaHandoff";
import { OrcaRoundTrip } from "@/components/OrcaRoundTrip";
-import { PostSliceCard } from "@/components/PostSliceCard";
-import { MaterialPlanCard, PrintPlanCard, SendReadyCard } from "@/components/PostSlicePanels";
+import { SlicedJobCards } from "@/components/SlicedJobCards";
import { useSession } from "@/store/session";
import { useSliced } from "@/store/sliced";
@@ -92,10 +91,7 @@ export default function Cockpit() {
{sliced ? (
<>
-
-
-
-
+
>
) : (
diff --git a/desktop/src/store/job.ts b/desktop/src/store/job.ts
index 3e656d40..858fd8bf 100644
--- a/desktop/src/store/job.ts
+++ b/desktop/src/store/job.ts
@@ -112,7 +112,7 @@ export const useJob = create((set, get) => ({
useSession.getState().setFile(record.original.path);
}
if (plan.includes("sliced") && next.sliced) useSliced.getState().setSliced(next.sliced.path);
- if (adoptSliced && next.sliced) void fingerprint("sliced", next.sliced.path);
+ if (adoptSliced && next.sliced) rebaselineSliced(next.sliced.path);
set({ ...idle });
},
@@ -197,10 +197,7 @@ export function trackJob(): () => void {
}
watchSliceView(s.path);
const next = linkSliced(before, useSession.getState().file?.path ?? null, s.path, now());
- if (next !== before) {
- update(next);
- void fingerprint("sliced", s.path);
- }
+ if (next !== before) update(next);
});
return () => { offSession(); offSliced(); };
}
@@ -235,28 +232,59 @@ async function verifyOpened(record: JobRecord) {
}
/**
- * What Studio last read from the open G-code: its identity and when. A path alone
- * is not an identity — Orca writes each new export over the same name — so the
- * watcher's sightings of this path are compared with this.
+ * What Studio last read from the open G-code: its size, hash and exact modification
+ * time. A path alone is not an identity — Orca writes each new export over the same
+ * name — so the watcher's sightings of this path are compared with this.
+ *
+ * One read at a time, and every answer is bound to the view it was asked for: a
+ * path change or a clear makes any answer in flight stale. The same read fills the
+ * job record's fingerprint, so there is a single source of the sliced identity.
*/
-let sliceView: { path: string; fp: Fingerprint | null; at: number; seq: number } | null = null;
-let viewSeq = 0;
-/** Clock slack between the file system's timestamps and ours. */
-const TIMESTAMP_SLACK_MS = 2000;
+interface SliceView {
+ path: string;
+ reading: boolean;
+ /** null until a read succeeds; sightings then trigger a read instead of being compared. */
+ base: Fingerprint | null;
+}
+let sliceView: SliceView | null = null;
function watchSliceView(path: string) {
- const seq = ++viewSeq;
- sliceView = { path, fp: null, at: Date.now(), seq };
- jobCheck([{ role: "sliced", path }]).then((report) => {
+ const view: SliceView = { path, reading: false, base: null };
+ sliceView = view;
+ void readSliced(view, undefined, false);
+}
+
+async function readSliced(view: SliceView, verdict: string | undefined, sighted: boolean) {
+ view.reading = true;
+ try {
+ const report = await jobCheck([{ role: "sliced", path: view.path }]);
+ if (sliceView !== view) return; // the path changed or was cleared meanwhile
const observed = report.files[0]?.observed;
- if (sliceView?.seq === seq && report.files[0]?.status === "recorded" && observed) sliceView.fp = observed;
- }).catch(() => { /* no identity yet: the next sighting cannot be compared, so nothing is claimed */ });
+ if (report.files[0]?.status !== "recorded" || !observed) return;
+ const before = view.base;
+ view.base = observed;
+ // Same bytes only when both hashes exist and agree; a size-only identity never counts.
+ const sameBytes = !!before && before.sha256 !== null && observed.sha256 !== null &&
+ before.size_bytes === observed.size_bytes && before.sha256 === observed.sha256;
+ const replaced = sighted && !sameBytes;
+ const record = useJob.getState().record;
+ if (record?.sliced && record.sliced.path === view.path) {
+ // A replacement that is another project's slice is not this job's result.
+ update(replaced && verdict === "no_match" ? clearSliced(record) : withFingerprint(record, "sliced", view.path, observed));
+ }
+ if (replaced) useSliced.getState().refresh();
+ } catch {
+ /* unreadable now: keep what is shown; the next completed sighting reads again */
+ } finally {
+ if (sliceView === view) view.reading = false;
+ }
}
export interface SightedExport {
path: string;
size_bytes: number;
- age_seconds: number;
+ /** The file's exact modification time as the watcher saw it (text; nanoseconds). */
+ modified_ns?: string;
complete: boolean;
/** Provenance verdict against the open project, when the watcher computed one. */
verdict?: string;
@@ -264,36 +292,24 @@ export interface SightedExport {
/**
* The folder watcher saw a file at the path that is open. Only a completed file
- * (the watcher's own settle check) that is a different size, or was written after
- * the open copy was read, is re-read; nothing is hashed on a routine poll. When
- * the bytes really differ, the saved identity is replaced and everything shown
- * from the old bytes is read again. A late answer never replaces a newer one.
+ * (the watcher's own settle check) whose size or exact modification time differs
+ * from what was last read is read again; an untouched file is never hashed, however
+ * often it is polled. When the bytes really differ, the saved identity is replaced
+ * and everything shown from the old bytes is read again. If the last read failed or
+ * never happened, the next completed sighting reads.
*/
-export async function observeSliced(sighting: SightedExport, now = Date.now()) {
+export function observeSliced(sighting: SightedExport) {
const view = sliceView;
- if (!view || view.path !== sighting.path || !sighting.complete || !view.fp) return;
- const wroteAt = now - sighting.age_seconds * 1000;
- if (sighting.size_bytes === view.fp.size_bytes && wroteAt <= view.at + TIMESTAMP_SLACK_MS) return;
- const seq = ++viewSeq;
- view.seq = seq;
- view.at = now;
- try {
- const report = await jobCheck([{ role: "sliced", path: sighting.path }]);
- const observed = report.files[0]?.observed;
- if (sliceView?.seq !== seq || useSliced.getState().path !== sighting.path) return;
- if (report.files[0]?.status !== "recorded" || !observed) return;
- const before = sliceView.fp;
- sliceView.fp = observed;
- if (before && observed.size_bytes === before.size_bytes && observed.sha256 === before.sha256) return;
- const record = useJob.getState().record;
- if (record?.sliced && record.sliced.path === sighting.path) {
- // A replacement that is another project's slice is not this job's result.
- update(sighting.verdict === "no_match"
- ? clearSliced(record)
- : { ...record, sliced: { ...record.sliced, fingerprint: observed } });
- }
- useSliced.getState().refresh();
- } catch {
- /* unreadable now: keep what is shown; the next sighting tries again */
- }
+ if (!view || view.path !== sighting.path || !sighting.complete || view.reading) return;
+ const base = view.base;
+ const untouched = !!base && base.size_bytes === sighting.size_bytes &&
+ (sighting.modified_ns === undefined || base.mtime_ns === sighting.modified_ns);
+ if (untouched) return;
+ void readSliced(view, sighting.verdict, true);
+}
+
+/** The open G-code was reopened under its own name by a restore: read it afresh. */
+export function rebaselineSliced(path: string) {
+ watchSliceView(path);
+ useSliced.getState().refresh();
}
From e485ffd9b722f637268538d29affa74c5811f47b Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 21:51:56 -0400
Subject: [PATCH 07/24] feat(engine): settings receipt - original, prepared and
sliced settings side by side
Compares the declared per-tool nozzle diameters (in order), layer height,
supports and the prime-tower on/off flag across the project, the prepared copy
and the sliced G-code. Each step is unchanged, changed, missing or not_checked;
a field a file does not state is missing (never off or equal); unreadable files
are not_checked. Whole-project scope is stated. POST /settings_receipt.
Fixtures are authored test artifacts, not slicer output.
Verified: backend/tests/test_settings_receipt.py 18 passed.
Co-Authored-By: Claude Sonnet 5.5
---
backend/snapstudio_api/server.py | 10 +
backend/snapstudio_api/service.py | 6 +
backend/snapstudio_core/settings_receipt.py | 275 ++++++++++++++++++++
backend/tests/test_settings_receipt.py | 227 ++++++++++++++++
4 files changed, 518 insertions(+)
create mode 100644 backend/snapstudio_core/settings_receipt.py
create mode 100644 backend/tests/test_settings_receipt.py
diff --git a/backend/snapstudio_api/server.py b/backend/snapstudio_api/server.py
index 2c5c326d..3c57eb90 100644
--- a/backend/snapstudio_api/server.py
+++ b/backend/snapstudio_api/server.py
@@ -353,6 +353,16 @@ def do_POST(self):
self._send(400, {"error": str(e)})
except Exception as exc:
self._send_exception(exc)
+ elif self.path == "/settings_receipt":
+ try:
+ self._send(200, service.settings_receipt(
+ rv.require_path_string(data, "original_path"),
+ rv.optional_str(data, "prepared_path", "") or None,
+ rv.optional_str(data, "sliced_path", "") or None))
+ except ValidationError as e:
+ self._send(400, {"error": str(e)})
+ except Exception as exc:
+ self._send_exception(exc)
elif self.path == "/slice_provenance":
try:
self._send(200, service.slice_provenance(
diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py
index 62113870..8d62d491 100644
--- a/backend/snapstudio_api/service.py
+++ b/backend/snapstudio_api/service.py
@@ -992,6 +992,12 @@ def job_check(files) -> dict:
return job_identity.check(files)
+def settings_receipt(original_path: str, prepared_path: str | None, sliced_path: str | None) -> dict:
+ """Original -> prepared -> sliced: what each file declares for a few settings. Read-only."""
+ from snapstudio_core import settings_receipt as sr
+ return sr.compare(original_path, prepared_path, sliced_path)
+
+
def slice_provenance(project_path: str, gcode_path: str) -> dict:
"""Is this G-code the slice of that project?"""
from snapstudio_core import gcode, project_traits, provenance
diff --git a/backend/snapstudio_core/settings_receipt.py b/backend/snapstudio_core/settings_receipt.py
new file mode 100644
index 00000000..d3fb738b
--- /dev/null
+++ b/backend/snapstudio_core/settings_receipt.py
@@ -0,0 +1,275 @@
+"""The settings receipt: what the project asked for, what Studio prepared, what the slice recorded.
+
+Three files carry the same few settings: the project that came in (original), the
+copy Studio prepared from it (prepared), and the G-code the slicer produced
+(sliced). This reads the declared value of each setting from each file and compares
+them step by step, so a slice that quietly used other nozzles, another layer height
+or no supports is visible without a printer connected.
+
+What it is, and is not:
+
+* **Settings only.** It does not say the G-code is the project's slice (that is
+ provenance), that the profile fits the printer, or that the print will work.
+* **Declared values.** A G-code config block is what the slicer recorded; the
+ project and prepared copy hold what they declare. Nothing is measured.
+* **Unknown stays unknown.** A field a file does not state is `missing`, never
+ "off", never "equal". A file that could not be read is `not_checked`.
+* **Per-tool order matters.** Nozzle diameters are compared tool by tool, never as
+ a set.
+* **Whole project.** Settings are compared as the project's own, not plate by plate;
+ the scope note says so rather than pretending otherwise.
+* **Prime tower: the declared on/off flag only.** Where a tower sits, how big it is,
+ and whether it fits are not inferred here.
+
+Read-only. It never prepares, slices, sends or writes.
+"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+SCHEMA_VERSION = "settings-receipt/1"
+
+STAGES = ("original", "prepared", "sliced")
+
+SETTINGS_PART = "Metadata/project_settings.config"
+MODEL_SETTINGS_PART = "Metadata/model_settings.config"
+
+_TOLERANCE = 1e-6
+
+SEPARATE_FROM = (
+ "This compares settings only. It does not say the G-code is your project (see "
+ "“Is this your project?”), that the slicer profile fits your printer, or that "
+ "the print will succeed.")
+
+#: The facts compared, in the order they are shown.
+FACTS = (
+ ("nozzles", "Nozzle diameter per tool"),
+ ("layer_height", "Layer height"),
+ ("supports", "Supports"),
+ ("prime_tower", "Prime tower (declared on/off only)"),
+)
+
+
+# --- reading declared values ---------------------------------------------------
+
+def _floats(raw) -> list[float] | None:
+ """A list of numbers from a declared value, or None if any entry is not a number."""
+ if raw is None:
+ return None
+ items = raw if isinstance(raw, list) else str(raw).replace(";", ",").split(",")
+ out = []
+ for item in items:
+ try:
+ out.append(float(str(item).strip()))
+ except ValueError:
+ return None
+ return out or None
+
+
+def _one_float(raw) -> float | None:
+ if isinstance(raw, list):
+ if len(raw) != 1:
+ return None
+ raw = raw[0]
+ values = _floats(raw)
+ return values[0] if values and len(values) == 1 else None
+
+
+def _flag(raw) -> bool | None:
+ if isinstance(raw, list):
+ if len(raw) != 1:
+ return None
+ raw = raw[0]
+ text = str(raw).strip().lower() if raw is not None else ""
+ if text in ("1", "true", "on", "yes"):
+ return True
+ if text in ("0", "false", "off", "no"):
+ return False
+ return None
+
+
+def _declared(config: dict) -> dict:
+ return {
+ "nozzles": _floats(config.get("nozzle_diameter")),
+ "layer_height": _one_float(config.get("layer_height")),
+ "supports": _flag(config.get("enable_support")),
+ "prime_tower": _flag(config.get("enable_prime_tower")),
+ }
+
+
+def _read_project(path: str) -> dict:
+ """The declared settings of a project file, or why they could not be read."""
+ low = path.lower()
+ if low.endswith(".stl"):
+ if not Path(path).is_file():
+ return {"state": "unreadable", "reason": "that file does not exist"}
+ return {"state": "read", "values": _declared({}), "note": "An STL carries no print settings."}
+ try:
+ from .container import ThreeMF
+ tm = ThreeMF.open(path)
+ parts = set(tm.list_parts())
+ config: dict = {}
+ if SETTINGS_PART in parts:
+ loaded = json.loads(tm.read_part(SETTINGS_PART).decode("utf-8", "ignore"))
+ config = loaded if isinstance(loaded, dict) else {}
+ plates = 0
+ if MODEL_SETTINGS_PART in parts:
+ plates = len(re.findall(r" dict:
+ from . import gcode
+ facts = gcode.read_facts(path)
+ if not facts.get("available"):
+ return {"state": "unreadable", "reason": facts.get("error") or "not a readable G-code file"}
+ config = facts.get("config") or {}
+ values = _declared(config)
+ # The reader already parsed these two; prefer its numbers over re-parsing text.
+ values["nozzles"] = facts.get("nozzle_diameter_mm") or values["nozzles"]
+ if facts.get("layer_height_mm") is not None:
+ values["layer_height"] = facts["layer_height_mm"]
+ return {"state": "read", "values": values}
+
+
+# --- comparing -----------------------------------------------------------------
+
+def _mm(v: float) -> str:
+ return f"{v:g} mm"
+
+
+def _show(fact_id: str, value) -> str:
+ if value is None:
+ return "not stated"
+ if fact_id == "nozzles":
+ return ", ".join(_mm(v) for v in value)
+ if fact_id == "layer_height":
+ return _mm(value)
+ return "on" if value else "off"
+
+
+def _equal(fact_id: str, a, b) -> bool:
+ if fact_id == "nozzles":
+ return len(a) == len(b) and all(abs(x - y) <= _TOLERANCE for x, y in zip(a, b))
+ if fact_id == "layer_height":
+ return abs(a - b) <= _TOLERANCE
+ return a == b
+
+
+def _difference(fact_id: str, a, b) -> str:
+ if fact_id == "nozzles":
+ if len(a) != len(b):
+ return f"{len(a)} tools became {len(b)} tools ({_show(fact_id, a)} → {_show(fact_id, b)})"
+ return "; ".join(f"Tool {i + 1}: {_mm(x)} → {_mm(y)}"
+ for i, (x, y) in enumerate(zip(a, b)) if abs(x - y) > _TOLERANCE)
+ return f"{_show(fact_id, a)} → {_show(fact_id, b)}"
+
+
+_LABEL = {"original": "the project", "prepared": "the prepared copy", "sliced": "the slice"}
+
+_WHAT = {
+ "nozzles": "nozzle diameter",
+ "layer_height": "layer height",
+ "supports": "supports setting",
+ "prime_tower": "prime tower setting",
+}
+
+
+def _advice(fact_id: str, a, b, frm: str, to: str) -> tuple[str, str]:
+ """(consequence, next action) for a real change between two stages."""
+ slicer = to == "sliced"
+ if fact_id == "nozzles":
+ why = ("Extrusion widths and flow are worked out from the nozzle, so the result no longer matches what the "
+ "project was set up for.")
+ act = ("Check the printer profile in Snapmaker Orca lists the nozzles your U1 actually has, then slice the "
+ "prepared copy again and open the new G-code here." if slicer else
+ "Open the prepared copy in Snapmaker Orca and confirm the nozzles are the ones you intend.")
+ elif fact_id == "layer_height":
+ why = "Layer count, print time and surface detail all follow from layer height."
+ act = ("In Snapmaker Orca, set the layer height in the process profile to the value you want, slice again and "
+ "open the new G-code here." if slicer else
+ "Open the prepared copy in Snapmaker Orca and confirm the layer height before slicing.")
+ elif fact_id == "supports":
+ turned_off = bool(a) and not b
+ why = ("Overhangs the project expected to be supported are not supported in the slice." if turned_off else
+ "The slice adds support material the project did not ask for, which changes material use and time.")
+ act = (f"In Snapmaker Orca, turn supports {'on' if turned_off else 'off'} in the process profile, slice again "
+ "and open the new G-code here." if slicer else
+ "Open the prepared copy in Snapmaker Orca and confirm the supports setting before slicing.")
+ else:
+ why = "Whether a prime tower is built changes how colour changes are handled and how much material is used."
+ act = ("In Snapmaker Orca, check the prime tower setting in the process profile, slice again and open the new "
+ "G-code here." if slicer else
+ "Open the prepared copy in Snapmaker Orca and confirm the prime tower setting before slicing.")
+ return why, act
+
+
+def _step(fact_id: str, frm: str, to: str, stages: dict) -> dict:
+ sa, sb = stages[frm], stages[to]
+ out = {"from": frm, "to": to, "from_value": None, "to_value": None, "status": "not_checked",
+ "detail": "", "next_action": None}
+ for name, stage in ((frm, sa), (to, sb)):
+ if stage["state"] != "read":
+ out["detail"] = f"Studio could not read {_LABEL[name]}, so this step was not checked."
+ out["next_action"] = f"Make sure {_LABEL[name]} is still where it was and opens, then check again."
+ return out
+ a, b = sa["values"][fact_id], sb["values"][fact_id]
+ out["from_value"], out["to_value"] = a, b
+ if a is None or b is None:
+ gap = frm if a is None else to
+ verb = "does not state" if gap != "sliced" else "does not record"
+ out["status"] = "missing"
+ out["detail"] = (f"{_LABEL[gap].capitalize()} {verb} a {_WHAT[fact_id]}, so Studio cannot say whether it "
+ f"matches ({_show(fact_id, a)} vs {_show(fact_id, b)}).")
+ out["next_action"] = ("Check this setting in Snapmaker Orca directly; Studio will not assume it is the same."
+ if gap == "sliced" else
+ "Set it explicitly in the project so there is a value to compare.")
+ return out
+ if _equal(fact_id, a, b):
+ out["status"] = "unchanged"
+ out["detail"] = f"Same in {_LABEL[frm]} and {_LABEL[to]}: {_show(fact_id, b)}."
+ return out
+ out["status"] = "changed"
+ why, act = _advice(fact_id, a, b, frm, to)
+ who = "Studio’s prepared copy differs from the project" if to == "prepared" else \
+ f"{_LABEL[to].capitalize()} differs from {_LABEL[frm]}"
+ out["detail"] = f"{who}: {_difference(fact_id, a, b)}. {why}"
+ out["next_action"] = act
+ return out
+
+
+_RANK = {"changed": 3, "missing": 2, "not_checked": 1, "unchanged": 0}
+
+
+def compare(original_path: str, prepared_path: str | None, sliced_path: str | None) -> dict:
+ """Compare the declared settings of the original, the prepared copy and the sliced G-code."""
+ stages: dict[str, dict] = {}
+ stages["original"] = _read_project(original_path)
+ stages["prepared"] = _read_project(prepared_path) if prepared_path else {"state": "not_provided"}
+ stages["sliced"] = _read_gcode(sliced_path) if sliced_path else {"state": "not_provided"}
+
+ present = [s for s in STAGES if stages[s]["state"] != "not_provided"]
+ pairs = list(zip(present, present[1:]))
+
+ facts = []
+ for fact_id, label in FACTS:
+ steps = [_step(fact_id, a, b, stages) for a, b in pairs]
+ if not steps:
+ status = "not_checked"
+ else:
+ status = max((s["status"] for s in steps), key=lambda v: _RANK[v])
+ facts.append({"id": fact_id, "label": label, "status": status, "steps": steps})
+
+ plates = stages["original"].get("plates", 0)
+ scope_note = ("Compared as whole-project settings; per-plate and per-object overrides are not compared. "
+ + (f"This project has {plates} plates and the G-code is one plate’s slice; Studio does not "
+ "know which." if plates > 1 else "A G-code is one plate’s slice."))
+ counts = {k: sum(1 for f in facts if f["status"] == k) for k in _RANK}
+ summary = (f"{counts['changed']} changed, {counts['missing']} missing, {counts['not_checked']} not checked, "
+ f"{counts['unchanged']} unchanged.")
+ return {"schema_version": SCHEMA_VERSION, "scope": "project", "scope_note": scope_note,
+ "stages": stages, "facts": facts, "summary": summary, "separate_from": SEPARATE_FROM}
diff --git a/backend/tests/test_settings_receipt.py b/backend/tests/test_settings_receipt.py
new file mode 100644
index 00000000..de52018f
--- /dev/null
+++ b/backend/tests/test_settings_receipt.py
@@ -0,0 +1,227 @@
+"""The settings receipt: what the project asked for, what Studio prepared, what the slice recorded.
+
+Every fixture here is an AUTHORED TEST ARTIFACT: a minimal project archive and a
+hand-written G-code config block. None of it was produced by a slicer, and nothing
+here was sliced. They exist to put known values on each side of a comparison.
+"""
+from __future__ import annotations
+
+import zipfile
+
+import pytest
+
+from snapstudio_core import settings_receipt as sr
+
+CONTENT_TYPES = (''
+ '')
+MODEL = ''
+
+
+def project(tmp_path, name="p.3mf", **settings):
+ """An authored project archive carrying only the settings given."""
+ import json
+ path = tmp_path / name
+ with zipfile.ZipFile(path, "w") as z:
+ z.writestr("[Content_Types].xml", CONTENT_TYPES)
+ z.writestr("3D/3dmodel.model", MODEL)
+ z.writestr("Metadata/project_settings.config", json.dumps(settings))
+ return str(path)
+
+
+def gcode(tmp_path, name="s.gcode", **config):
+ """An authored G-code file: a header, one move, and a config block of the given values."""
+ lines = "".join(f"; {k} = {v}\n" for k, v in config.items())
+ path = tmp_path / name
+ path.write_text(
+ "; HEADER_BLOCK_START\n; generated by Snapmaker Orca 2.3.5 on 2026-08-23 at 10:00:00\n"
+ "; total layer number: 6\n; HEADER_BLOCK_END\nPRINT_START\nG1 X10 Y10 E1\nPRINT_END\n\n"
+ "; total filament used [g] = 4.00\n; estimated printing time (normal mode) = 41m 12s\n"
+ f"; CONFIG_BLOCK_START\n{lines}; CONFIG_BLOCK_END\n", encoding="utf-8")
+ return str(path)
+
+
+def fact(receipt, fact_id):
+ return next(f for f in receipt["facts"] if f["id"] == fact_id)
+
+
+def step(row, a, b):
+ return next(s for s in row["steps"] if (s["from"], s["to"]) == (a, b))
+
+
+ASKED = dict(nozzle_diameter=["0.4", "0.4", "0.4", "0.4"], layer_height="0.20", enable_support="1", enable_prime_tower="1")
+
+
+def test_the_r2_mismatch_is_surfaced_exactly_without_a_printer(tmp_path):
+ original = project(tmp_path, **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.2,0.2,0.2,0.2", layer_height="0.08", enable_support="0", enable_prime_tower="1")
+ r = sr.compare(original, None, sliced)
+ assert fact(r, "nozzles")["status"] == "changed"
+ assert fact(r, "layer_height")["status"] == "changed"
+ assert fact(r, "supports")["status"] == "changed"
+ assert fact(r, "prime_tower")["status"] == "unchanged"
+ s = step(fact(r, "layer_height"), "original", "sliced")
+ assert (s["status"], s["from_value"], s["to_value"]) == ("changed", 0.2, 0.08)
+ assert "0.2" in s["detail"] and "0.08" in s["detail"]
+ assert s["next_action"]
+
+
+def test_matching_values_are_unchanged_and_say_no_action_is_needed(tmp_path):
+ original = project(tmp_path, **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.2", enable_support="1", enable_prime_tower="1")
+ r = sr.compare(original, None, sliced)
+ assert [f["status"] for f in r["facts"]] == ["unchanged"] * 4
+ assert all(s["next_action"] is None for f in r["facts"] for s in f["steps"])
+
+
+def test_nozzle_order_is_per_tool_and_a_swap_is_a_change(tmp_path):
+ original = project(tmp_path, nozzle_diameter=["0.4", "0.6", "0.4", "0.4"])
+ sliced = gcode(tmp_path, nozzle_diameter="0.6,0.4,0.4,0.4")
+ row = fact(sr.compare(original, None, sliced), "nozzles")
+ assert row["status"] == "changed"
+ detail = step(row, "original", "sliced")["detail"]
+ assert "Tool 1" in detail and "Tool 2" in detail and "Tool 3" not in detail
+
+
+def test_a_different_number_of_tools_is_a_change(tmp_path):
+ original = project(tmp_path, nozzle_diameter=["0.4"] * 4)
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4")
+ s = step(fact(sr.compare(original, None, gcode_path := sliced), "nozzles"), "original", "sliced")
+ assert s["status"] == "changed" and "4" in s["detail"] and "2" in s["detail"]
+
+
+def test_a_field_the_slice_does_not_record_is_missing_never_off_or_equal(tmp_path):
+ original = project(tmp_path, **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.2") # no enable_support, no tower
+ r = sr.compare(original, None, sliced)
+ assert fact(r, "supports")["status"] == "missing"
+ assert fact(r, "prime_tower")["status"] == "missing"
+ s = step(fact(r, "supports"), "original", "sliced")
+ assert s["to_value"] is None and "not record" in s["detail"].lower()
+ assert s["next_action"]
+
+
+def test_a_field_the_project_does_not_declare_is_missing_not_off(tmp_path):
+ original = project(tmp_path, nozzle_diameter=["0.4"], layer_height="0.2")
+ sliced = gcode(tmp_path, nozzle_diameter="0.4", layer_height="0.2", enable_support="0")
+ assert fact(sr.compare(original, None, sliced), "supports")["status"] == "missing"
+
+
+def test_an_unparseable_value_is_missing_not_a_guess(tmp_path):
+ original = project(tmp_path, nozzle_diameter=["0.4", "wide"], layer_height="thin", enable_support="maybe")
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4", layer_height="0.2", enable_support="1")
+ r = sr.compare(original, None, sliced)
+ assert [f["status"] for f in r["facts"][:3]] == ["missing", "missing", "missing"]
+
+
+def test_a_prepared_copy_adds_a_step_and_each_step_is_judged_on_its_own(tmp_path):
+ original = project(tmp_path, "o.3mf", **ASKED)
+ prepared = project(tmp_path, "p.3mf", **{**ASKED, "layer_height": "0.16"})
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.16", enable_support="1", enable_prime_tower="1")
+ row = fact(sr.compare(original, prepared, sliced), "layer_height")
+ assert step(row, "original", "prepared")["status"] == "changed"
+ assert step(row, "prepared", "sliced")["status"] == "unchanged"
+ assert row["status"] == "changed"
+ assert "prepared copy" in step(row, "original", "prepared")["detail"].lower()
+
+
+def test_an_unreadable_prepared_copy_is_not_checked_not_skipped_or_assumed_equal(tmp_path):
+ original = project(tmp_path, **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.2", enable_support="1", enable_prime_tower="1")
+ r = sr.compare(original, str(tmp_path / "gone.3mf"), sliced)
+ row = fact(r, "layer_height")
+ assert step(row, "original", "prepared")["status"] == "not_checked"
+ assert step(row, "prepared", "sliced")["status"] == "not_checked"
+ assert row["status"] == "not_checked"
+ assert r["stages"]["prepared"]["state"] == "unreadable"
+
+
+def test_without_a_slice_nothing_can_be_compared_end_to_end(tmp_path):
+ r = sr.compare(project(tmp_path, **ASKED), None, None)
+ assert [f["status"] for f in r["facts"]] == ["not_checked"] * 4
+ assert r["stages"]["sliced"]["state"] == "not_provided"
+
+
+def test_an_unreadable_slice_is_not_checked(tmp_path):
+ r = sr.compare(project(tmp_path, **ASKED), None, str(tmp_path / "nope.gcode"))
+ assert all(f["status"] == "not_checked" for f in r["facts"])
+ assert r["stages"]["sliced"]["state"] == "unreadable"
+
+
+def test_the_scope_is_the_whole_project_and_says_so(tmp_path):
+ r = sr.compare(project(tmp_path, **ASKED), None, gcode(tmp_path, nozzle_diameter="0.4"))
+ assert r["scope"] == "project"
+ assert "plate" in r["scope_note"].lower()
+
+
+def test_the_prime_tower_is_a_declared_flag_only(tmp_path):
+ r = sr.compare(project(tmp_path, **ASKED), None, gcode(tmp_path, nozzle_diameter="0.4", enable_prime_tower="0"))
+ row = fact(r, "prime_tower")
+ assert row["status"] == "changed"
+ text = " ".join(s["detail"] + (s["next_action"] or "") for s in row["steps"]).lower()
+ assert "arrange" not in text and "footprint" not in text and "inside the bed" not in text
+
+
+def test_the_receipt_does_not_speak_for_identity_profile_or_success(tmp_path):
+ r = sr.compare(project(tmp_path, **ASKED), None, gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4"))
+ assert "settings only" in r["separate_from"].lower()
+ for forbidden in ("will print", "print success", "guarantee"):
+ assert forbidden not in str(r).lower()
+
+
+def test_inputs_are_never_modified(tmp_path):
+ import hashlib
+ original = project(tmp_path, **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.4")
+ digest = lambda p: hashlib.sha256(open(p, "rb").read()).hexdigest()
+ before = (digest(original), digest(sliced))
+ sr.compare(original, None, sliced)
+ assert (digest(original), digest(sliced)) == before
+
+
+def test_a_multi_plate_project_says_which_part_it_cannot_resolve(tmp_path):
+ import json
+ path = tmp_path / "multi.3mf"
+ with zipfile.ZipFile(path, "w") as z:
+ z.writestr("[Content_Types].xml", CONTENT_TYPES)
+ z.writestr("3D/3dmodel.model", MODEL)
+ z.writestr("Metadata/project_settings.config", json.dumps(ASKED))
+ z.writestr("Metadata/model_settings.config", "")
+ r = sr.compare(str(path), None, gcode(tmp_path, nozzle_diameter="0.4"))
+ assert "2 plates" in r["scope_note"]
+
+
+def test_a_stl_has_no_settings_so_everything_is_missing_not_unchanged(tmp_path):
+ stl = tmp_path / "m.stl"
+ stl.write_text("solid x\nendsolid x\n")
+ r = sr.compare(str(stl), None, gcode(tmp_path, nozzle_diameter="0.4", layer_height="0.2"))
+ assert fact(r, "layer_height")["status"] == "missing"
+
+
+def test_the_http_route_returns_the_receipt_and_refuses_a_request_without_an_original(tmp_path):
+ import json
+ import threading
+ import urllib.error
+ import urllib.request
+
+ from snapstudio_api import server
+
+ original = project(tmp_path, **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.2,0.2,0.2,0.2", layer_height="0.08", enable_support="0")
+ httpd, token = server.build_server("127.0.0.1", 0)
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ try:
+ url = f"http://127.0.0.1:{httpd.server_address[1]}/settings_receipt"
+
+ def post(body):
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST",
+ headers={"Content-Type": "application/json", "X-Auth-Token": token})
+ return urllib.request.urlopen(req, timeout=5)
+
+ got = json.loads(post({"original_path": original, "sliced_path": sliced}).read())
+ assert fact(got, "layer_height")["status"] == "changed"
+ with pytest.raises(urllib.error.HTTPError) as bad:
+ post({"sliced_path": sliced})
+ assert bad.value.code == 400
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
From 06a3132fc7dd7763470feb096e6d89ea23db2a50 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 21:54:22 -0400
Subject: [PATCH 08/24] feat(desktop): settings receipt card on the After
slicing page
Shows the project, prepared copy and sliced G-code settings side by side with
plain differences and a next action; unknown and unchecked are never drawn as a
match; an answer about earlier files is never shown beside later ones. The
prepared copy is the one Studio wrote and validated in this session.
Verified: desktop vitest all pass incl. 7 new; tsc clean.
Co-Authored-By: Claude Sonnet 5.5
---
desktop/src/api.ts | 35 ++++++
.../components/SettingsReceiptCard.test.tsx | 106 ++++++++++++++++++
.../src/components/SettingsReceiptCard.tsx | 93 +++++++++++++++
desktop/src/lib/settingsReceipt.ts | 40 +++++++
desktop/src/routes/AfterSlicing.tsx | 7 ++
5 files changed, 281 insertions(+)
create mode 100644 desktop/src/components/SettingsReceiptCard.test.tsx
create mode 100644 desktop/src/components/SettingsReceiptCard.tsx
create mode 100644 desktop/src/lib/settingsReceipt.ts
diff --git a/desktop/src/api.ts b/desktop/src/api.ts
index 4b7ab332..578e139e 100644
--- a/desktop/src/api.ts
+++ b/desktop/src/api.ts
@@ -1520,6 +1520,41 @@ export function jobCheck(files: JobCheckEntry[]): Promise<{ schema_version: stri
return post("/job_check", { files }, "job check");
}
+export type ReceiptStatus = "unchanged" | "changed" | "missing" | "not_checked";
+export type ReceiptStage = "original" | "prepared" | "sliced";
+
+export interface ReceiptStep {
+ from: ReceiptStage;
+ to: ReceiptStage;
+ from_value: number | boolean | number[] | null;
+ to_value: number | boolean | number[] | null;
+ status: ReceiptStatus;
+ detail: string;
+ next_action: string | null;
+}
+
+export interface ReceiptFact {
+ id: "nozzles" | "layer_height" | "supports" | "prime_tower";
+ label: string;
+ status: ReceiptStatus;
+ steps: ReceiptStep[];
+}
+
+export interface SettingsReceipt {
+ schema_version: string;
+ scope: "project";
+ scope_note: string;
+ stages: Record;
+ facts: ReceiptFact[];
+ summary: string;
+ separate_from: string;
+}
+
+/** Read-only: the declared settings of the project, its prepared copy and the sliced G-code, side by side. */
+export function settingsReceipt(originalPath: string, preparedPath?: string | null, slicedPath?: string | null): Promise {
+ return post("/settings_receipt", { original_path: originalPath, prepared_path: preparedPath ?? "", sliced_path: slicedPath ?? "" }, "settings receipt");
+}
+
export function sliceProvenance(projectPath: string, gcodePath: string): Promise {
return post("/slice_provenance", { project_path: projectPath, gcode_path: gcodePath },
"provenance");
diff --git a/desktop/src/components/SettingsReceiptCard.test.tsx b/desktop/src/components/SettingsReceiptCard.test.tsx
new file mode 100644
index 00000000..3d5a6a30
--- /dev/null
+++ b/desktop/src/components/SettingsReceiptCard.test.tsx
@@ -0,0 +1,106 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/api", () => ({ settingsReceipt: vi.fn() }));
+
+import { settingsReceipt } from "@/api";
+import type { ReceiptFact, ReceiptStep, SettingsReceipt } from "@/api";
+import { SettingsReceiptCard } from "@/components/SettingsReceiptCard";
+
+// Authored test data shaped like the engine's answer. Not slicer output.
+const step = (over: Partial): ReceiptStep =>
+ ({ from: "original", to: "sliced", from_value: null, to_value: null, status: "unchanged", detail: "", next_action: null, ...over });
+const fact = (id: ReceiptFact["id"], label: string, steps: ReceiptStep[]): ReceiptFact => {
+ const rank = { changed: 3, missing: 2, not_checked: 1, unchanged: 0 } as const;
+ const status = steps.length ? steps.map((s) => s.status).sort((a, b) => rank[b] - rank[a])[0] : "not_checked";
+ return { id, label, status, steps };
+};
+const receipt = (facts: ReceiptFact[], summary = "1 changed."): SettingsReceipt => ({
+ schema_version: "settings-receipt/1", scope: "project", scope_note: "Compared as whole-project settings.",
+ stages: { original: { state: "read" }, prepared: { state: "not_provided" }, sliced: { state: "read" } },
+ facts, summary, separate_from: "This compares settings only.",
+});
+
+const MISMATCH = receipt([
+ fact("nozzles", "Nozzle diameter per tool", [step({ status: "changed", detail: "Tool 1: 0.4 mm → 0.2 mm", next_action: "Check the printer profile in Snapmaker Orca." })]),
+ fact("layer_height", "Layer height", [step({ status: "changed", detail: "0.2 mm → 0.08 mm", next_action: "Set the layer height, slice again." })]),
+ fact("supports", "Supports", [step({ status: "missing", detail: "The slice does not record a supports setting.", next_action: "Check this setting in Snapmaker Orca directly." })]),
+ fact("prime_tower", "Prime tower (declared on/off only)", [step({ status: "unchanged", detail: "Same: on." })]),
+], "2 changed, 1 missing, 0 not checked, 1 unchanged.");
+
+beforeEach(() => vi.clearAllMocks());
+afterEach(cleanup);
+
+describe("the settings receipt card", () => {
+ it("shows each difference with its explanation and next action", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ render();
+ await act(async () => {});
+ expect(screen.getByText("Nozzle diameter per tool")).toBeTruthy();
+ expect(screen.getByText("Tool 1: 0.4 mm → 0.2 mm")).toBeTruthy();
+ expect(screen.getByText(/Check the printer profile/)).toBeTruthy();
+ expect(screen.getAllByText("Different").length).toBe(2);
+ expect(screen.getAllByText("Not stated").length).toBe(1);
+ expect(screen.getByText("Same")).toBeTruthy();
+ });
+
+ it("a missing or unchecked setting is never worded as a match", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(receipt([
+ fact("supports", "Supports", [step({ status: "missing", detail: "Not recorded." })]),
+ fact("layer_height", "Layer height", [step({ status: "not_checked", detail: "Could not read the prepared copy." })]),
+ ]));
+ render();
+ await act(async () => {});
+ expect(screen.queryByText("Same")).toBeNull();
+ expect(screen.getByText("Not stated")).toBeTruthy();
+ expect(screen.getByText("Not checked")).toBeTruthy();
+ });
+
+ it("states that it is about settings only, and its scope", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ render();
+ await act(async () => {});
+ expect(screen.getByText("This compares settings only.")).toBeTruthy();
+ expect(screen.getByText("Compared as whole-project settings.")).toBeTruthy();
+ });
+
+ it("an answer about earlier files is never shown beside later ones", async () => {
+ const pending: Record void> = {};
+ vi.mocked(settingsReceipt).mockImplementation((o, _p, s) => new Promise((resolve) => { pending[`${o}|${s}`] = resolve; }));
+ const view = render();
+ await act(async () => {});
+ view.rerender();
+ await act(async () => {});
+ await act(async () => pending["C:\\a.3mf|C:\\a.gcode"](MISMATCH));
+ expect(screen.queryByText("Layer height")).toBeNull();
+ expect(screen.getByText("Comparing…")).toBeTruthy();
+ await act(async () => pending["C:\\a.3mf|C:\\b.gcode"](receipt([fact("layer_height", "Layer height", [step({ status: "unchanged", detail: "Same: 0.2 mm." })])], "0 changed.")));
+ expect(screen.getByText("Same: 0.2 mm.")).toBeTruthy();
+ });
+
+ it("a revision bump re-reads the same paths", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ const view = render();
+ await act(async () => {});
+ view.rerender();
+ await act(async () => {});
+ expect(settingsReceipt).toHaveBeenCalledTimes(2);
+ });
+
+ it("a failed comparison says so and shows no stale result", async () => {
+ vi.mocked(settingsReceipt).mockRejectedValue(new Error("down"));
+ render();
+ await act(async () => {});
+ expect(screen.getByText(/could not compare/)).toBeTruthy();
+ expect(screen.queryByText("Layer height")).toBeNull();
+ });
+
+ it("without a sliced file the steps say to open one", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(receipt([fact("layer_height", "Layer height", [])], "0 changed, 0 missing, 1 not checked, 0 unchanged."));
+ render();
+ await act(async () => {});
+ expect(screen.getByText("Open a sliced G-code to compare.")).toBeTruthy();
+ });
+});
diff --git a/desktop/src/components/SettingsReceiptCard.tsx b/desktop/src/components/SettingsReceiptCard.tsx
new file mode 100644
index 00000000..89d74a19
--- /dev/null
+++ b/desktop/src/components/SettingsReceiptCard.tsx
@@ -0,0 +1,93 @@
+import { useEffect, useRef, useState } from "react";
+import { CheckCircle2, HelpCircle, TriangleAlert } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import { settingsReceipt } from "@/api";
+import type { ReceiptFact, SettingsReceipt } from "@/api";
+import { receiptKey, STATUS_LABEL, statusTone, stepTitle } from "@/lib/settingsReceipt";
+
+/**
+ * Settings receipt: what the project asked for, what Studio prepared, what the
+ * slicer recorded.
+ *
+ * Compares a few declared settings across the three files, per tool and in order,
+ * and says what to do about each difference. It is about settings only: whether
+ * the G-code belongs to the project, whether the profile fits the printer and
+ * whether the print will work are separate questions answered elsewhere.
+ */
+export function SettingsReceiptCard({ original, prepared, sliced, revision = 0 }: {
+ original: string; prepared: string | null; sliced: string | null; revision?: number;
+}) {
+ const key = receiptKey(original, prepared, sliced, revision);
+ const [answer, setAnswer] = useState<{ key: string; receipt: SettingsReceipt } | null>(null);
+ const [failed, setFailed] = useState(null);
+ const current = useRef(key);
+ current.current = key;
+
+ useEffect(() => {
+ let alive = true;
+ setFailed(null);
+ settingsReceipt(original, prepared, sliced).then(
+ (receipt) => { if (alive && current.current === key) setAnswer({ key, receipt }); },
+ () => { if (alive && current.current === key) setFailed(key); },
+ );
+ return () => { alive = false; };
+ }, [key, original, prepared, sliced]);
+
+ // Only an answer about the files that are open now is shown.
+ const receipt = answer && answer.key === key ? answer.receipt : null;
+
+ return (
+
+
+
+
Settings receipt
+
+ What the project asked for, what Studio prepared, and what the slicer recorded.
+
+
+
+ {!receipt && failed !== key &&
Comparing…
}
+ {failed === key && (
+
Studio could not compare these files. Check that they are still where they were.
+ );
+}
diff --git a/desktop/src/lib/settingsReceipt.ts b/desktop/src/lib/settingsReceipt.ts
new file mode 100644
index 00000000..f29d4cf4
--- /dev/null
+++ b/desktop/src/lib/settingsReceipt.ts
@@ -0,0 +1,40 @@
+import type { ReceiptStatus, ReceiptStage } from "@/api";
+
+/**
+ * Plain wording for the settings receipt. The engine decides each status; this only
+ * decides how it is said. Nothing here claims a print will work: the receipt is
+ * about settings, and says so.
+ */
+export const STATUS_LABEL: Record = {
+ unchanged: "Same",
+ changed: "Different",
+ missing: "Not stated",
+ not_checked: "Not checked",
+};
+
+export const STAGE_LABEL: Record = {
+ original: "Project",
+ prepared: "Prepared copy",
+ sliced: "Sliced G-code",
+};
+
+/** What the files are called when a step is read aloud. */
+export function stepTitle(from: ReceiptStage, to: ReceiptStage): string {
+ return `${STAGE_LABEL[from]} → ${STAGE_LABEL[to]}`;
+}
+
+/** Visual weight only; "missing" and "not checked" are never drawn as a pass. */
+export function statusTone(status: ReceiptStatus): "ok" | "attention" | "unknown" {
+ if (status === "unchanged") return "ok";
+ if (status === "changed") return "attention";
+ return "unknown";
+}
+
+/**
+ * The identity a receipt answers for. A response is shown only while this is still
+ * the key of what is open, so an answer about earlier files can never sit beside
+ * later ones.
+ */
+export function receiptKey(original: string, prepared: string | null, sliced: string | null, revision = 0): string {
+ return JSON.stringify([original, prepared, sliced, revision]);
+}
diff --git a/desktop/src/routes/AfterSlicing.tsx b/desktop/src/routes/AfterSlicing.tsx
index edff3085..2d7c09be 100644
--- a/desktop/src/routes/AfterSlicing.tsx
+++ b/desktop/src/routes/AfterSlicing.tsx
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { SlicedJobCards } from "@/components/SlicedJobCards";
import { OrcaRoundTrip } from "@/components/OrcaRoundTrip";
import { JobWorkspaceCard } from "@/components/JobWorkspaceCard";
+import { SettingsReceiptCard } from "@/components/SettingsReceiptCard";
import { useSliced } from "@/store/sliced";
import { useSession } from "@/store/session";
import { POST_SLICE_EXPLAINER } from "@/lib/postSlice";
@@ -24,7 +25,12 @@ export default function AfterSlicing() {
const name = useSliced((s) => s.name);
const setSliced = useSliced((s) => s.setSliced);
const clear = useSliced((s) => s.clear);
+ const revision = useSliced((s) => s.revision);
const project = useSession((s) => s.file);
+ const convert = useSession((s) => s.convert);
+ // A prepared copy counts only if Studio wrote and validated it in this session.
+ const prepared = convert.status === "done" && convert.data && !convert.data.blocked && convert.data.validated_ok
+ ? convert.data.output_path : null;
const [typed, setTyped] = useState("");
return (
@@ -95,6 +101,7 @@ export default function AfterSlicing() {
Choose another file
+ {project && }
>
)}
From 0d84e390afdbcde9ba8034d9ccf1019050b773fc Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 21:55:34 -0400
Subject: [PATCH 09/24] test(cli): pin the external u1convert contract through
the real entry point
doctor --json prints one JSON object and exits 0 only for READY (a valid
CONVERTIBLE answer exits 1); repair writes a new file and never touches the
input. Runs against a packaged executable too when SNAPSTUDIO_CLI_EXE is set;
until that is done and recorded, packaging stays unverified.
Verified: backend/tests/test_external_cli_contract.py 5 passed (source CLI).
Co-Authored-By: Claude Sonnet 5.5
---
backend/tests/test_external_cli_contract.py | 190 ++++++++++++++++++++
1 file changed, 190 insertions(+)
create mode 100644 backend/tests/test_external_cli_contract.py
diff --git a/backend/tests/test_external_cli_contract.py b/backend/tests/test_external_cli_contract.py
new file mode 100644
index 00000000..fd13051a
--- /dev/null
+++ b/backend/tests/test_external_cli_contract.py
@@ -0,0 +1,190 @@
+"""The command-line contract an external program relies on, exercised through the real entry point.
+
+Other programs call `u1convert` to check a model and to write a prepared copy. This
+pins what they depend on, using the actual CLI in a subprocess (the same entry point
+the installed command runs) - not an in-process import:
+
+* `doctor --json` prints one JSON object on stdout and exits 0 only when the
+ verdict is READY. A valid CONVERTIBLE answer exits 1. The exit code is a verdict
+ signal, not a transport failure: a caller must read the JSON, not the exit code.
+* `repair --mode u1 -o ` writes a NEW file and never touches the input.
+* The JSON fields a caller reads: verdict, score, validation_issues,
+ compatibility_issues, recommended_action, family, is_u1.
+
+Scope: by default this is the source CLI, run with this interpreter, which does NOT
+validate a packaged/installed executable. Point SNAPSTUDIO_CLI_EXE at one to run the
+identical checks against it; until that has been done and recorded, packaging is
+unverified.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import subprocess
+import sys
+
+import pytest
+
+CUBE = """solid cube
+facet normal 0 0 -1
+ outer loop
+ vertex 0 0 0
+ vertex 10 10 0
+ vertex 10 0 0
+ endloop
+endfacet
+facet normal 0 0 -1
+ outer loop
+ vertex 0 0 0
+ vertex 0 10 0
+ vertex 10 10 0
+ endloop
+endfacet
+facet normal 0 0 1
+ outer loop
+ vertex 0 0 10
+ vertex 10 0 10
+ vertex 10 10 10
+ endloop
+endfacet
+facet normal 0 0 1
+ outer loop
+ vertex 0 0 10
+ vertex 10 10 10
+ vertex 0 10 10
+ endloop
+endfacet
+facet normal 0 -1 0
+ outer loop
+ vertex 0 0 0
+ vertex 10 0 0
+ vertex 10 0 10
+ endloop
+endfacet
+facet normal 0 -1 0
+ outer loop
+ vertex 0 0 0
+ vertex 10 0 10
+ vertex 0 0 10
+ endloop
+endfacet
+facet normal 1 0 0
+ outer loop
+ vertex 10 0 0
+ vertex 10 10 0
+ vertex 10 10 10
+ endloop
+endfacet
+facet normal 1 0 0
+ outer loop
+ vertex 10 0 0
+ vertex 10 10 10
+ vertex 10 0 10
+ endloop
+endfacet
+facet normal 0 1 0
+ outer loop
+ vertex 10 10 0
+ vertex 0 10 0
+ vertex 0 10 10
+ endloop
+endfacet
+facet normal 0 1 0
+ outer loop
+ vertex 10 10 0
+ vertex 0 10 10
+ vertex 10 10 10
+ endloop
+endfacet
+facet normal -1 0 0
+ outer loop
+ vertex 0 10 0
+ vertex 0 0 0
+ vertex 0 0 10
+ endloop
+endfacet
+facet normal -1 0 0
+ outer loop
+ vertex 0 10 0
+ vertex 0 0 10
+ vertex 0 10 10
+ endloop
+endfacet
+endsolid cube
+"""
+
+
+def run(*args, timeout=120):
+ """Run the real CLI in a subprocess.
+
+ By default the source entry point (what the `u1convert` command invokes). Set
+ SNAPSTUDIO_CLI_EXE to the path of a built/installed `u1convert` executable to run
+ the same checks against that instead - the separate packaging gate.
+ """
+ exe = os.environ.get("SNAPSTUDIO_CLI_EXE")
+ cmd = [exe] if exe else [sys.executable, "-c", "from u1convert.cli import cli; cli()"]
+ return subprocess.run([*cmd, *args], capture_output=True, text=True, timeout=timeout)
+
+
+def sha(path):
+ return hashlib.sha256(open(path, "rb").read()).hexdigest()
+
+
+def parse_like_a_caller(stdout: str) -> dict:
+ """The tolerant parse an external caller uses: the last {...} in stdout."""
+ text = stdout.strip()
+ start, end = text.rfind("{"), text.rfind("}")
+ assert 0 <= start < end, f"no JSON object in stdout: {stdout!r}"
+ return json.loads(text[start:end + 1])
+
+
+@pytest.fixture()
+def cube(tmp_path):
+ p = tmp_path / "authored_cube.stl"
+ p.write_text(CUBE)
+ return str(p)
+
+
+def test_doctor_on_a_convertible_model_returns_valid_json_and_exits_1(cube):
+ before = sha(cube)
+ r = run("doctor", cube, "--json")
+ data = parse_like_a_caller(r.stdout)
+ assert data["verdict"] == "CONVERTIBLE"
+ assert r.returncode == 1, "CONVERTIBLE is a valid answer that exits 1; callers must not treat that as failure"
+ assert sha(cube) == before
+
+
+def test_the_json_carries_the_fields_a_caller_reads(cube):
+ data = parse_like_a_caller(run("doctor", cube, "--json").stdout)
+ for key in ("verdict", "score", "family", "recommended_action"):
+ assert key in data, key
+ assert isinstance(data["verdict"], str)
+ assert "validation_issues" in data or "compatibility_issues" in data or "recommended_action" in data
+
+
+def test_repair_writes_a_new_file_and_never_modifies_the_input(cube, tmp_path):
+ before = sha(cube)
+ out = tmp_path / "prepared_u1.3mf"
+ r = run("repair", cube, "--mode", "u1", "-o", str(out))
+ assert out.exists(), (r.returncode, r.stdout[-300:], r.stderr[-300:])
+ assert out.stat().st_size > 0
+ assert sha(cube) == before
+
+
+def test_a_prepared_copy_is_then_ready_and_exits_0(cube, tmp_path):
+ out = tmp_path / "prepared_u1.3mf"
+ run("repair", cube, "--mode", "u1", "-o", str(out))
+ r = run("doctor", str(out), "--json")
+ data = parse_like_a_caller(r.stdout)
+ assert (data["verdict"], r.returncode) == ("READY", 0)
+
+
+def test_doctor_on_a_missing_file_does_not_masquerade_as_an_answer(tmp_path):
+ r = run("doctor", str(tmp_path / "nope.3mf"), "--json")
+ assert r.returncode != 0
+ try:
+ data = parse_like_a_caller(r.stdout)
+ except AssertionError:
+ return # no JSON at all is acceptable
+ assert data.get("verdict") != "READY"
From d8f5bfeaea39f89468fd869d9cdcaf9c8297a853 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sat, 17 Oct 2026 21:56:06 -0400
Subject: [PATCH 10/24] docs: the external command-line contract and what is
verified
Co-Authored-By: Claude Sonnet 5.5
---
docs/EXTERNAL_CLI_CONTRACT.md | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
create mode 100644 docs/EXTERNAL_CLI_CONTRACT.md
diff --git a/docs/EXTERNAL_CLI_CONTRACT.md b/docs/EXTERNAL_CLI_CONTRACT.md
new file mode 100644
index 00000000..e667b67f
--- /dev/null
+++ b/docs/EXTERNAL_CLI_CONTRACT.md
@@ -0,0 +1,27 @@
+# External command-line contract
+
+Other programs may call the `u1convert` command to check a model and to write a
+prepared copy. This is the contract they can rely on. It is pinned by
+`backend/tests/test_external_cli_contract.py`, which runs the real entry point in a
+subprocess. There is no separate API or background service for this.
+
+| Call | Output | Exit code |
+|---|---|---|
+| `u1convert doctor --json` | one JSON object on stdout | `0` only when the verdict is `READY`; otherwise `1` |
+| `u1convert repair --mode u1 -o ` | a new file at `` | the caller should check that `` exists |
+
+- **The exit code of `doctor` is a verdict signal, not a transport failure.** A valid
+ `CONVERTIBLE` answer exits `1` with complete JSON. Read the JSON; do not require exit `0`.
+- JSON fields a caller reads: `verdict`, `score`, `validation_issues`,
+ `compatibility_issues`, `recommended_action`, `family`, `is_u1`.
+- `repair` never modifies its input; it writes a new file. It may also write a
+ `FIX_REPORT.json` next to the output.
+- Everything is local: nothing is uploaded.
+
+## What is and is not verified
+
+- Verified: the source command-line entry point, run with a Python interpreter, against
+ authored test files.
+- **Not verified: a packaged or installed executable.** Run the same checks against one with
+ `SNAPSTUDIO_CLI_EXE= pytest backend/tests/test_external_cli_contract.py`
+ and record the result before claiming packaging works.
From bc8df1b343df0a1d0fb972e0fc675b4d95db01c1 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sun, 18 Oct 2026 11:36:57 -0400
Subject: [PATCH 11/24] fix(receipt): strict G-code parsing, neutral advice,
restored-job disclosure, CLI contract truth
Repairs the review blockers on the assembled PR A + PR B head d8f5bfe.
- Sliced stage uses the same strict parse as a project: an unreadable entry in a
G-code nozzle list or a layer-height list is "not stated", never compacted or
reduced to its first value.
- Supports advice no longer orders a reversal; it says the difference may be
intentional and the action is conditional. The prepared copy is no longer
credited to Studio (the file may have been edited since).
- A restored job whose saved prepared copy is not part of the comparison says so.
- AfterSlicing passes the slice revision to the card as well as keying it.
- docs/EXTERNAL_CLI_CONTRACT.md: repair preserves its input only with a different
output path; a missing file exits 2. Contract test pins all seven fields and
the whole of stdout as one JSON object.
- New tests: malformed G-code values, step-order roll-up, advice wording, and an
AfterSlicing wiring test (validated_ok, blocked, omission, revision remount).
Verified on this tree: backend 3147 passed / 49 skipped; vitest 948 passed; tsc
clean; vite build ok; source-independence 0 violations; B guard-removal matrix
17/17 caught.
Co-Authored-By: Claude Sonnet 5.5
---
backend/snapstudio_core/settings_receipt.py | 24 +++--
backend/tests/test_external_cli_contract.py | 16 ++--
backend/tests/test_settings_receipt.py | 71 +++++++++++++++
.../components/SettingsReceiptCard.test.tsx | 8 ++
.../src/components/SettingsReceiptCard.tsx | 11 ++-
desktop/src/routes/AfterSlicing.test.tsx | 87 +++++++++++++++++++
desktop/src/routes/AfterSlicing.tsx | 7 +-
docs/EXTERNAL_CLI_CONTRACT.md | 5 +-
8 files changed, 205 insertions(+), 24 deletions(-)
create mode 100644 desktop/src/routes/AfterSlicing.test.tsx
diff --git a/backend/snapstudio_core/settings_receipt.py b/backend/snapstudio_core/settings_receipt.py
index d3fb738b..1515dbf7 100644
--- a/backend/snapstudio_core/settings_receipt.py
+++ b/backend/snapstudio_core/settings_receipt.py
@@ -127,13 +127,10 @@ def _read_gcode(path: str) -> dict:
facts = gcode.read_facts(path)
if not facts.get("available"):
return {"state": "unreadable", "reason": facts.get("error") or "not a readable G-code file"}
- config = facts.get("config") or {}
- values = _declared(config)
- # The reader already parsed these two; prefer its numbers over re-parsing text.
- values["nozzles"] = facts.get("nozzle_diameter_mm") or values["nozzles"]
- if facts.get("layer_height_mm") is not None:
- values["layer_height"] = facts["layer_height_mm"]
- return {"state": "read", "values": values}
+ # Strict parse of the recorded config, the same as for a project. The G-code reader's
+ # own nozzle/layer numbers skip entries it cannot read and take the first of a list,
+ # which would turn an unreadable declaration into a plausible one.
+ return {"state": "read", "values": _declared(facts.get("config") or {})}
# --- comparing -----------------------------------------------------------------
@@ -195,10 +192,12 @@ def _advice(fact_id: str, a, b, frm: str, to: str) -> tuple[str, str]:
"Open the prepared copy in Snapmaker Orca and confirm the layer height before slicing.")
elif fact_id == "supports":
turned_off = bool(a) and not b
- why = ("Overhangs the project expected to be supported are not supported in the slice." if turned_off else
- "The slice adds support material the project did not ask for, which changes material use and time.")
- act = (f"In Snapmaker Orca, turn supports {'on' if turned_off else 'off'} in the process profile, slice again "
- "and open the new G-code here." if slicer else
+ why = ("Supports hold up overhangs and change material use and print time, so turning them off can leave "
+ "overhangs unsupported." if turned_off else
+ "Supports hold up overhangs and change material use and print time, so turning them on adds support "
+ "material the project did not ask for.")
+ act = ("This may be intentional. If it is not what you want, change the supports setting in the process "
+ "profile in Snapmaker Orca, slice again and open the new G-code here." if slicer else
"Open the prepared copy in Snapmaker Orca and confirm the supports setting before slicing.")
else:
why = "Whether a prime tower is built changes how colour changes are handled and how much material is used."
@@ -235,8 +234,7 @@ def _step(fact_id: str, frm: str, to: str, stages: dict) -> dict:
return out
out["status"] = "changed"
why, act = _advice(fact_id, a, b, frm, to)
- who = "Studio’s prepared copy differs from the project" if to == "prepared" else \
- f"{_LABEL[to].capitalize()} differs from {_LABEL[frm]}"
+ who = f"{_LABEL[to].capitalize()} differs from {_LABEL[frm]}"
out["detail"] = f"{who}: {_difference(fact_id, a, b)}. {why}"
out["next_action"] = act
return out
diff --git a/backend/tests/test_external_cli_contract.py b/backend/tests/test_external_cli_contract.py
index fd13051a..d21173a6 100644
--- a/backend/tests/test_external_cli_contract.py
+++ b/backend/tests/test_external_cli_contract.py
@@ -5,9 +5,10 @@
the installed command runs) - not an in-process import:
* `doctor --json` prints one JSON object on stdout and exits 0 only when the
- verdict is READY. A valid CONVERTIBLE answer exits 1. The exit code is a verdict
+ verdict is READY. A valid CONVERTIBLE answer exits 1; a missing file exits 2. The exit code is a verdict
signal, not a transport failure: a caller must read the JSON, not the exit code.
-* `repair --mode u1 -o ` writes a NEW file and never touches the input.
+* `repair --mode u1 -o ` writes a NEW file; with a different `` the input is
+ untouched (an `` equal to the input is unsupported and not pinned).
* The JSON fields a caller reads: verdict, score, validation_issues,
compatibility_issues, recommended_action, family, is_u1.
@@ -149,18 +150,19 @@ def cube(tmp_path):
def test_doctor_on_a_convertible_model_returns_valid_json_and_exits_1(cube):
before = sha(cube)
r = run("doctor", cube, "--json")
- data = parse_like_a_caller(r.stdout)
+ data = json.loads(r.stdout) # the whole of stdout is one JSON object
assert data["verdict"] == "CONVERTIBLE"
assert r.returncode == 1, "CONVERTIBLE is a valid answer that exits 1; callers must not treat that as failure"
assert sha(cube) == before
def test_the_json_carries_the_fields_a_caller_reads(cube):
- data = parse_like_a_caller(run("doctor", cube, "--json").stdout)
- for key in ("verdict", "score", "family", "recommended_action"):
+ data = json.loads(run("doctor", cube, "--json").stdout)
+ for key in ("verdict", "score", "validation_issues", "compatibility_issues", "recommended_action", "family", "is_u1"):
assert key in data, key
assert isinstance(data["verdict"], str)
- assert "validation_issues" in data or "compatibility_issues" in data or "recommended_action" in data
+ assert isinstance(data["validation_issues"], list) and isinstance(data["compatibility_issues"], list)
+ assert isinstance(data["is_u1"], bool)
def test_repair_writes_a_new_file_and_never_modifies_the_input(cube, tmp_path):
@@ -182,7 +184,7 @@ def test_a_prepared_copy_is_then_ready_and_exits_0(cube, tmp_path):
def test_doctor_on_a_missing_file_does_not_masquerade_as_an_answer(tmp_path):
r = run("doctor", str(tmp_path / "nope.3mf"), "--json")
- assert r.returncode != 0
+ assert r.returncode == 2
try:
data = parse_like_a_caller(r.stdout)
except AssertionError:
diff --git a/backend/tests/test_settings_receipt.py b/backend/tests/test_settings_receipt.py
index de52018f..cbe8ab8d 100644
--- a/backend/tests/test_settings_receipt.py
+++ b/backend/tests/test_settings_receipt.py
@@ -225,3 +225,74 @@ def post(body):
finally:
httpd.shutdown()
httpd.server_close()
+
+
+# --- repair: strict G-code parsing, neutral advice, roll-up order ------------------
+
+def test_a_gcode_nozzle_list_with_an_unreadable_entry_is_missing_not_compacted(tmp_path):
+ original = project(tmp_path, nozzle_diameter=["0.4", "0.6"])
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,unknown,0.6")
+ s = step(fact(sr.compare(original, None, sliced), "nozzles"), "original", "sliced")
+ assert (s["status"], s["to_value"]) == ("missing", None)
+
+
+def test_a_gcode_layer_height_list_is_missing_not_its_first_value(tmp_path):
+ original = project(tmp_path, layer_height="0.2")
+ sliced = gcode(tmp_path, layer_height="0.2,0.3")
+ s = step(fact(sr.compare(original, None, sliced), "layer_height"), "original", "sliced")
+ assert (s["status"], s["to_value"]) == ("missing", None)
+
+
+def test_a_project_layer_height_list_is_missing_not_its_first_value(tmp_path):
+ original = project(tmp_path, layer_height=["0.2", "0.3"])
+ sliced = gcode(tmp_path, layer_height="0.2")
+ s = step(fact(sr.compare(original, None, sliced), "layer_height"), "original", "sliced")
+ assert (s["status"], s["from_value"]) == ("missing", None)
+
+
+def test_a_later_changed_step_makes_the_whole_row_changed_whatever_the_step_order(tmp_path):
+ original = project(tmp_path, "o.3mf", **ASKED)
+ prepared = project(tmp_path, "p.3mf", **ASKED) # original -> prepared: same
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.08", enable_support="1", enable_prime_tower="1")
+ r = sr.compare(original, prepared, sliced)
+ row = fact(r, "layer_height")
+ assert [s["status"] for s in row["steps"]] == ["unchanged", "changed"]
+ assert row["status"] == "changed"
+ assert r["summary"].startswith("1 changed")
+
+
+def test_a_missing_step_outranks_not_checked_and_unchanged(tmp_path):
+ original = project(tmp_path, "o.3mf", **ASKED)
+ prepared = project(tmp_path, "p.3mf", **ASKED)
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.2") # supports not recorded
+ row = fact(sr.compare(original, prepared, sliced), "supports")
+ assert [s["status"] for s in row["steps"]] == ["unchanged", "missing"]
+ assert row["status"] == "missing"
+
+
+@pytest.mark.parametrize("asked,recorded", [("1", "0"), ("0", "1")])
+def test_supports_advice_never_orders_a_reversal_and_says_it_may_be_intentional(tmp_path, asked, recorded):
+ original = project(tmp_path, "o.3mf", **{**ASKED, "enable_support": asked})
+ prepared = project(tmp_path, "p.3mf", **{**ASKED, "enable_support": recorded})
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.2", enable_support=recorded, enable_prime_tower="1")
+ row = fact(sr.compare(original, prepared, sliced), "supports")
+ actions = [s["next_action"] for s in row["steps"] if s["next_action"]]
+ assert actions
+ for text in actions:
+ low = text.lower()
+ assert "turn supports on" not in low and "turn supports off" not in low
+ assert "confirm" in low or "if it is not what you want" in low
+
+
+def test_a_slice_difference_from_supports_says_it_may_be_intentional(tmp_path):
+ sliced = gcode(tmp_path, nozzle_diameter="0.4,0.4,0.4,0.4", layer_height="0.2", enable_support="0", enable_prime_tower="1")
+ s = step(fact(sr.compare(project(tmp_path, **ASKED), None, sliced), "supports"), "original", "sliced")
+ assert "may be intentional" in s["next_action"].lower()
+
+
+def test_the_receipt_does_not_credit_studio_with_what_the_prepared_file_holds(tmp_path):
+ original = project(tmp_path, "o.3mf", **ASKED)
+ prepared = project(tmp_path, "p.3mf", **{**ASKED, "layer_height": "0.16"})
+ s = step(fact(sr.compare(original, prepared, None), "layer_height"), "original", "prepared")
+ assert s["detail"].startswith("The prepared copy differs from the project")
+ assert "studio" not in s["detail"].lower()
diff --git a/desktop/src/components/SettingsReceiptCard.test.tsx b/desktop/src/components/SettingsReceiptCard.test.tsx
index 3d5a6a30..4cf456dc 100644
--- a/desktop/src/components/SettingsReceiptCard.test.tsx
+++ b/desktop/src/components/SettingsReceiptCard.test.tsx
@@ -103,4 +103,12 @@ describe("the settings receipt card", () => {
await act(async () => {});
expect(screen.getByText("Open a sliced G-code to compare.")).toBeTruthy();
});
+it("discloses a saved prepared copy that is not part of the comparison, and only then", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ const { rerender } = render();
+ await act(async () => {});
+ expect(screen.getByTestId("receipt-prepared-omitted").textContent).toMatch(/saved prepared copy that was not reopened/);
+ rerender();
+ expect(screen.queryByTestId("receipt-prepared-omitted")).toBeNull();
+ });
});
diff --git a/desktop/src/components/SettingsReceiptCard.tsx b/desktop/src/components/SettingsReceiptCard.tsx
index 89d74a19..8ddc5fe2 100644
--- a/desktop/src/components/SettingsReceiptCard.tsx
+++ b/desktop/src/components/SettingsReceiptCard.tsx
@@ -14,8 +14,10 @@ import { receiptKey, STATUS_LABEL, statusTone, stepTitle } from "@/lib/settingsR
* the G-code belongs to the project, whether the profile fits the printer and
* whether the print will work are separate questions answered elsewhere.
*/
-export function SettingsReceiptCard({ original, prepared, sliced, revision = 0 }: {
+export function SettingsReceiptCard({ original, prepared, sliced, revision = 0, preparedOmitted = false }: {
original: string; prepared: string | null; sliced: string | null; revision?: number;
+ /** A saved job has a prepared copy that is not part of this comparison. */
+ preparedOmitted?: boolean;
}) {
const key = receiptKey(original, prepared, sliced, revision);
const [answer, setAnswer] = useState<{ key: string; receipt: SettingsReceipt } | null>(null);
@@ -46,6 +48,13 @@ export function SettingsReceiptCard({ original, prepared, sliced, revision = 0 }
+ {preparedOmitted && (
+
+ This job has a saved prepared copy that was not reopened, so this compares the project with the slice
+ directly. It cannot show whether the prepared copy changed anything.
+
+ )}
+
{!receipt && failed !== key &&
Comparing…
}
{failed === key && (
Studio could not compare these files. Check that they are still where they were.
diff --git a/desktop/src/routes/AfterSlicing.test.tsx b/desktop/src/routes/AfterSlicing.test.tsx
new file mode 100644
index 00000000..9f6dbe89
--- /dev/null
+++ b/desktop/src/routes/AfterSlicing.test.tsx
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+import React, { useEffect } from "react";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/api", () => ({}));
+vi.mock("@/components/JobWorkspaceCard", () => ({ JobWorkspaceCard: () => null }));
+vi.mock("@/components/OrcaRoundTrip", () => ({ OrcaRoundTrip: () => null }));
+vi.mock("@/components/SlicedJobCards", () => ({ SlicedJobCards: () => null }));
+
+// Stands in for the real card: shows what AfterSlicing hands it and counts how often it is mounted.
+const mounts = vi.fn();
+vi.mock("@/components/SettingsReceiptCard", () => ({
+ SettingsReceiptCard: (p: { original: string; prepared: string | null; sliced: string | null; revision?: number; preparedOmitted?: boolean }) => {
+ useEffect(() => { mounts(); }, []);
+ return (
+
+ );
+ },
+}));
+
+import AfterSlicing from "./AfterSlicing";
+import { useJob } from "@/store/job";
+import { useSession } from "@/store/session";
+import { useSliced } from "@/store/sliced";
+
+const PROJECT = { path: "C:\p.3mf", name: "p.3mf", ext: "3mf" };
+const done = (data: any) => ({ status: "done" as const, data, error: null });
+const idleConvert = { status: "idle" as const, data: null, error: null };
+const shown = () => JSON.parse(screen.getByTestId("receipt").textContent ?? "{}");
+
+beforeEach(() => {
+ mounts.mockClear();
+ useSession.setState({ file: PROJECT as any, convert: idleConvert as any });
+ useSliced.setState({ path: "C:\s.gcode", name: "s.gcode", revision: 0 });
+ useJob.setState({ record: null });
+});
+afterEach(cleanup);
+
+describe("After slicing: what the settings receipt is given", () => {
+ it("passes a prepared copy that Studio wrote and validated this session", () => {
+ useSession.setState({ convert: done({ blocked: false, validated_ok: true, output_path: "C:\prep.3mf" }) as any });
+ render();
+ expect(shown()).toMatchObject({ original: "C:\p.3mf", prepared: "C:\prep.3mf", sliced: "C:\s.gcode", omitted: false });
+ });
+
+ it("does not pass a prepared copy that did not validate", () => {
+ useSession.setState({ convert: done({ blocked: false, validated_ok: false, output_path: "C:\prep.3mf" }) as any });
+ render();
+ expect(shown().prepared).toBeNull();
+ });
+
+ it("does not pass a prepared copy from a blocked preparation", () => {
+ useSession.setState({ convert: done({ blocked: true, validated_ok: true, output_path: "C:\prep.3mf" }) as any });
+ render();
+ expect(shown().prepared).toBeNull();
+ });
+
+ it("does not pass a prepared copy while preparing or after an error", () => {
+ useSession.setState({ convert: { status: "loading", data: null, error: null } as any });
+ render();
+ expect(shown().prepared).toBeNull();
+ });
+
+ it("says so when a restored job has a saved prepared copy that is not part of the comparison", () => {
+ useJob.setState({ record: { schema: 1, savedAt: "t", original: { path: "C:\p.3mf" }, prepared: { path: "C:\prep.3mf", mode: "x" }, sliced: null } as any });
+ render();
+ expect(shown()).toMatchObject({ prepared: null, omitted: true });
+ });
+
+ it("does not claim an omission when there is no saved prepared copy", () => {
+ useJob.setState({ record: { schema: 1, savedAt: "t", original: { path: "C:\p.3mf" }, prepared: null, sliced: null } as any });
+ render();
+ expect(shown().omitted).toBe(false);
+ });
+
+ it("hands the card the slice revision and starts it afresh when Orca re-exports over the same path", () => {
+ render();
+ expect(mounts).toHaveBeenCalledTimes(1);
+ expect(shown().revision).toBe(0);
+ act(() => useSliced.getState().refresh());
+ expect(mounts).toHaveBeenCalledTimes(2); // remounted: the old answer is discarded
+ expect(shown().revision).toBe(1);
+ });
+});
diff --git a/desktop/src/routes/AfterSlicing.tsx b/desktop/src/routes/AfterSlicing.tsx
index 2d7c09be..cc9f5049 100644
--- a/desktop/src/routes/AfterSlicing.tsx
+++ b/desktop/src/routes/AfterSlicing.tsx
@@ -8,6 +8,7 @@ import { JobWorkspaceCard } from "@/components/JobWorkspaceCard";
import { SettingsReceiptCard } from "@/components/SettingsReceiptCard";
import { useSliced } from "@/store/sliced";
import { useSession } from "@/store/session";
+import { useJob } from "@/store/job";
import { POST_SLICE_EXPLAINER } from "@/lib/postSlice";
/**
@@ -31,6 +32,9 @@ export default function AfterSlicing() {
// A prepared copy counts only if Studio wrote and validated it in this session.
const prepared = convert.status === "done" && convert.data && !convert.data.blocked && convert.data.validated_ok
? convert.data.output_path : null;
+ // A restored job keeps its prepared copy on record but does not reopen it, so the receipt must say so.
+ const hasSavedPrepared = useJob((s) => s.record?.prepared != null);
+ const preparedOmitted = !prepared && hasSavedPrepared;
const [typed, setTyped] = useState("");
return (
@@ -101,7 +105,8 @@ export default function AfterSlicing() {
Choose another file
- {project && }
+ {project && }
>
)}
diff --git a/docs/EXTERNAL_CLI_CONTRACT.md b/docs/EXTERNAL_CLI_CONTRACT.md
index e667b67f..52123c56 100644
--- a/docs/EXTERNAL_CLI_CONTRACT.md
+++ b/docs/EXTERNAL_CLI_CONTRACT.md
@@ -7,14 +7,15 @@ subprocess. There is no separate API or background service for this.
| Call | Output | Exit code |
|---|---|---|
-| `u1convert doctor --json` | one JSON object on stdout | `0` only when the verdict is `READY`; otherwise `1` |
+| `u1convert doctor --json` | one JSON object on stdout | `0` only when the verdict is `READY`; `1` for any other verdict; `2` when the file does not exist (a usage error, no JSON) |
| `u1convert repair --mode u1 -o ` | a new file at `` | the caller should check that `` exists |
- **The exit code of `doctor` is a verdict signal, not a transport failure.** A valid
`CONVERTIBLE` answer exits `1` with complete JSON. Read the JSON; do not require exit `0`.
- JSON fields a caller reads: `verdict`, `score`, `validation_issues`,
`compatibility_issues`, `recommended_action`, `family`, `is_u1`.
-- `repair` never modifies its input; it writes a new file. It may also write a
+- `repair` leaves its input unmodified **when `` is a different path** (this is what the test
+ checks). Passing the input itself as `` is not supported: do not do it. It may also write a
`FIX_REPORT.json` next to the output.
- Everything is local: nothing is uploaded.
From 98ec3ff08d28de8eb124d7cf489fab8fdc4c67b0 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sun, 18 Oct 2026 15:54:11 -0400
Subject: [PATCH 12/24] feat(desktop): Studio refresh - Home, This job, After
slicing
Presentation and wiring only; no new API, store or job-state machine.
- Home: Open a project first, then the explicit restore card (names the files
it will reopen; explains why a prepared copy is never reopened), then recents.
- This job: persistent project header, five action-named steps derived from
existing session/slice state, one next action. The Orca handoff is offered only
for a copy Studio wrote and validated this session and names the file it opens.
Prepare link now points at the workspace (Compatibility keeps its own result).
- After slicing: settings receipt as project / prepared / sliced values side by
side (Tool 1-4 in order; Same / Different / Not stated / Not checked / Not
included), scope under the title, retry on failure, and four separate
questions (printer, project compatibility, settings, print readiness).
- Receipt wording: explanations name the stage that changed (not always "the
project"); a re-preparation in flight is not called an omission; neutral
prepared-copy attribution.
- Shell: narrow-window drawer (dialog, Escape/scrim, focus return, Tab wrap),
titles, visible focus, reduced motion.
Behaviour changes: This job no longer offers the Orca handoff for an unvalidated
or blocked result; page title "Dashboard" is now "Home".
Verified on this tree: tsc clean; vitest 1012 passed; vite build ok; backend
settings_receipt + CLI contract tests 33 passed; source-independence 0
violations. Browser screenshots and a real-key walk were taken with synthetic
data; native Tauri and packaged CLI are not covered.
Co-Authored-By: Claude Sonnet 5.5
---
backend/snapstudio_core/settings_receipt.py | 6 +-
backend/tests/test_settings_receipt.py | 16 ++
desktop/src/components/JobHeader.test.tsx | 108 ++++++++++++
desktop/src/components/JobHeader.tsx | 111 ++++++++++++
.../src/components/JobWorkspaceCard.test.tsx | 26 +++
desktop/src/components/JobWorkspaceCard.tsx | 25 ++-
desktop/src/components/OrcaHandoff.test.tsx | 42 +++++
desktop/src/components/OrcaHandoff.tsx | 5 +
.../components/SettingsReceiptCard.test.tsx | 68 ++++++-
.../src/components/SettingsReceiptCard.tsx | 69 ++++++--
.../src/components/shell/AppShell.test.tsx | 96 ++++++++++
desktop/src/components/shell/AppShell.tsx | 48 ++++-
desktop/src/components/shell/Sidebar.tsx | 9 +-
desktop/src/components/shell/TopBar.tsx | 15 +-
desktop/src/index.css | 18 ++
desktop/src/lib/jobProgress.test.ts | 93 ++++++++++
desktop/src/lib/jobProgress.ts | 144 +++++++++++++++
desktop/src/lib/settingsReceipt.ts | 31 +++-
desktop/src/lib/settingsReceiptCells.test.ts | 58 ++++++
desktop/src/routes/AfterSlicing.test.tsx | 30 +++-
desktop/src/routes/AfterSlicing.tsx | 39 +++-
desktop/src/routes/Cockpit.test.tsx | 79 +++++++++
desktop/src/routes/Cockpit.tsx | 20 ++-
desktop/src/routes/Dashboard.test.tsx | 93 ++++++++++
desktop/src/routes/Dashboard.tsx | 166 +++++++++---------
25 files changed, 1276 insertions(+), 139 deletions(-)
create mode 100644 desktop/src/components/JobHeader.test.tsx
create mode 100644 desktop/src/components/JobHeader.tsx
create mode 100644 desktop/src/components/OrcaHandoff.test.tsx
create mode 100644 desktop/src/components/shell/AppShell.test.tsx
create mode 100644 desktop/src/lib/jobProgress.test.ts
create mode 100644 desktop/src/lib/jobProgress.ts
create mode 100644 desktop/src/lib/settingsReceiptCells.test.ts
create mode 100644 desktop/src/routes/Cockpit.test.tsx
create mode 100644 desktop/src/routes/Dashboard.test.tsx
diff --git a/backend/snapstudio_core/settings_receipt.py b/backend/snapstudio_core/settings_receipt.py
index 1515dbf7..001c4bac 100644
--- a/backend/snapstudio_core/settings_receipt.py
+++ b/backend/snapstudio_core/settings_receipt.py
@@ -180,8 +180,8 @@ def _advice(fact_id: str, a, b, frm: str, to: str) -> tuple[str, str]:
"""(consequence, next action) for a real change between two stages."""
slicer = to == "sliced"
if fact_id == "nozzles":
- why = ("Extrusion widths and flow are worked out from the nozzle, so the result no longer matches what the "
- "project was set up for.")
+ why = ("Extrusion widths and flow are worked out from the nozzle, so the result no longer matches what "
+ f"{_LABEL[frm]} was set up for.")
act = ("Check the printer profile in Snapmaker Orca lists the nozzles your U1 actually has, then slice the "
"prepared copy again and open the new G-code here." if slicer else
"Open the prepared copy in Snapmaker Orca and confirm the nozzles are the ones you intend.")
@@ -195,7 +195,7 @@ def _advice(fact_id: str, a, b, frm: str, to: str) -> tuple[str, str]:
why = ("Supports hold up overhangs and change material use and print time, so turning them off can leave "
"overhangs unsupported." if turned_off else
"Supports hold up overhangs and change material use and print time, so turning them on adds support "
- "material the project did not ask for.")
+ f"material {_LABEL[frm]} did not ask for.")
act = ("This may be intentional. If it is not what you want, change the supports setting in the process "
"profile in Snapmaker Orca, slice again and open the new G-code here." if slicer else
"Open the prepared copy in Snapmaker Orca and confirm the supports setting before slicing.")
diff --git a/backend/tests/test_settings_receipt.py b/backend/tests/test_settings_receipt.py
index cbe8ab8d..9d605e6e 100644
--- a/backend/tests/test_settings_receipt.py
+++ b/backend/tests/test_settings_receipt.py
@@ -296,3 +296,19 @@ def test_the_receipt_does_not_credit_studio_with_what_the_prepared_file_holds(tm
s = step(fact(sr.compare(original, prepared, None), "layer_height"), "original", "prepared")
assert s["detail"].startswith("The prepared copy differs from the project")
assert "studio" not in s["detail"].lower()
+
+
+def test_an_explanation_names_the_stage_that_actually_changed_not_always_the_project(tmp_path):
+ # Supports on in the project and the prepared copy, off in the slice: the prepared -> sliced step
+ # must not say "the project" turned them off, and a nozzle change after preparing must not either.
+ original = project(tmp_path, "o.3mf", **{**ASKED, "enable_support": "0"})
+ prepared = project(tmp_path, "p.3mf", **{**ASKED, "enable_support": "0"})
+ sliced = gcode(tmp_path, nozzle_diameter="0.2,0.4,0.4,0.4", layer_height="0.2", enable_support="1", enable_prime_tower="1")
+ receipt = sr.compare(original, prepared, sliced)
+ supports = step(fact(receipt, "supports"), "prepared", "sliced")["detail"]
+ nozzles = step(fact(receipt, "nozzles"), "prepared", "sliced")["detail"]
+ assert "the prepared copy did not ask for" in supports and "the project did not ask for" not in supports
+ assert "what the prepared copy was set up for" in nozzles and "the project was set up for" not in nozzles
+ # The first step still names the project.
+ first = sr.compare(project(tmp_path, "o2.3mf", **{**ASKED, "enable_support": "0"}), None, sliced)
+ assert "the project did not ask for" in step(fact(first, "supports"), "original", "sliced")["detail"]
diff --git a/desktop/src/components/JobHeader.test.tsx b/desktop/src/components/JobHeader.test.tsx
new file mode 100644
index 00000000..4194a37d
--- /dev/null
+++ b/desktop/src/components/JobHeader.test.tsx
@@ -0,0 +1,108 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue({ port: 1, token: "t" }) }));
+vi.mock("@/api", () => ({ doctor: vi.fn(), convert: vi.fn(), diff: vi.fn() }));
+
+import { JobHeader } from "@/components/JobHeader";
+import { useSession } from "@/store/session";
+import { useSliced } from "@/store/sliced";
+
+const FILE = { path: "C:\\work\\Benchy.3mf", name: "Benchy.3mf", ext: "3mf" };
+const doctorDone = (over: object = {}) => ({ status: "done" as const, data: { verdict: "READY", ...over }, error: null });
+const idle = { status: "idle" as const, data: null, error: null };
+const runDoctor = vi.fn();
+
+const view = () => render();
+const steps = () => within(screen.getByRole("list", { name: "Job progress" })).getAllByRole("listitem");
+
+beforeEach(() => {
+ runDoctor.mockReset();
+ useSession.setState({ file: FILE as any, doctor: doctorDone() as any, convert: idle as any, runDoctor: runDoctor as any });
+ useSliced.setState({ path: null, name: null });
+});
+afterEach(cleanup);
+
+describe("the job header", () => {
+ it("renders nothing without a project", () => {
+ useSession.setState({ file: null });
+ expect(view().container.innerHTML).toBe("");
+ });
+
+ it("keeps the project's name in view, whole, with its path on hover", () => {
+ const long = "a-very-long-project-name-with-many-parts-and-a-version-number-v12-final-final.3mf";
+ useSession.setState({ file: { path: `C:\\work\\${long}`, name: long, ext: "3mf" } as any });
+ view();
+ const name = screen.getByRole("heading", { name: new RegExp(long.slice(0, 20)) }).querySelector("span")!;
+ expect(name.textContent).toBe(long);
+ expect(name.className).toContain("break-all");
+ expect(name.getAttribute("title")).toBe(`C:\\work\\${long}`);
+ });
+
+ it("shows five action-named steps and marks exactly the current one", () => {
+ view();
+ expect(steps().map((s) => s.textContent?.replace(/:.*$/, "").trim())).toEqual(
+ ["Open project", "Check", "Prepare copy", "Slice in Orca", "Compare slice"]);
+ const current = steps().filter((s) => s.getAttribute("aria-current") === "step");
+ expect(current).toHaveLength(1);
+ expect(current[0].textContent).toContain("Prepare copy");
+ });
+
+ it("a check with findings says so in words, not a bare tick", () => {
+ useSession.setState({ doctor: doctorDone({ verdict: "REPAIRABLE" }) as any });
+ view();
+ expect(steps()[1].textContent).toContain("needs review");
+ });
+
+ it("offers one next action, and it is preparing, not an Orca handoff, before a copy exists", () => {
+ view();
+ const status = screen.getByRole("status");
+ expect(status.textContent).toContain("Prepare a U1 copy");
+ expect(within(status).getByRole("link", { name: /Prepare a copy/ }).getAttribute("href")).toBe("/workspace");
+ expect(status.textContent).not.toMatch(/Orca/);
+ });
+
+ it("names the exact prepared copy once one is validated", () => {
+ useSession.setState({ convert: { status: "done", error: null, data: { validated_ok: true, blocked: false, output_name: "Benchy_U1.3mf", output_path: "C:\\work\\Benchy_U1.3mf" } } as any });
+ view();
+ expect(screen.getByRole("status").textContent).toContain("Open Benchy_U1.3mf in Snapmaker Orca");
+ });
+
+ it("an unvalidated copy is not offered to Orca", () => {
+ useSession.setState({ convert: { status: "done", error: null, data: { validated_ok: false, blocked: false, output_name: "x_U1.3mf" } } as any });
+ view();
+ expect(screen.getByRole("status").textContent).not.toMatch(/Orca/);
+ });
+
+ it("a failed check can be retried", () => {
+ useSession.setState({ doctor: { status: "error", data: null, error: "x" } as any });
+ view();
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Check again" })); });
+ expect(runDoctor).toHaveBeenCalledTimes(1);
+ });
+
+ it("while checking it says so and offers no button", () => {
+ useSession.setState({ doctor: { status: "loading", data: null, error: null } as any });
+ view();
+ expect(screen.getByRole("status").textContent).toContain("Checking the project…");
+ expect(within(screen.getByRole("status")).queryByRole("button")).toBeNull();
+ expect(within(screen.getByRole("status")).queryByRole("link")).toBeNull();
+ });
+
+ it("with a slice open the next action is to compare it", () => {
+ useSliced.setState({ path: "C:\\out\\Benchy.gcode", name: "Benchy.gcode" });
+ view();
+ expect(within(screen.getByRole("status")).getByRole("link", { name: /Compare slice/ }).getAttribute("href")).toBe("/after-slicing");
+ });
+
+ it("moves focus to the Orca step it points at", () => {
+ useSession.setState({ convert: { status: "done", error: null, data: { validated_ok: true, blocked: false, output_name: "p.3mf" } } as any });
+ render(prepared);
+ Element.prototype.scrollIntoView = vi.fn();
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Go to the Orca step" })); });
+ expect(document.activeElement?.id).toBe("job-stage-prepared");
+ });
+});
diff --git a/desktop/src/components/JobHeader.tsx b/desktop/src/components/JobHeader.tsx
new file mode 100644
index 00000000..257bc4db
--- /dev/null
+++ b/desktop/src/components/JobHeader.tsx
@@ -0,0 +1,111 @@
+import { Link } from "react-router-dom";
+import { AlertTriangle, ArrowRight, Check, Circle, CircleDot, FileCheck2, XCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useSession } from "@/store/session";
+import { useSliced } from "@/store/sliced";
+import { deriveSteps, nextAction, type JobFacts, type StepState } from "@/lib/jobProgress";
+
+const MARK: Record = {
+ done: Check, review: AlertTriangle, current: CircleDot, todo: Circle, blocked: XCircle, error: XCircle,
+};
+
+/** Facts the job header reads, taken straight from the existing stores. */
+export function useJobFacts(): JobFacts {
+ const project = useSession((s) => s.file);
+ const doctor = useSession((s) => s.doctor);
+ const convert = useSession((s) => s.convert);
+ const sliced = useSliced((s) => s.path);
+ return {
+ hasProject: !!project,
+ doctor: {
+ status: doctor.status,
+ prepareBlocked: !!doctor.data?.prepare_blocked,
+ verdict: typeof doctor.data?.verdict === "string" ? doctor.data.verdict : null,
+ },
+ convert: {
+ status: convert.status,
+ blocked: !!convert.data?.blocked,
+ validatedOk: !!convert.data?.validated_ok,
+ outputName: convert.data?.output_name ?? null,
+ },
+ hasSliced: !!sliced,
+ };
+}
+
+/**
+ * The job at a glance: which project, where it is, and the one thing to do next.
+ * Everything is derived from existing state; nothing here keeps its own.
+ */
+export function JobHeader() {
+ const project = useSession((s) => s.file);
+ const runDoctor = useSession((s) => s.runDoctor);
+ const facts = useJobFacts();
+ if (!project) return null;
+
+ const steps = deriveSteps(facts);
+ const next = nextAction(facts);
+
+ const goTo = (id: string) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.scrollIntoView({ block: "start" });
+ el.focus({ preventScroll: true });
+ };
+
+ return (
+
+
+
This job
+ {/* The full checks stay one click away; not repeated when the next action already is that link. */}
+ {next.action?.to !== "/compatibility" && (
+
+ Open the full checks
+
+ )}
+
+
+ );
+}
diff --git a/desktop/src/components/JobWorkspaceCard.test.tsx b/desktop/src/components/JobWorkspaceCard.test.tsx
index 75f6344a..dbd392f6 100644
--- a/desktop/src/components/JobWorkspaceCard.test.tsx
+++ b/desktop/src/components/JobWorkspaceCard.test.tsx
@@ -104,6 +104,32 @@ describe("the job card", () => {
expect(container.innerHTML).toBe("");
});
+ it("says which files will reopen, by name, and that nothing is prepared, sliced or sent", async () => {
+ report({ original: "same", prepared: "same", sliced: "same" });
+ render();
+ expect(screen.getByText(/Check first: Studio then lists exactly which files it will reopen/)).toBeTruthy();
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Check these files" })); });
+ const note = screen.getByText(/Will reopen:/);
+ expect(note.textContent).toContain("A.3mf, A.gcode");
+ expect(note.textContent).not.toContain("A_U1.3mf");
+ expect(note.textContent).toMatch(/does not prepare, slice or send anything/);
+ });
+
+ it("explains why the prepared copy is listed but never reopened", () => {
+ render();
+ expect(screen.getByText("Why isn’t the prepared copy reopened?")).toBeTruthy();
+ expect(screen.getByText("preserve mode")).toBeTruthy();
+ });
+
+ it("keeps a long file name whole and readable, with its full path on hover", () => {
+ const long = "a-very-long-project-name-with-many-parts-and-a-version-number-v12-final-final.3mf";
+ useJob.setState({ record: { ...RECORD, original: { ...RECORD.original, name: long, path: `C:\\work\\${long}` } } });
+ render();
+ const name = screen.getByText(long);
+ expect(name.className).toContain("break-all");
+ expect(name.getAttribute("title")).toBe(`C:\\work\\${long}`);
+ });
+
it("forgetting the job removes it", () => {
render();
act(() => { fireEvent.click(screen.getByRole("button", { name: "Forget this job" })); });
diff --git a/desktop/src/components/JobWorkspaceCard.tsx b/desktop/src/components/JobWorkspaceCard.tsx
index 6d880756..d03299ea 100644
--- a/desktop/src/components/JobWorkspaceCard.tsx
+++ b/desktop/src/components/JobWorkspaceCard.tsx
@@ -70,7 +70,7 @@ export function JobWorkspaceCard() {
{resuming
? "Studio kept which files belonged together. Check them before reopening — files can move or change."
- : "The project, the copy Studio prepared from it and the G-code that came back, kept together."}
+ : "The project, its prepared copy and the G-code that came back, kept together."}
+ It is listed so you can see it, but a saved job never reopens it: Studio would rather you prepare a
+ fresh copy from the project than trust one that may have been edited since.
+
Checking that the project is unchanged before listing its earlier prepared copy and G-code…
)}
@@ -131,6 +147,7 @@ export function JobWorkspaceCard() {
{plan.length === 0 ? "Nothing to reopen" : `Reopen ${plan.map((r) => LABEL[r].toLowerCase()).join(" and ")}`}
+ {plan.length > 0 && <>Will reopen: {plan.map((r) => (record[r] as LinkedFile).name).join(", ")}. >}
Reopening only shows these files again. It does not prepare, slice or send anything.
diff --git a/desktop/src/components/OrcaHandoff.test.tsx b/desktop/src/components/OrcaHandoff.test.tsx
new file mode 100644
index 00000000..db3fd97f
--- /dev/null
+++ b/desktop/src/components/OrcaHandoff.test.tsx
@@ -0,0 +1,42 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/api", () => ({ detectOrca: vi.fn(), openInOrca: vi.fn() }));
+vi.mock("@/components/EcosystemPanel", () => ({ EcosystemPanel: () => null }));
+vi.mock("@/components/FidelityCard", () => ({ FidelityCard: () => null }));
+vi.mock("@/components/FixLedgerCard", () => ({ FixLedgerCard: () => null }));
+
+import * as api from "@/api";
+import { OrcaHandoff } from "@/components/OrcaHandoff";
+
+const PATH = "C:\\work\\a-long-prepared-copy-name-with-many-parts-U1-v12.3mf";
+const view = () => render();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(api.detectOrca).mockResolvedValue("C:\\Orca\\orca.exe");
+ vi.mocked(api.openInOrca).mockResolvedValue(undefined);
+});
+afterEach(cleanup);
+
+describe("the Orca handoff", () => {
+ it("names the exact file it opens, whole, with the full path on hover", async () => {
+ view();
+ await act(async () => {});
+ const target = screen.getByTestId("orca-target");
+ expect(target.textContent).toBe("a-long-prepared-copy-name-with-many-parts-U1-v12.3mf");
+ expect(target.getAttribute("title")).toBe(PATH);
+ expect(target.className).toContain("break-all");
+ });
+
+ it("opens that file, and only that file", async () => {
+ view();
+ await act(async () => {});
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Open in Snapmaker Orca" })); });
+ expect(api.openInOrca).toHaveBeenCalledTimes(1);
+ expect(api.openInOrca).toHaveBeenCalledWith(PATH);
+ });
+});
diff --git a/desktop/src/components/OrcaHandoff.tsx b/desktop/src/components/OrcaHandoff.tsx
index c1e2f126..e0d4d3a7 100644
--- a/desktop/src/components/OrcaHandoff.tsx
+++ b/desktop/src/components/OrcaHandoff.tsx
@@ -62,6 +62,11 @@ export function OrcaHandoff({ outputPath, originalPath }:
)}
+ {/* The exact file the button opens, so the handoff never targets something unnamed. */}
+
+ Opens the prepared copy
+ {outputPath.split(/[\\/]/).pop() || outputPath}
+
{ORCA_HANDOFF_LINE}
diff --git a/desktop/src/components/SettingsReceiptCard.test.tsx b/desktop/src/components/SettingsReceiptCard.test.tsx
index 4cf456dc..102f9c79 100644
--- a/desktop/src/components/SettingsReceiptCard.test.tsx
+++ b/desktop/src/components/SettingsReceiptCard.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import React from "react";
-import { act, cleanup, render, screen } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/api", () => ({ settingsReceipt: vi.fn() }));
@@ -42,7 +42,6 @@ describe("the settings receipt card", () => {
expect(screen.getByText("Tool 1: 0.4 mm → 0.2 mm")).toBeTruthy();
expect(screen.getByText(/Check the printer profile/)).toBeTruthy();
expect(screen.getAllByText("Different").length).toBe(2);
- expect(screen.getAllByText("Not stated").length).toBe(1);
expect(screen.getByText("Same")).toBeTruthy();
});
@@ -54,8 +53,8 @@ describe("the settings receipt card", () => {
render();
await act(async () => {});
expect(screen.queryByText("Same")).toBeNull();
- expect(screen.getByText("Not stated")).toBeTruthy();
- expect(screen.getByText("Not checked")).toBeTruthy();
+ expect(screen.getAllByText("Not stated").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Not checked").length).toBeGreaterThan(0);
});
it("states that it is about settings only, and its scope", async () => {
@@ -103,6 +102,67 @@ describe("the settings receipt card", () => {
await act(async () => {});
expect(screen.getByText("Open a sliced G-code to compare.")).toBeTruthy();
});
+ it("lays the three files' values side by side, with Tool 1 to Tool 4 in order", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue({
+ ...receipt([
+ fact("nozzles", "Nozzle diameter per tool", [
+ step({ from: "original", to: "prepared", from_value: [0.4, 0.4, 0.4, 0.4], to_value: [0.4, 0.4, 0.4, 0.4], status: "unchanged", detail: "Same." }),
+ step({ from: "prepared", to: "sliced", from_value: [0.4, 0.4, 0.4, 0.4], to_value: [0.4, 0.2, 0.4, 0.4], status: "changed", detail: "Tool 2: 0.4 mm → 0.2 mm" }),
+ ]),
+ ]),
+ stages: { original: { state: "read" }, prepared: { state: "read" }, sliced: { state: "read" } },
+ });
+ render();
+ await act(async () => {});
+ for (const label of ["Project", "Prepared copy", "Sliced G-code"]) expect(screen.getAllByText(label).length).toBeGreaterThan(0);
+ const tool2 = screen.getAllByText(/^Tool 2: [0-9.]+ mm$/).map((n) => n.textContent);
+ expect(tool2).toEqual(["Tool 2: 0.4 mm", "Tool 2: 0.4 mm", "Tool 2: 0.2 mm"]); // project, prepared, sliced
+ expect(screen.getByText("Project → Prepared copy")).toBeTruthy();
+ expect(screen.getByText("Prepared copy → Sliced G-code")).toBeTruthy();
+ });
+
+ it("a file that was not included is 'Not included', not 'Not stated'", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ render();
+ await act(async () => {});
+ expect(screen.getAllByText("Not included").length).toBe(4); // the prepared column of each setting
+ });
+
+ it("shows the project-level scope before and after the answer arrives", async () => {
+ let resolve!: (r: SettingsReceipt) => void;
+ vi.mocked(settingsReceipt).mockImplementation(() => new Promise((r) => { resolve = r; }));
+ render();
+ expect(screen.getByTestId("receipt-scope").textContent).toMatch(/per-plate and per-object overrides are not compared/);
+ expect(screen.getByRole("status").textContent).toBe("Comparing…");
+ await act(async () => resolve(MISMATCH));
+ expect(screen.getByTestId("receipt-scope").textContent).toBe("Compared as whole-project settings.");
+ });
+
+ it("a failed comparison can be tried again", async () => {
+ vi.mocked(settingsReceipt).mockRejectedValueOnce(new Error("down")).mockResolvedValue(MISMATCH);
+ render();
+ await act(async () => {});
+ expect(screen.getByRole("alert")).toBeTruthy();
+ await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Try again" })); });
+ expect(screen.getByText("Nozzle diameter per tool")).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("does not call the saved copy omitted while a new one is being prepared", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ render();
+ await act(async () => {});
+ expect(screen.getByTestId("receipt-preparing")).toBeTruthy();
+ expect(screen.queryByTestId("receipt-prepared-omitted")).toBeNull();
+ });
+
+ it("does not credit Studio with the prepared copy", async () => {
+ vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
+ render();
+ await act(async () => {});
+ expect(screen.queryByText(/what Studio prepared/)).toBeNull();
+ });
+
it("discloses a saved prepared copy that is not part of the comparison, and only then", async () => {
vi.mocked(settingsReceipt).mockResolvedValue(MISMATCH);
const { rerender } = render();
diff --git a/desktop/src/components/SettingsReceiptCard.tsx b/desktop/src/components/SettingsReceiptCard.tsx
index 8ddc5fe2..3805027d 100644
--- a/desktop/src/components/SettingsReceiptCard.tsx
+++ b/desktop/src/components/SettingsReceiptCard.tsx
@@ -1,27 +1,32 @@
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, HelpCircle, TriangleAlert } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
import { settingsReceipt } from "@/api";
-import type { ReceiptFact, SettingsReceipt } from "@/api";
-import { receiptKey, STATUS_LABEL, statusTone, stepTitle } from "@/lib/settingsReceipt";
+import type { ReceiptFact, ReceiptStage, SettingsReceipt } from "@/api";
+import { receiptKey, STAGE_LABEL, STATUS_LABEL, stageCell, statusTone, stepTitle } from "@/lib/settingsReceipt";
+
+const STAGES: ReceiptStage[] = ["original", "prepared", "sliced"];
/**
- * Settings receipt: what the project asked for, what Studio prepared, what the
- * slicer recorded.
+ * Settings receipt: the project's, the prepared copy's and the slice's declared values, side by side.
*
* Compares a few declared settings across the three files, per tool and in order,
* and says what to do about each difference. It is about settings only: whether
* the G-code belongs to the project, whether the profile fits the printer and
* whether the print will work are separate questions answered elsewhere.
*/
-export function SettingsReceiptCard({ original, prepared, sliced, revision = 0, preparedOmitted = false }: {
+export function SettingsReceiptCard({ original, prepared, sliced, revision = 0, preparedOmitted = false, preparing = false }: {
original: string; prepared: string | null; sliced: string | null; revision?: number;
/** A saved job has a prepared copy that is not part of this comparison. */
preparedOmitted?: boolean;
+ /** A new prepared copy is being made; it joins the comparison once Studio has checked it. */
+ preparing?: boolean;
}) {
const key = receiptKey(original, prepared, sliced, revision);
const [answer, setAnswer] = useState<{ key: string; receipt: SettingsReceipt } | null>(null);
const [failed, setFailed] = useState(null);
+ const [attempt, setAttempt] = useState(0);
const current = useRef(key);
current.current = key;
@@ -33,7 +38,7 @@ export function SettingsReceiptCard({ original, prepared, sliced, revision = 0,
() => { if (alive && current.current === key) setFailed(key); },
);
return () => { alive = false; };
- }, [key, original, prepared, sliced]);
+ }, [key, original, prepared, sliced, attempt]);
// Only an answer about the files that are open now is shown.
const receipt = answer && answer.key === key ? answer.receipt : null;
@@ -44,29 +49,39 @@ export function SettingsReceiptCard({ original, prepared, sliced, revision = 0,
Settings receipt
- What the project asked for, what Studio prepared, and what the slicer recorded.
+ The declared values in the project, the prepared copy and the sliced G-code, side by side.
+
+
+ {receipt ? receipt.scope_note : "Compared as whole-project settings; per-plate and per-object overrides are not compared."}
- {preparedOmitted && (
+ {preparing && (
+
+ A new prepared copy is being made. It joins this comparison once Studio has checked it.
+
+ )}
+ {preparedOmitted && !preparing && (
This job has a saved prepared copy that was not reopened, so this compares the project with the slice
directly. It cannot show whether the prepared copy changed anything.
)}
- {!receipt && failed !== key &&
Comparing…
}
+ {!receipt && failed !== key &&
Comparing…
}
{failed === key && (
-
Studio could not compare these files. Check that they are still where they were.
+
+
Studio could not compare these files. Check that they are still where they were.
diff --git a/desktop/src/routes/Dashboard.test.tsx b/desktop/src/routes/Dashboard.test.tsx
new file mode 100644
index 00000000..ef8249b1
--- /dev/null
+++ b/desktop/src/routes/Dashboard.test.tsx
@@ -0,0 +1,93 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue({ port: 1, token: "t" }) }));
+vi.mock("@/api", () => ({
+ library: vi.fn(), printerStatus: vi.fn().mockRejectedValue(new Error("offline")), demoReport: vi.fn(), jobCheck: vi.fn(),
+}));
+vi.mock("@/hooks/useOpenFile", () => ({ useOpenFile: () => vi.fn() }));
+vi.mock("@/components/Workflow", () => ({ Workflow: () => }));
+vi.mock("@/components/HeroMark", () => ({ HeroMark: () => }));
+vi.mock("@/components/DoctorsGrid", () => ({ DoctorsGrid: () => }));
+vi.mock("@/components/FirstPrintCard", () => ({ FirstPrintCard: () => }));
+vi.mock("@/components/IntelligenceReport", () => ({ IntelligenceReport: () => }));
+
+import * as api from "@/api";
+import Dashboard from "@/routes/Dashboard";
+import { useJob } from "@/store/job";
+import { useSession } from "@/store/session";
+
+const RECORD = {
+ schema: "job/1", savedAt: "2026-01-01T00:00:00.000Z",
+ original: { path: "C:\\work\\A.3mf", name: "A.3mf", fingerprint: { size_bytes: 1, sha256: "a".repeat(64) } },
+ prepared: null, sliced: null,
+};
+const PROJECT = { id: 1, name: "Bracket.3mf", source_path: "C:\\work\\Bracket.3mf", source_family: "orca", output_path: null, verdict: "READY", score: 90, filament_count: 2, last_action: "doctor", updated_at: null };
+
+const mount = async () => {
+ const view = render(
+
+
+ ,
+ );
+ await act(async () => {});
+ return view;
+};
+const before = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
+
+beforeEach(() => {
+ vi.mocked(api.library).mockResolvedValue([]);
+ useJob.setState({ record: null, phase: "idle", check: {}, error: null });
+ useSession.setState({ file: null });
+});
+afterEach(cleanup);
+
+describe("Home", () => {
+ it("empty: Open a project comes first and the empty state says what to do", async () => {
+ await mount();
+ const open = screen.getByRole("heading", { name: "Open a project" });
+ expect(before(open, screen.getByText("Recent projects"))).toBe(true);
+ expect(await screen.findByText("No projects yet")).toBeTruthy();
+ expect(screen.getByText(/Studio reads it on this computer/)).toBeTruthy();
+ });
+
+ it("with a saved job: open, then the explicit restore, then recent projects", async () => {
+ vi.mocked(api.library).mockResolvedValue([PROJECT] as any);
+ useJob.setState({ record: RECORD as any });
+ await mount();
+ const open = screen.getByRole("heading", { name: "Open a project" });
+ const restore = screen.getByText("Pick up your last job");
+ const recent = screen.getByText("Recent projects");
+ expect(before(open, restore)).toBe(true);
+ expect(before(restore, recent)).toBe(true);
+ expect(await screen.findByText("Bracket.3mf")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Check these files" })).toBeTruthy();
+ expect(screen.getByText(/Check first: Studio then lists exactly which files it will reopen/)).toBeTruthy();
+ });
+
+ it("restoring is never automatic: nothing is checked, opened or prepared until asked", async () => {
+ useJob.setState({ record: RECORD as any });
+ await mount();
+ expect(api.jobCheck).not.toHaveBeenCalled();
+ expect(useSession.getState().file).toBeNull();
+ });
+
+ it("a library that cannot load says so, with a retry, without losing the open-a-project action", async () => {
+ vi.mocked(api.library).mockRejectedValue(new Error("down"));
+ await mount();
+ expect(screen.getByRole("heading", { name: "Open a project" })).toBeTruthy();
+ expect(await screen.findByText(/Couldn’t load your library/)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /Retry/ })).toBeTruthy();
+ });
+
+ it("keeps every other Home surface, below the action", async () => {
+ await mount();
+ expect(before(screen.getByRole("heading", { name: "Open a project" }), screen.getByTestId("mock-DoctorsGrid"))).toBe(true);
+ expect(screen.getByTestId("mock-FirstPrintCard")).toBeTruthy();
+ expect(screen.getByText("Activity")).toBeTruthy();
+ });
+});
diff --git a/desktop/src/routes/Dashboard.tsx b/desktop/src/routes/Dashboard.tsx
index 9c841a3e..e29516d8 100644
--- a/desktop/src/routes/Dashboard.tsx
+++ b/desktop/src/routes/Dashboard.tsx
@@ -107,98 +107,27 @@ export default function Dashboard() {
return (
-
-
-
-
- The Intelligence Layer for Open 3D Printing
-
-
One place. Any file. Any printer. Catch print risks before slicing — your Project, Printer & Cost Doctors check every model before your U1 ever sees it. Local-first & open source.
- )}
-
- {/* Hero: open a model + the end-to-end workflow it flows through */}
+ {/* Open a project first: the one thing Home is for. */}
-
+
-
+
-
Open a model to begin
-
Drag in or browse for a .stl or .3mf · your file stays on your computer
+
Open a project
+
Choose a .stl or .3mf, or drop one on this window. Studio reads it on this computer.
- Open a model
+ Open a project
-
-
-
- {/* Beginner anchor: the whole first-print path in one card. */}
-
-
- {/* The Doctors — Studio's first-class, user-facing capabilities. */}
-
-
- {/* First-run "why Studio?" — only for a brand-new, empty library. */}
- {status === "success" && projects.length === 0 && (
-
-
-
Why Snapmaker Studio?
-
- Before you slice in Snapmaker Orca, Studio flags a model's print risks for your U1 — then keeps an eye on it while it does.
-
-
-
-
-
-
Understand & check
-
Size, colors, geometry health and a plain-language print-readiness score — before you waste filament.
-
-
-
-
-
-
Fix & prepare U1 copy
-
Prepares any STL or 3MF into a U1 profile copy. Your original is never changed.
-
-
-
-
-
-
Watch it print
-
Live temperatures, progress, cost and failure insights from your U1. Monitoring is read-only; send, pause, resume and cancel run only when you confirm.
) : status === "error" ? (
@@ -237,7 +166,7 @@ export default function Dashboard() {
- {p.name}
+ {p.name}
{p.verdict && }
@@ -254,12 +183,87 @@ export default function Dashboard() {
No projects yet
-
Open a model above — it’ll appear here once you check or prepare it.
+
Open a project above — it’ll appear here once you check or prepare it.
)}
+
+
+
+
+ The Intelligence Layer for Open 3D Printing
+
+
One place. Any file. Any printer. Catch print risks before slicing — your Project, Printer & Cost Doctors check every model before your U1 ever sees it. Local-first & open source.
+ )}
+
+
+
+
+
+
+
+
+ {/* Beginner anchor: the whole first-print path in one card. */}
+
+
+ {/* The Doctors — Studio's first-class, user-facing capabilities. */}
+
+
+ {/* First-run "why Studio?" — only for a brand-new, empty library. */}
+ {status === "success" && projects.length === 0 && (
+
+
+
Why Snapmaker Studio?
+
+ Before you slice in Snapmaker Orca, Studio flags a model's print risks for your U1 — then keeps an eye on it while it does.
+
+
+
+
+
+
Understand & check
+
Size, colors, geometry health and a plain-language print-readiness score — before you waste filament.
+
+
+
+
+
+
Fix & prepare U1 copy
+
Prepares any STL or 3MF into a U1 profile copy. Your original is never changed.
+
+
+
+
+
+
Watch it print
+
Live temperatures, progress, cost and failure insights from your U1. Monitoring is read-only; send, pause, resume and cancel run only when you confirm.
+
+
+
+
+
+ )}
+
From e602536676d271bd3871dd1d7ce7b069bd8477c4 Mon Sep 17 00:00:00 2001
From: DeadlyVirusIn
Date: Sun, 18 Oct 2026 16:15:27 -0400
Subject: [PATCH 13/24] fix(desktop): refresh review repairs - unvalidated
copy, receipt cell, drawer focus
Opus and Sol both approved 98ec3ff with notes; this addresses the converging ones.
- This job: a copy that was written but failed its own validation is no longer
shown as "Nothing prepared yet" / "Prepare a U1 copy". The Prepare step shows
needs review, the next action points at the workspace, the way-back ledger is
kept, and the copy is still not offered to Orca.
- A failed prepare request no longer claims "Nothing was written"; only a
refusal (blocked) does.
- Receipt: a read file keeps its value when the first transition touching it
could not be checked.
- Drawer: choosing a destination moves focus to the page, not .
- "Check again" is disabled while a prepare is in flight (shared request
generation would drop the result).
- Home action label is a span, not a heading inside a button.
Verified: tsc clean; vitest 1016 passed; build ok; source-independence 0
violations. This repair is not re-reviewed.
Co-Authored-By: Claude Sonnet 5.5
---
desktop/src/components/JobHeader.test.tsx | 9 +++++++-
desktop/src/components/JobHeader.tsx | 2 +-
.../src/components/shell/AppShell.test.tsx | 2 ++
desktop/src/components/shell/AppShell.tsx | 10 +++++++--
desktop/src/lib/jobProgress.test.ts | 17 ++++++++++++++
desktop/src/lib/jobProgress.ts | 22 +++++++++++++++++--
desktop/src/lib/settingsReceipt.ts | 4 +++-
desktop/src/lib/settingsReceiptCells.test.ts | 11 ++++++++++
desktop/src/routes/Cockpit.tsx | 17 ++++++++++++++
desktop/src/routes/Dashboard.test.tsx | 8 +++----
desktop/src/routes/Dashboard.tsx | 2 +-
11 files changed, 92 insertions(+), 12 deletions(-)
diff --git a/desktop/src/components/JobHeader.test.tsx b/desktop/src/components/JobHeader.test.tsx
index 4194a37d..fc9c8411 100644
--- a/desktop/src/components/JobHeader.test.tsx
+++ b/desktop/src/components/JobHeader.test.tsx
@@ -74,7 +74,8 @@ describe("the job header", () => {
it("an unvalidated copy is not offered to Orca", () => {
useSession.setState({ convert: { status: "done", error: null, data: { validated_ok: false, blocked: false, output_name: "x_U1.3mf" } } as any });
view();
- expect(screen.getByRole("status").textContent).not.toMatch(/Orca/);
+ expect(screen.getByRole("status").textContent).not.toMatch(/Open .* in Snapmaker Orca/);
+ expect(screen.queryByRole("button", { name: "Go to the Orca step" })).toBeNull();
});
it("a failed check can be retried", () => {
@@ -84,6 +85,12 @@ describe("the job header", () => {
expect(runDoctor).toHaveBeenCalledTimes(1);
});
+ it("does not offer a retry that would drop an in-flight prepare", () => {
+ useSession.setState({ doctor: { status: "error", data: null, error: "x" } as any, convert: { status: "loading", data: null, error: null } as any });
+ view();
+ expect((screen.getByRole("button", { name: "Check again" }) as HTMLButtonElement).disabled).toBe(true);
+ });
+
it("while checking it says so and offers no button", () => {
useSession.setState({ doctor: { status: "loading", data: null, error: null } as any });
view();
diff --git a/desktop/src/components/JobHeader.tsx b/desktop/src/components/JobHeader.tsx
index 257bc4db..b87dcef2 100644
--- a/desktop/src/components/JobHeader.tsx
+++ b/desktop/src/components/JobHeader.tsx
@@ -103,7 +103,7 @@ export function JobHeader() {
goTo(next.action!.target!)}>{next.action.label}
)}
{next.action?.kind === "retry" && (
- void runDoctor()}>{next.action.label}
+ void runDoctor()}>{next.action.label}
)}
diff --git a/desktop/src/components/shell/AppShell.test.tsx b/desktop/src/components/shell/AppShell.test.tsx
index bd4d8949..1e5cece0 100644
--- a/desktop/src/components/shell/AppShell.test.tsx
+++ b/desktop/src/components/shell/AppShell.test.tsx
@@ -75,6 +75,8 @@ describe("narrow navigation", () => {
act(() => { fireEvent.click(link); });
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("This job");
+ // The link that held focus is gone: focus follows the page, not .
+ expect(document.activeElement).toBe(screen.getByRole("main"));
});
it("Tab wraps inside the open drawer", () => {
diff --git a/desktop/src/components/shell/AppShell.tsx b/desktop/src/components/shell/AppShell.tsx
index cbf9bb7a..1adb72da 100644
--- a/desktop/src/components/shell/AppShell.tsx
+++ b/desktop/src/components/shell/AppShell.tsx
@@ -30,12 +30,18 @@ export function AppShell() {
const [menuOpen, setMenuOpen] = useState(false);
const menuButton = useRef(null);
const drawer = useRef(null);
+ const main = useRef(null);
+ const focusPage = useRef(false);
const closeMenu = useCallback((returnFocus = true) => {
setMenuOpen(false);
if (returnFocus) menuButton.current?.focus();
+ else focusPage.current = true; // chose a destination: focus goes to the page, not the Menu button
}, []);
// A navigation (or a wider window) ends the drawer; focus follows the page, not the button.
- useEffect(() => { setMenuOpen(false); }, [pathname]);
+ useEffect(() => {
+ setMenuOpen(false);
+ if (focusPage.current) { focusPage.current = false; main.current?.focus({ preventScroll: true }); }
+ }, [pathname]);
useEffect(() => {
if (!menuOpen) return;
// First real destination, not the scrim.
@@ -95,7 +101,7 @@ export function AppShell() {
)}
setMenuOpen((o) => !o)} />
-
+
diff --git a/desktop/src/lib/jobProgress.test.ts b/desktop/src/lib/jobProgress.test.ts
index 5683c9ac..981bf09f 100644
--- a/desktop/src/lib/jobProgress.test.ts
+++ b/desktop/src/lib/jobProgress.test.ts
@@ -81,6 +81,23 @@ describe("the one next action", () => {
expect(blocked.action?.label).not.toMatch(/^Prepare/);
});
+ it("a copy that was written but failed its own check is not \"nothing prepared\" and is not offered to Orca", () => {
+ const f = facts({ convert: { status: "done", validatedOk: false, blocked: false, outputName: "x_U1.3mf" } });
+ expect(states(f).prepare).toBe("review");
+ expect(states(f).slice).toBe("todo");
+ const next = nextAction(f);
+ expect(next.headline).toBe("A copy was written, but its check found issues");
+ expect(next.headline).not.toMatch(/Prepare a U1 copy|Orca/);
+ expect(next.action).toMatchObject({ to: "/workspace" });
+ });
+
+ it("a failed request does not claim that nothing was written; a refusal does", () => {
+ const failed = nextAction(facts({ convert: { status: "error" } }));
+ expect(failed.headline).toBe("Studio could not confirm a prepared copy");
+ expect(failed.detail).not.toMatch(/Nothing was written/);
+ expect(nextAction(facts({ convert: { status: "done", blocked: true } })).detail).toMatch(/Nothing was written/);
+ });
+
it("an open slice points at comparing it", () => {
expect(nextAction(facts({ hasSliced: true })).action).toMatchObject({ to: "/after-slicing" });
});
diff --git a/desktop/src/lib/jobProgress.ts b/desktop/src/lib/jobProgress.ts
index 27d7bf1f..75e2ead1 100644
--- a/desktop/src/lib/jobProgress.ts
+++ b/desktop/src/lib/jobProgress.ts
@@ -59,6 +59,9 @@ const problem = (verdict: string | null) => verdict !== null && verdict.toUpperC
export const preparedReady = (f: JobFacts) =>
f.convert.status === "done" && !f.convert.blocked && f.convert.validatedOk;
+/** A copy was written but its own validation did not pass: neither "prepared" nor "nothing happened". */
+export const unvalidated = (f: JobFacts) => f.convert.status === "done" && !f.convert.blocked && !f.convert.validatedOk;
+
export function deriveSteps(f: JobFacts): JobStep[] {
const open: StepState = f.hasProject ? "done" : "current";
@@ -74,6 +77,7 @@ export function deriveSteps(f: JobFacts): JobStep[] {
else if (preparedReady(f)) prepare = "done";
else if (f.convert.status === "loading") prepare = "current";
else if (f.convert.status === "error" || f.convert.blocked) prepare = "error";
+ else if (unvalidated(f)) prepare = "review";
else if (f.hasProject && (check === "done" || check === "review")) prepare = "current";
let slice: StepState = "todo";
@@ -128,13 +132,27 @@ export function nextAction(f: JobFacts): NextAction {
if (f.convert.status === "loading") {
return { headline: "Preparing a copy…", detail: "Your original is never changed.", action: null };
}
- if (f.convert.status === "error" || f.convert.blocked) {
+ if (f.convert.blocked) {
return {
- headline: "The copy could not be prepared",
+ headline: "Studio refused to prepare this project",
detail: "Nothing was written. Open the full checks to see why.",
action: { kind: "link", label: "Open the full checks", to: "/compatibility" },
};
}
+ if (f.convert.status === "error") {
+ return {
+ headline: "Studio could not confirm a prepared copy",
+ detail: "Nothing is offered to Orca. Open the workspace to see what happened and try again.",
+ action: { kind: "link", label: "Open the workspace", to: "/workspace" },
+ };
+ }
+ if (unvalidated(f)) {
+ return {
+ headline: "A copy was written, but its check found issues",
+ detail: "Studio did not confirm it, so it is not offered to Orca. Open the workspace to see what remains.",
+ action: { kind: "link", label: "Open the workspace", to: "/workspace" },
+ };
+ }
return {
headline: "Prepare a U1 copy",
detail: "Preparing writes a new copy and never touches your original.",
diff --git a/desktop/src/lib/settingsReceipt.ts b/desktop/src/lib/settingsReceipt.ts
index 41658cd1..08509c78 100644
--- a/desktop/src/lib/settingsReceipt.ts
+++ b/desktop/src/lib/settingsReceipt.ts
@@ -62,7 +62,9 @@ export function stageCell(receipt: SettingsReceipt, fact: ReceiptFact, stage: Re
const state = receipt.stages[stage].state;
if (state === "not_provided") return { kind: "not_included" };
if (state === "unreadable") return { kind: "not_checked" };
- const step = fact.steps.find((s) => s.from === stage || s.to === stage);
+ // A transition that was checked beats one that could not be: it may carry this file's value.
+ const touching = fact.steps.filter((s) => s.from === stage || s.to === stage);
+ const step = touching.find((s) => s.status !== "not_checked") ?? touching[0];
if (!step || step.status === "not_checked") return { kind: "not_checked" };
const value = step.from === stage ? step.from_value : step.to_value;
return value === null || value === undefined ? { kind: "not_stated" } : { kind: "value", lines: valueLines(fact.id, value) };
diff --git a/desktop/src/lib/settingsReceiptCells.test.ts b/desktop/src/lib/settingsReceiptCells.test.ts
index 9549d189..e2dd5460 100644
--- a/desktop/src/lib/settingsReceiptCells.test.ts
+++ b/desktop/src/lib/settingsReceiptCells.test.ts
@@ -52,6 +52,17 @@ describe("receipt cells keep changed, unchanged, missing and not checked apart",
expect(stageCell(receipt({}), skipped, "original")).toEqual({ kind: "not_checked" });
});
+ it("a read prepared copy keeps its value when the project could not be read", () => {
+ const f3 = fact("layer_height", [
+ step({ from: "original", to: "prepared", status: "not_checked" }),
+ step({ from: "prepared", to: "sliced", from_value: 0.16, to_value: 0.16, status: "unchanged" }),
+ ]);
+ const r = receipt({ original: "unreadable", prepared: "read" });
+ expect(stageCell(r, f3, "original")).toEqual({ kind: "not_checked" });
+ expect(stageCell(r, f3, "prepared")).toEqual({ kind: "value", lines: ["0.16 mm"] });
+ expect(stageCell(r, f3, "sliced")).toEqual({ kind: "value", lines: ["0.16 mm"] });
+ });
+
it("a receipt with no steps checks nothing", () => {
expect(stageCell(receipt({}), fact("supports", []), "original")).toEqual({ kind: "not_checked" });
});
diff --git a/desktop/src/routes/Cockpit.tsx b/desktop/src/routes/Cockpit.tsx
index 97e38a59..cf6f0366 100644
--- a/desktop/src/routes/Cockpit.tsx
+++ b/desktop/src/routes/Cockpit.tsx
@@ -38,6 +38,10 @@ export default function Cockpit() {
const prepared: string | undefined = convert.status === "done" && convert.data && !convert.data.blocked && convert.data.validated_ok
? (convert.data.output_path || convert.data.output) : undefined;
+ // A copy was written but its own validation did not pass: say so, keep the record of what was written.
+ const unvalidatedPath: string | undefined = convert.status === "done" && convert.data && !convert.data.blocked && !convert.data.validated_ok
+ ? (convert.data.output_path || convert.data.output) : undefined;
+
if (!project) {
return (