diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 06:05:15 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 06:05:15 +0800 |
| commit | 13479cd194cd992c67805093e59f8b1dd1b5f3a2 (patch) | |
| tree | 2918d1d408a65e694c49c081ce288566b8bcf5d5 | |
| parent | 822c092790bad4b1c0d780c1fafee063d70c91f6 (diff) | |
| download | somhairles-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.md | 14 | ||||
| -rw-r--r-- | public/App.js | 54 | ||||
| -rw-r--r-- | public/styles.css | 5 | ||||
| -rw-r--r-- | src/SomhairlesDream.Frontend/App.fs | 21 | ||||
| -rwxr-xr-x | tools/acceptance/e2e_acceptance.py | 89 |
5 files changed, 178 insertions, 5 deletions
@@ -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() |
