summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 06:05:15 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 06:05:15 +0800
commit13479cd194cd992c67805093e59f8b1dd1b5f3a2 (patch)
tree2918d1d408a65e694c49c081ce288566b8bcf5d5
parent822c092790bad4b1c0d780c1fafee063d70c91f6 (diff)
downloadsomhairles-dream-fsharp-13479cd194cd992c67805093e59f8b1dd1b5f3a2.tar.gz
feat(viewer): add user retry for failed step loads and regressions for same-run ordering and replay invalidation
-rw-r--r--README.md14
-rw-r--r--public/App.js54
-rw-r--r--public/styles.css5
-rw-r--r--src/SomhairlesDream.Frontend/App.fs21
-rwxr-xr-xtools/acceptance/e2e_acceptance.py89
5 files changed, 178 insertions, 5 deletions
diff --git a/README.md b/README.md
index 09f6d2a..470dc8b 100644
--- a/README.md
+++ b/README.md
@@ -188,16 +188,22 @@ dotnet run --project src/SomhairlesDream.Cli -- run \
reference run; a failure run (pre-existing run directory) shows status
`失败` with the error surfaced in `run-message`; zero console errors on
all pages.
-- Browser acceptance driver (see above), 53/53 checks green on desktop and
+- Browser acceptance driver (see above), 61/61 checks green on desktop and
narrow viewports: framing asserted numerically from the projected scene
bounding box (not by trusting whole-canvas hashes alone), frozen
screenshots byte-deterministic, GLB hash equality, failure surfacing,
zero console errors. A stale-load regression intercepts step GLB
- responses and fulfills them late, reordered, or with errors across six
+ responses and fulfills them late, reordered, or with errors across nine
runs, asserting via actual scene geometry (`THREE.Object3D` types/colors)
and the visible note that superseded runs' responses never repaint the
- viewport, same-path reruns still reload, run switches to empty state
- fall back to the replay frame, and failed current loads retry. The
+ viewport, same-path reruns still reload, and run switches to empty state
+ fall back to the replay frame. It also proves: a failed current
+ latest-step load (after run completion) shows a visible retry affordance
+ on the note and a user click re-requests the same owner/path without
+ SSE reconnect; within one run an older step resolving after the newer
+ step is ignored; and a live step response still pending after entering
+ replay never repaints the selected replay frame view. Held responses
+ are drained and the route handler removed before the page closes. The
frontend refits the camera to the loaded model
(`THREE.Box3`-based framing) whenever the canvas resizes, and a resize
never reverts the viewport to replay placeholder geometry.
diff --git a/public/App.js b/public/App.js
index 4b842d1..70d2377 100644
--- a/public/App.js
+++ b/public/App.js
@@ -92,6 +92,8 @@ export let loadedStep = createAtom(undefined);
export let viewMode = createAtom(new ViewMode(0, []));
+export let failedLoad = createAtom(undefined);
+
export let displayedNode = createAtom(undefined);
export let freezeRotation = createAtom(false);
@@ -218,6 +220,7 @@ export function clearGroup(group) {
export function invalidateLoads() {
loadGeneration(loadGeneration() + 1);
pendingLoad(undefined);
+ failedLoad(undefined);
}
export function renderFrame(frame) {
@@ -320,6 +323,16 @@ export function fallbackView() {
element("viewport-panel").classList.add("viewport-fallback");
}
+export function setNoteRetryable(retryable) {
+ const note = element("viewport-note");
+ if (retryable) {
+ note.classList.add("is-retryable");
+ }
+ else {
+ note.classList.remove("is-retryable");
+ }
+}
+
export function showFrameMetadata(frame) {
setText("mesh-label", frame.Label);
setText("timeline-current", toText(printf("V%02d · %s"))(frame.Version)(frame.Label));
@@ -343,6 +356,8 @@ export function startStepLoad(view, owner) {
loadGeneration(generation);
pendingLoad([generation, owner]);
viewMode(new ViewMode(1, []));
+ failedLoad(undefined);
+ setNoteRetryable(false);
const loader = new GLTFLoader();
const projectId = encodeURIComponent(owner.ProjectId);
const runId = encodeURIComponent(owner.RunId);
@@ -368,6 +383,8 @@ export function startStepLoad(view, owner) {
if (equals(pendingLoad(), [generation, owner])) {
pendingLoad(undefined);
loadedStep(undefined);
+ failedLoad(owner);
+ setNoteRetryable(true);
setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path));
}
}));
@@ -399,6 +416,40 @@ export function loadStepArtifact(view, stepPath) {
}
}
+export function retryFailedLoad() {
+ let view, owner;
+ const matchValue = viewState();
+ const matchValue_1 = failedLoad();
+ let matchResult, owner_1, view_1;
+ if (matchValue != null) {
+ if (matchValue_1 != null) {
+ if ((view = matchValue, (owner = matchValue_1, (owner.ProjectId === currentProjectId()) && (owner.RunId === currentRunId())))) {
+ matchResult = 0;
+ owner_1 = matchValue_1;
+ view_1 = matchValue;
+ }
+ else {
+ matchResult = 1;
+ }
+ }
+ else {
+ matchResult = 1;
+ }
+ }
+ else {
+ matchResult = 1;
+ }
+ switch (matchResult) {
+ case 0: {
+ loadStepArtifact(view_1, owner_1.Path);
+ break;
+ }
+ case 1: {
+ break;
+ }
+ }
+}
+
export function updateState(payload) {
let arg, arg_3;
setStatusClass(statusName(payload));
@@ -573,6 +624,9 @@ export function boot() {
element("replay-button").addEventListener("click", (_arg) => {
toggleReplay();
});
+ element("viewport-note").addEventListener("click", (_arg_1) => {
+ retryFailedLoad();
+ });
element("run-controls").addEventListener("submit", (event) => {
event.preventDefault();
startRun(selectedRun());
diff --git a/public/styles.css b/public/styles.css
index 0ee02ef..6346df5 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -690,3 +690,8 @@ button:focus-visible {
animation-iteration-count: 1 !important;
}
}
+
+.viewport-note.is-retryable {
+ cursor: pointer;
+ pointer-events: auto;
+}
diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs
index 6e07798..549b695 100644
--- a/src/SomhairlesDream.Frontend/App.fs
+++ b/src/SomhairlesDream.Frontend/App.fs
@@ -60,6 +60,7 @@ let mutable loadGeneration = 0
let mutable pendingLoad: (int * LoadOwner) option = None
let mutable loadedStep: LoadOwner option = None
let mutable viewMode = FrameMode
+let mutable failedLoad: LoadOwner option = None
let mutable displayedNode: ThreeNode option = None
let mutable freezeRotation = false
@@ -132,6 +133,7 @@ let clearGroup (group: ThreeNode) =
let invalidateLoads () =
loadGeneration <- loadGeneration + 1
pendingLoad <- None
+ failedLoad <- None
let renderFrame (frame: ReplayFrame) =
selectedFrame <- frame
@@ -242,6 +244,14 @@ let fallbackView () =
setText "viewport-note" "WebGL 视图待命 · 已保留重建数据"
(element "viewport-panel").classList.add("viewport-fallback")
+let setNoteRetryable (retryable: bool) =
+ let note = element "viewport-note"
+
+ if retryable then
+ note.classList.add("is-retryable")
+ else
+ note.classList.remove("is-retryable")
+
let showFrameMetadata (frame: ReplayFrame) =
setText "mesh-label" frame.Label
setText "timeline-current" (sprintf "V%02d · %s" frame.Version frame.Label)
@@ -262,6 +272,8 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) =
loadGeneration <- generation
pendingLoad <- Some(generation, owner)
viewMode <- StepMode
+ failedLoad <- None
+ setNoteRetryable false
let loader = createGLTFLoader ()
let projectId = JS.encodeURIComponent owner.ProjectId
let runId = JS.encodeURIComponent owner.RunId
@@ -285,6 +297,8 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) =
if pendingLoad = Some(generation, owner) then
pendingLoad <- None
loadedStep <- None
+ failedLoad <- Some owner
+ setNoteRetryable true
setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path))
let loadStepArtifact (view: ViewState) (stepPath: string) =
@@ -297,6 +311,12 @@ let loadStepArtifact (view: ViewState) (stepPath: string) =
| Some (_, pending) when pending = owner -> ()
| _ -> startStepLoad view owner
+let retryFailedLoad () =
+ match viewState, failedLoad with
+ | Some view, Some owner when owner.ProjectId = currentProjectId && owner.RunId = currentRunId ->
+ loadStepArtifact view owner.Path
+ | _ -> ()
+
let updateState (payload: SnapshotPayload) =
let status = statusName payload
setStatusClass status
@@ -474,6 +494,7 @@ let boot () =
fallbackView ()
(element "replay-button").addEventListener("click", fun _ -> toggleReplay ())
+ (element "viewport-note").addEventListener("click", fun _ -> retryFailedLoad ())
(element "run-controls").addEventListener("submit", fun event ->
event.preventDefault()
startRun (selectedRun ()))
diff --git a/tools/acceptance/e2e_acceptance.py b/tools/acceptance/e2e_acceptance.py
index ee75ee6..1b3ae6c 100755
--- a/tools/acceptance/e2e_acceptance.py
+++ b/tools/acceptance/e2e_acceptance.py
@@ -253,7 +253,12 @@ def stale_load_regression(browser, base_url, track, results, evidence):
Intercepts steps GLB requests and fulfills them late, out of order, or
with errors to prove stale responses from superseded runs never repaint
- geometry or notes, while the current owner's loads keep working.
+ geometry or notes, while the current owner's loads keep working. Also
+ covers: user retry of a failed current latest-step load after run
+ completion (same owner/path, no SSE reconnect), same-run older-step
+ responses resolving after the newer step, and pending live responses
+ ignored after entering replay. All still-held responses are drained and
+ the route handler is removed before the page closes.
"""
page = browser.new_page(viewport={"width": 1280, "height": 900})
@@ -403,6 +408,88 @@ def stale_load_regression(browser, base_url, track, results, evidence):
"stale-load: current owner load unaffected by stale error"))
(evidence / "stale-scene-final.json").write_text(json.dumps(g5, indent=2, ensure_ascii=False))
page.screenshot(path=str(evidence / "stale-page-final.png"), full_page=True)
+
+ # Scenario D: current latest-step failure after run completion retries the
+ # same owner/path via the visible note affordance, without new SSE events.
+ run_k = f"run-stale-{stamp}-k"
+ start_run(run_k)
+ release(held_for(run_k, "steps/01-foundation.glb"))
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ ek3 = held_for(run_k, "steps/03-cabin.glb")
+ wait_text(page, "#status-label", "已完成")
+ release(ek3, status=500)
+ wait_text(page, "#viewport-note", "管线几何加载失败 · steps/03-cabin.glb")
+ cursor = page.evaluate(
+ "() => getComputedStyle(document.querySelector('#viewport-note')).cursor")
+ results.append((cursor == "pointer",
+ f"stale-load: failed current load exposes retry affordance (cursor={cursor})"))
+ page.screenshot(path=str(evidence / "stale-page-retry-failed.png"), full_page=True)
+ page.click("#viewport-note")
+ ek4 = held_for(run_k, "steps/03-cabin.glb")
+ retries = [e for e in held if e["run"] == run_k and e["path"] == "steps/03-cabin.glb"]
+ results.append((len(retries) == 2 and ek4 is retries[1],
+ f"stale-load: user retry re-requests same owner/path ({len(retries)} requests)"))
+ stream = page.evaluate("() => document.querySelector('#stream-state').textContent")
+ results.append(("已连接" in stream,
+ f"stale-load: retry works without SSE reconnect (stream='{stream[:30]}')"))
+ release(ek4)
+ wait_text(page, "#viewport-note", "管线几何 · steps/03-cabin.glb")
+ page.wait_for_timeout(600)
+ gk = page.evaluate(SCENE_JS)
+ cursor_after = page.evaluate(
+ "() => getComputedStyle(document.querySelector('#viewport-note')).cursor")
+ results.append((gk["ready"] and is_glb(gk) and box_ok(gk) and cursor_after != "pointer",
+ f"stale-load: retry displays latest step and clears affordance "
+ f"(kids={gk.get('kids')})"))
+ page.screenshot(path=str(evidence / "stale-page-retry-ok.png"), full_page=True)
+
+ # Scenario E: same-run older step resolving after the newer step must be ignored.
+ run_h = f"run-stale-{stamp}-h"
+ start_run(run_h)
+ held_for(run_h, "steps/01-foundation.glb")
+ eh3 = held_for(run_h, "steps/03-cabin.glb")
+ release(eh3)
+ wait_text(page, "#viewport-note", "管线几何 · steps/03-cabin.glb")
+ page.wait_for_timeout(600)
+ gh = page.evaluate(SCENE_JS)
+ results.append((gh["ready"] and is_glb(gh) and box_ok(gh)
+ and "steps/03-cabin.glb" in gh["note"],
+ f"stale-load: newer same-run step displayed after supersede "
+ f"(kids={gh.get('kids')})"))
+ note_h = gh["note"]
+ release(held_for(run_h, "steps/01-foundation.glb"))
+ page.wait_for_timeout(600)
+ gh2 = page.evaluate(SCENE_JS)
+ results.append((gh2["ready"] and gh2["kids"] == gh["kids"] and gh2["note"] == note_h,
+ f"stale-load: late older same-run step ignored (note='{gh2['note'][:40]}')"))
+
+ # Scenario F: live GLB response still pending after entering replay must
+ # not repaint the selected replay frame view.
+ run_j = f"run-stale-{stamp}-j"
+ start_run(run_j)
+ release(held_for(run_j, "steps/01-foundation.glb"))
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ ej3 = held_for(run_j, "steps/03-cabin.glb")
+ wait_text(page, "#status-label", "已完成")
+ page.click("#replay-button")
+ page.wait_for_timeout(300)
+ page.click("#replay-button")
+ page.wait_for_timeout(300)
+ gj = page.evaluate(SCENE_JS)
+ results.append((gj["ready"] and is_frame(gj) and box_ok(gj),
+ f"stale-load: replay entry repaints frame view while GLB pending "
+ f"(kids={gj.get('kids')})"))
+ page.screenshot(path=str(evidence / "stale-page-replay.png"), full_page=True)
+ release(ej3)
+ page.wait_for_timeout(600)
+ gj2 = page.evaluate(SCENE_JS)
+ results.append((gj2["ready"] and gj2["kids"] == gj["kids"] and gj2["note"] == gj["note"],
+ f"stale-load: live GLB after replay ignored (note='{gj2['note'][:40]}')"))
+
+ for entry in held:
+ if not entry["done"]:
+ release(entry)
+ page.unroute("**/api/artifacts/steps*")
page.close()