diff options
| -rw-r--r-- | README.md | 28 | ||||
| -rw-r--r-- | public/App.js | 382 | ||||
| -rw-r--r-- | public/index.html | 17 | ||||
| -rw-r--r-- | public/styles.css | 30 | ||||
| -rw-r--r-- | src/SomhairlesDream.Frontend/App.fs | 287 | ||||
| -rw-r--r-- | src/SomhairlesDream.Frontend/Bindings.fs | 3 | ||||
| -rwxr-xr-x | tools/acceptance/e2e_acceptance.py | 197 |
7 files changed, 823 insertions, 121 deletions
@@ -188,7 +188,7 @@ 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), 61/61 checks green on desktop and +- Browser acceptance driver (see above), 71/71 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, @@ -196,16 +196,24 @@ dotnet run --project src/SomhairlesDream.Cli -- run \ 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, 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 + 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. A historical + replay regression pins a real checkpoint mid-run by clicking its + timeline node (the request carries the run's own projectId/runId/path), + keeps the pin through later live snapshots (no auto-follow reloads), + returns to live scope via 跟随最新 with the newest checkpoint, ignores a + late older historical response after a newer selection, and asserts + both the served checkpoint identity (sha256 equals the CLI reference + GLB) and that selecting a different version changes the actual viewport + geometry (THREE.Box3 rel diff ≥ 0.05). 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. - NOT verified: production deployment of the old site remains untouched; - `--render` PNG renders; replay button flow against a historical run. + `--render` PNG renders. diff --git a/public/App.js b/public/App.js index 70d2377..ab4ae59 100644 --- a/public/App.js +++ b/public/App.js @@ -1,9 +1,9 @@ import { Union, Record } from "./fable_modules/fable-library-js.4.24.0/Types.js"; import { union_type, class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js"; import { Replay_frames } from "./SomhairlesDream.Shared/Replay.js"; -import { equals, createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js"; -import { tryFind, item } from "./fable_modules/fable-library-js.4.24.0/Array.js"; -import { isNullOrWhiteSpace, printf, toText } from "./fable_modules/fable-library-js.4.24.0/String.js"; +import { defaultOf, equals, createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js"; +import { equalsWith, tryFind, iterateIndexed, item } from "./fable_modules/fable-library-js.4.24.0/Array.js"; +import { join, isNullOrWhiteSpace, printf, toText } from "./fable_modules/fable-library-js.4.24.0/String.js"; import { MeshData_triangleIndices, MeshData_positionData } from "./SomhairlesDream.Shared/Domain.js"; import { min, max } from "./fable_modules/fable-library-js.4.24.0/Double.js"; import { defaultArg } from "./fable_modules/fable-library-js.4.24.0/Option.js"; @@ -84,6 +84,10 @@ export let currentProjectId = createAtom(""); export let currentRunId = createAtom(""); +export let historicalSteps = createAtom([]); + +export let pinnedIndex = createAtom(undefined); + export let loadGeneration = createAtom(0); export let pendingLoad = createAtom(undefined); @@ -333,9 +337,34 @@ export function setNoteRetryable(retryable) { } } +export function updateFollowButton() { + if ((historicalSteps().length > 0) && (pinnedIndex() != null)) { + element("follow-button").removeAttribute("disabled"); + setText("follow-button", "跟随最新"); + } + else { + element("follow-button").setAttribute("disabled", "disabled"); + if (historicalSteps().length > 0) { + setText("follow-button", "跟随最新 · 已开启"); + } + else { + setText("follow-button", "跟随最新"); + } + } +} + +export function stopReplay() { + if (replayTimer() == null) { + } + else { + clearInterval(replayTimer()); + replayTimer(undefined); + } +} + export function showFrameMetadata(frame) { - setText("mesh-label", frame.Label); - setText("timeline-current", toText(printf("V%02d · %s"))(frame.Version)(frame.Label)); + setText("mesh-label", toText(printf("占位 · %s"))(frame.Label)); + setText("timeline-current", toText(printf("V%02d · 占位 · %s"))(frame.Version)(frame.Label)); } export function setManifestLink(payload) { @@ -351,7 +380,7 @@ export function setManifestLink(payload) { } } -export function startStepLoad(view, owner) { +export function startStepLoad(view, owner, historical) { const generation = (loadGeneration() + 1) | 0; loadGeneration(generation); pendingLoad([generation, owner]); @@ -364,7 +393,12 @@ export function startStepLoad(view, owner) { let url; const arg_2 = encodeURIComponent(owner.Path); url = toText(printf("/api/artifacts/steps?projectId=%s&runId=%s&path=%s"))(projectId)(runId)(arg_2); - setText("viewport-note", toText(printf("管线几何加载中 · %s"))(owner.Path)); + if (historical) { + setText("viewport-note", toText(printf("历史几何加载中 · %s"))(owner.Path)); + } + else { + setText("viewport-note", toText(printf("管线几何加载中 · %s"))(owner.Path)); + } loader.load(url, ((gltf) => { if (equals(pendingLoad(), [generation, owner])) { pendingLoad(undefined); @@ -374,7 +408,12 @@ export function startStepLoad(view, owner) { loadedStep(owner); frameObject(view, gltf.scene); view.Renderer.render(view.Scene, view.Camera); - setText("viewport-note", toText(printf("管线几何 · %s"))(owner.Path)); + if (historical) { + setText("viewport-note", toText(printf("历史几何 · %s"))(owner.Path)); + } + else { + setText("viewport-note", toText(printf("管线几何 · %s"))(owner.Path)); + } } else { gltf.scene.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); }); @@ -383,14 +422,19 @@ export function startStepLoad(view, owner) { if (equals(pendingLoad(), [generation, owner])) { pendingLoad(undefined); loadedStep(undefined); - failedLoad(owner); + failedLoad([owner, historical]); setNoteRetryable(true); - setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path)); + if (historical) { + setText("viewport-note", toText(printf("历史几何加载失败 · %s"))(owner.Path)); + } + else { + setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path)); + } } })); } -export function loadStepArtifact(view, stepPath) { +export function loadStepArtifact(view, stepPath, historical) { const owner = new LoadOwner(currentProjectId(), currentRunId(), stepPath); let matchResult, pending_1; if (pendingLoad() != null) { @@ -410,22 +454,23 @@ export function loadStepArtifact(view, stepPath) { break; } case 1: { - startStepLoad(view, owner); + startStepLoad(view, owner, historical); break; } } } export function retryFailedLoad() { - let view, owner; + let view, owner, historical; const matchValue = viewState(); const matchValue_1 = failedLoad(); - let matchResult, owner_1, view_1; + let matchResult, historical_1, owner_1, view_1; if (matchValue != null) { if (matchValue_1 != null) { - if ((view = matchValue, (owner = matchValue_1, (owner.ProjectId === currentProjectId()) && (owner.RunId === currentRunId())))) { + if ((view = matchValue, (owner = matchValue_1[0], (historical = matchValue_1[1], (owner.ProjectId === currentProjectId()) && (owner.RunId === currentRunId()))))) { matchResult = 0; - owner_1 = matchValue_1; + historical_1 = matchValue_1[1]; + owner_1 = matchValue_1[0]; view_1 = matchValue; } else { @@ -441,7 +486,147 @@ export function retryFailedLoad() { } switch (matchResult) { case 0: { - loadStepArtifact(view_1, owner_1.Path); + loadStepArtifact(view_1, owner_1.Path, historical_1); + break; + } + case 1: { + break; + } + } +} + +export function applyHistoricalStep(index, duringPlayback) { + let arg_5, view; + let matchResult, view_1; + if (viewState() != null) { + if ((view = viewState(), (index >= 0) && (index < historicalSteps().length))) { + matchResult = 0; + view_1 = viewState(); + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + pinnedIndex(index); + const path = item(index, historicalSteps()); + const version = (index + 1) | 0; + setText("mesh-label", toText(printf("历史 · %s"))(path)); + setText("timeline-current", toText(printf("V%02d · 历史回放 · %s"))(version)(path)); + loadStepArtifact(view_1, path, true); + if (duringPlayback) { + setText("replay-state", (arg_5 = (historicalSteps().length | 0), toText(printf("历史回放中 · V%02d (%d/%d)"))(version)(version)(arg_5))); + } + else { + setText("replay-state", toText(printf("历史回放 · 已固定 V%02d"))(version)); + } + break; + } + case 1: { + break; + } + } +} + +export function renderTimeline() { + let arg; + const track = element("timeline-track"); + var child; while ((child = track.firstChild)) { track.removeChild(child); }; + const line = document.createElement("span"); + line.className = "timeline-line"; + line.setAttribute("aria-hidden", "true"); + track.appendChild(line); + if (historicalSteps().length > 0) { + track.setAttribute("style", (arg = (historicalSteps().length | 0), toText(printf("grid-template-columns: repeat(%d, 1fr)"))(arg))); + iterateIndexed((i, path) => { + const active = (pinnedIndex() == null) ? (i === (historicalSteps().length - 1)) : (pinnedIndex() === i); + track.appendChild(createStepNode(i + 1, path, active, true, i)); + }, historicalSteps()); + } + else { + track.setAttribute("style", "grid-template-columns: repeat(3, 1fr)"); + frames.forEach((frame) => { + track.appendChild(createStepNode(frame.Version, toText(printf("占位 · %s"))(frame.Label), false, frame.Version < frames.length, undefined)); + }); + } +} + +export function selectStep(index) { + if ((index >= 0) && (index < historicalSteps().length)) { + stopReplay(); + applyHistoricalStep(index, false); + updateFollowButton(); + renderTimeline(); + } +} + +export function createStepNode(version, label, active, complete, onClickIndex) { + const classes = ["timeline-step"]; + if (complete) { + void (classes.push("timeline-step-complete")); + } + if (onClickIndex != null) { + void (classes.push("timeline-step-real")); + } + if (active) { + void (classes.push("timeline-step-active")); + } + const step = document.createElement("div"); + step.className = join(" ", classes); + const dot = document.createElement("span"); + dot.className = "timeline-dot"; + dot.setAttribute("aria-hidden", "true"); + step.appendChild(dot); + const versionLabel = document.createElement("span"); + versionLabel.className = "timeline-version"; + versionLabel.textContent = toText(printf("V%02d"))(version); + step.appendChild(versionLabel); + const text = document.createElement("span"); + text.className = "timeline-label"; + text.textContent = label; + step.appendChild(text); + if (onClickIndex == null) { + } + else { + const index = onClickIndex | 0; + step.addEventListener("click", (_arg) => { + selectStep(index); + }); + } + return step; +} + +export function followLatestStep() { + let view; + let matchResult, view_1; + if (viewState() != null) { + if ((view = viewState(), historicalSteps().length > 0)) { + matchResult = 0; + view_1 = viewState(); + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + stopReplay(); + pinnedIndex(undefined); + const total = historicalSteps().length | 0; + const latest = item(total - 1, historicalSteps()); + loadStepArtifact(view_1, latest, false); + setText("mesh-label", latest); + setText("timeline-current", toText(printf("V%02d · 实时跟随 · %s"))(total)(latest)); + setText("replay-state", toText(printf("实时跟随 · 最新 V%02d"))(total)); + updateFollowButton(); + renderTimeline(); break; } case 1: { @@ -451,7 +636,7 @@ export function retryFailedLoad() { } export function updateState(payload) { - let arg, arg_3; + let arg, arg_3, testExpr, arg_5, arg_7; setStatusClass(statusName(payload)); setText("status-label", statusLabel(payload)); setText("run-message", ((payload.status === "failed") && !isNullOrWhiteSpace(payload.error)) ? payload.error : payload.message); @@ -460,6 +645,12 @@ export function updateState(payload) { if ((currentProjectId() !== payload.projectId) ? true : (currentRunId() !== payload.runId)) { invalidateLoads(); displayedNode(undefined); + stopReplay(); + pinnedIndex(undefined); + historicalSteps([]); + setText("replay-button", "占位演示"); + setText("replay-state", "占位示例 · 未选择历史"); + updateFollowButton(); } currentProjectId(payload.projectId); currentRunId(payload.runId); @@ -475,28 +666,65 @@ export function updateState(payload) { const completedSteps = (payload.completedSteps == null) ? [] : payload.completedSteps; const matchValue = viewState(); if (matchValue == null) { - const steps_1 = completedSteps; - if (steps_1.length > 0) { - setText("mesh-label", item(steps_1.length - 1, steps_1)); + if (completedSteps.length > 0) { + setText("mesh-label", item(completedSteps.length - 1, completedSteps)); } else { showFrameMetadata(frame); } } - else { + else if ((testExpr = completedSteps, !equalsWith((x, y) => (x === y), testExpr, defaultOf()) && (testExpr.length === 0))) { const view = matchValue; - const steps = completedSteps; - if (steps.length === 0) { - showFrameMetadata(frame); - renderFrame(frame); + if (historicalSteps().length > 0) { + stopReplay(); + historicalSteps([]); + pinnedIndex(undefined); + setText("replay-button", "占位演示"); + renderTimeline(); + updateFollowButton(); } - else { - const latest = item(steps.length - 1, steps); + showFrameMetadata(frame); + renderFrame(frame); + } + else { + const view_1 = matchValue; + if (!equalsWith((x_1, y_1) => (x_1 === y_1), completedSteps, historicalSteps())) { + if (historicalSteps().length === 0) { + stopReplay(); + setText("replay-button", "播放历史"); + } + historicalSteps(completedSteps); + let matchResult, pinned_1; + if (pinnedIndex() != null) { + if (pinnedIndex() >= completedSteps.length) { + matchResult = 0; + pinned_1 = pinnedIndex(); + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + pinnedIndex(undefined); + break; + } + } + renderTimeline(); + updateFollowButton(); + } + if (pinnedIndex() == null) { + const latest = item(completedSteps.length - 1, completedSteps); const owner = new LoadOwner(payload.projectId, payload.runId, latest); if (!equals(loadedStep(), owner)) { - loadStepArtifact(view, latest); + loadStepArtifact(view_1, latest, false); } setText("mesh-label", latest); + setText("timeline-current", (arg_5 = (completedSteps.length | 0), toText(printf("V%02d · 实时跟随 · %s"))(arg_5)(latest))); + setText("replay-state", (arg_7 = (completedSteps.length | 0), toText(printf("实时跟随 · 最新 V%02d"))(arg_7))); } } } @@ -505,38 +733,78 @@ export function updateStreamState(message) { setText("stream-state", message); } -export function toggleReplay() { - if (replayTimer() == null) { - replayIndex(0); - setText("replay-button", "暂停重建"); - setText("replay-state", "重建中 · 几何逐帧替换"); - const tick = () => { - if (replayIndex() >= frames.length) { - if (replayTimer() == null) { - } - else { - clearInterval(replayTimer()); - } - replayTimer(undefined); - setText("replay-button", "重建演示"); - setText("replay-state", "重建完成 · 几何已更新"); +export function startPlaceholderPlayback() { + replayIndex(0); + setText("replay-button", "暂停占位演示"); + setText("replay-state", "占位演示中 · 未选择历史"); + const tick = () => { + if (replayIndex() >= frames.length) { + stopReplay(); + setText("replay-button", "占位演示"); + setText("replay-state", "占位演示完成 · 未选择历史"); + } + else { + const frame = item(replayIndex(), frames); + showFrameMetadata(frame); + renderFrame(frame); + setText("replay-state", toText(printf("占位演示中 · V%02d %s"))(frame.Version)(frame.Label)); + replayIndex(replayIndex() + 1); + } + }; + tick(); + replayTimer(setInterval(tick, 900)); +} + +export function startHistoricalPlayback() { + if (historicalSteps().length > 0) { + setText("replay-button", "暂停历史回放"); + applyHistoricalStep(0, true); + updateFollowButton(); + renderTimeline(); + const total = historicalSteps().length | 0; + replayTimer(setInterval(() => { + let arg; + if (pinnedIndex() == null) { + stopReplay(); + setText("replay-button", "播放历史"); + } + else if ((pinnedIndex() + 1) < total) { + const pinned_1 = pinnedIndex() | 0; + applyHistoricalStep(pinned_1 + 1, true); + renderTimeline(); } else { - const frame = item(replayIndex(), frames); - showFrameMetadata(frame); - renderFrame(frame); - setText("replay-state", toText(printf("重建中 · V%02d %s"))(frame.Version)(frame.Label)); - replayIndex(replayIndex() + 1); + const pinned_2 = pinnedIndex() | 0; + stopReplay(); + setText("replay-button", "播放历史"); + setText("replay-state", (arg = ((pinned_2 + 1) | 0), toText(printf("历史回放完成 · 停在 V%02d"))(arg))); } - }; - tick(); - replayTimer(setInterval(tick, 900)); + }, 1500)); + } +} + +export function toggleReplay() { + let arg; + if (replayTimer() == null) { + if (historicalSteps().length > 0) { + startHistoricalPlayback(); + } + else { + startPlaceholderPlayback(); + } } else { clearInterval(replayTimer()); replayTimer(undefined); - setText("replay-button", "重建演示"); - setText("replay-state", "演示已暂停"); + if (historicalSteps().length > 0) { + const current = defaultArg(pinnedIndex(), 0) | 0; + setText("replay-button", "播放历史"); + setText("replay-state", (arg = ((current + 1) | 0), toText(printf("历史回放 · 已暂停 · V%02d"))(arg))); + } + else { + setText("replay-button", "占位演示"); + setText("replay-state", "占位演示 · 已暂停"); + } } } @@ -624,13 +892,17 @@ export function boot() { element("replay-button").addEventListener("click", (_arg) => { toggleReplay(); }); - element("viewport-note").addEventListener("click", (_arg_1) => { + element("follow-button").addEventListener("click", (_arg_1) => { + followLatestStep(); + }); + element("viewport-note").addEventListener("click", (_arg_2) => { retryFailedLoad(); }); element("run-controls").addEventListener("submit", (event) => { event.preventDefault(); startRun(selectedRun()); }); + renderTimeline(); } boot(); diff --git a/public/index.html b/public/index.html index 2acf08e..71e9e62 100644 --- a/public/index.html +++ b/public/index.html @@ -162,29 +162,32 @@ <span class="eyebrow">RECONSTRUCTION TIMELINE</span> <h2 id="timeline-title">几何版本轨迹</h2> </div> - <span id="replay-state" class="replay-state">可回放 · 三个确定性快照</span> + <span id="replay-state" class="replay-state">占位示例 · 未选择历史</span> </div> - <div class="timeline-track" aria-label="几何版本"> + <div class="timeline-track" id="timeline-track" aria-label="几何版本"> <span class="timeline-line" aria-hidden="true"></span> <div class="timeline-step timeline-step-complete"> <span class="timeline-dot" aria-hidden="true"></span> <span class="timeline-version">V01</span> - <span class="timeline-label">重建起点</span> + <span class="timeline-label">占位 · 重建起点</span> </div> <div class="timeline-step timeline-step-complete"> <span class="timeline-dot" aria-hidden="true"></span> <span class="timeline-version">V02</span> - <span class="timeline-label">主承力骨架</span> + <span class="timeline-label">占位 · 主承力骨架</span> </div> <div class="timeline-step"> <span class="timeline-dot" aria-hidden="true"></span> <span class="timeline-version">V03</span> - <span class="timeline-label">舱段重构</span> + <span class="timeline-label">占位 · 舱段重构</span> </div> </div> <div class="timeline-footer"> - <span id="timeline-current">V01 · 重建起点</span> - <button id="replay-button" class="replay-button" type="button">重建演示</button> + <span id="timeline-current">V01 · 占位 · 重建起点</span> + <div class="timeline-actions"> + <button id="follow-button" class="replay-button replay-button-ghost" type="button" disabled>跟随最新</button> + <button id="replay-button" class="replay-button" type="button">占位演示</button> + </div> </div> </section> diff --git a/public/styles.css b/public/styles.css index 6346df5..a925e58 100644 --- a/public/styles.css +++ b/public/styles.css @@ -540,6 +540,31 @@ button:focus-visible { background: var(--gold); } +.timeline-step-real { + cursor: pointer; +} + +.timeline-step-real:hover .timeline-version { + color: var(--ivory); +} + +.timeline-step-active .timeline-dot { + border-color: var(--ivory); + box-shadow: 0 0 0 3px rgba(215, 169, 72, 0.35); +} + +#follow-button[disabled] { + border-color: var(--line-bright); + color: var(--muted); + cursor: default; + opacity: 0.45; +} + +#follow-button[disabled]:hover { + background: transparent; + color: var(--muted); +} + .timeline-version { color: var(--gold-soft); font-size: 0.68rem; @@ -557,6 +582,11 @@ button:focus-visible { font-size: 0.68rem; } +.timeline-actions { + display: flex; + gap: 10px; +} + .replay-button { min-height: 44px; padding: 8px 16px; diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs index 549b695..b2bb8e6 100644 --- a/src/SomhairlesDream.Frontend/App.fs +++ b/src/SomhairlesDream.Frontend/App.fs @@ -54,13 +54,21 @@ let mutable replayIndex = 0 let mutable eventSource: EventSource option = None let mutable currentProjectId = "" let mutable currentRunId = "" +/// Checkpoint artifact paths published by the currently selected run. Empty +/// when no real history exists and the placeholder fallback is shown. +let mutable historicalSteps: string array = [||] +/// Index into historicalSteps of the user-pinned historical checkpoint. +/// None means the viewport follows the latest published checkpoint. +let mutable pinnedIndex: int option = None /// Bumped on every new step request and on every invalidation; only the /// response matching the latest generation may repaint the viewport. 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 +/// Last failed step load plus whether it was a historical (replay) load, so +/// the retry affordance re-requests the same owner in the same scope. +let mutable failedLoad: (LoadOwner * bool) option = None let mutable displayedNode: ThreeNode option = None let mutable freezeRotation = false @@ -252,9 +260,28 @@ let setNoteRetryable (retryable: bool) = else note.classList.remove("is-retryable") +let updateFollowButton () = + if Array.length historicalSteps > 0 && pinnedIndex.IsSome then + (element "follow-button").removeAttribute("disabled") + setText "follow-button" "跟随最新" + else + (element "follow-button").setAttribute("disabled", "disabled") + + if Array.length historicalSteps > 0 then + setText "follow-button" "跟随最新 · 已开启" + else + setText "follow-button" "跟随最新" + +let stopReplay () = + match replayTimer with + | Some handle -> + clearInterval handle + replayTimer <- None + | None -> () + let showFrameMetadata (frame: ReplayFrame) = - setText "mesh-label" frame.Label - setText "timeline-current" (sprintf "V%02d · %s" frame.Version frame.Label) + setText "mesh-label" (sprintf "占位 · %s" frame.Label) + setText "timeline-current" (sprintf "V%02d · 占位 · %s" frame.Version frame.Label) let setManifestLink (payload: SnapshotPayload) = let link = element "artifact-manifest-link" @@ -267,7 +294,7 @@ let setManifestLink (payload: SnapshotPayload) = link.setAttribute("href", value) link.classList.remove("is-hidden") -let startStepLoad (view: ViewState) (owner: LoadOwner) = +let startStepLoad (view: ViewState) (owner: LoadOwner) (historical: bool) = let generation = loadGeneration + 1 loadGeneration <- generation pendingLoad <- Some(generation, owner) @@ -278,7 +305,11 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) = let projectId = JS.encodeURIComponent owner.ProjectId let runId = JS.encodeURIComponent owner.RunId let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent owner.Path) - setText "viewport-note" (sprintf "管线几何加载中 · %s" owner.Path) + + if historical then + setText "viewport-note" (sprintf "历史几何加载中 · %s" owner.Path) + else + setText "viewport-note" (sprintf "管线几何加载中 · %s" owner.Path) loadGLTF loader url (fun gltf -> @@ -290,18 +321,26 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) = loadedStep <- Some owner frameObject view gltf.scene view.Renderer.render(view.Scene, view.Camera) - setText "viewport-note" (sprintf "管线几何 · %s" owner.Path) + + if historical then + setText "viewport-note" (sprintf "历史几何 · %s" owner.Path) + else + setText "viewport-note" (sprintf "管线几何 · %s" owner.Path) else disposeNodeResources gltf.scene) (fun _ -> if pendingLoad = Some(generation, owner) then pendingLoad <- None loadedStep <- None - failedLoad <- Some owner + failedLoad <- Some(owner, historical) setNoteRetryable true - setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path)) -let loadStepArtifact (view: ViewState) (stepPath: string) = + if historical then + setText "viewport-note" (sprintf "历史几何加载失败 · %s" owner.Path) + else + setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path)) + +let loadStepArtifact (view: ViewState) (stepPath: string) (historical: bool) = let owner = { ProjectId = currentProjectId RunId = currentRunId @@ -309,12 +348,112 @@ let loadStepArtifact (view: ViewState) (stepPath: string) = match pendingLoad with | Some (_, pending) when pending = owner -> () - | _ -> startStepLoad view owner + | _ -> startStepLoad view owner historical let retryFailedLoad () = match viewState, failedLoad with - | Some view, Some owner when owner.ProjectId = currentProjectId && owner.RunId = currentRunId -> - loadStepArtifact view owner.Path + | Some view, Some (owner, historical) when owner.ProjectId = currentProjectId && owner.RunId = currentRunId -> + loadStepArtifact view owner.Path historical + | _ -> () + +let applyHistoricalStep (index: int) (duringPlayback: bool) = + match viewState with + | Some view when index >= 0 && index < Array.length historicalSteps -> + pinnedIndex <- Some index + let path = historicalSteps[index] + let version = index + 1 + setText "mesh-label" (sprintf "历史 · %s" path) + setText "timeline-current" (sprintf "V%02d · 历史回放 · %s" version path) + loadStepArtifact view path true + + if duringPlayback then + setText "replay-state" (sprintf "历史回放中 · V%02d (%d/%d)" version version (Array.length historicalSteps)) + else + setText "replay-state" (sprintf "历史回放 · 已固定 V%02d" version) + | _ -> () + +let rec renderTimeline () = + let track = element "timeline-track" + clearChildren track + + let line = document.createElement("span") + line.className <- "timeline-line" + line.setAttribute("aria-hidden", "true") + track.appendChild line |> ignore + + if Array.length historicalSteps > 0 then + track.setAttribute("style", sprintf "grid-template-columns: repeat(%d, 1fr)" historicalSteps.Length) + + historicalSteps + |> Array.iteri (fun i path -> + let active = + match pinnedIndex with + | Some pinned -> pinned = i + | None -> i = historicalSteps.Length - 1 + + track.appendChild (createStepNode (i + 1) path active true (Some i)) |> ignore) + else + track.setAttribute("style", "grid-template-columns: repeat(3, 1fr)") + + frames + |> Array.iter (fun frame -> + track.appendChild + (createStepNode frame.Version (sprintf "占位 · %s" frame.Label) false (frame.Version < frames.Length) None) + |> ignore) + +and selectStep (index: int) = + if index >= 0 && index < Array.length historicalSteps then + stopReplay () + applyHistoricalStep index false + updateFollowButton () + renderTimeline () + +and createStepNode (version: int) (label: string) (active: bool) (complete: bool) (onClickIndex: int option) = + let classes = ResizeArray [ "timeline-step" ] + + if complete then classes.Add("timeline-step-complete") + + if onClickIndex.IsSome then classes.Add("timeline-step-real") + + if active then classes.Add("timeline-step-active") + + let step = document.createElement("div") + step.className <- String.concat " " classes + + let dot = document.createElement("span") + dot.className <- "timeline-dot" + dot.setAttribute("aria-hidden", "true") + step.appendChild dot |> ignore + + let versionLabel = document.createElement("span") + versionLabel.className <- "timeline-version" + versionLabel.textContent <- sprintf "V%02d" version + step.appendChild versionLabel |> ignore + + let text = document.createElement("span") + text.className <- "timeline-label" + text.textContent <- label + step.appendChild text |> ignore + + match onClickIndex with + | Some index -> step.addEventListener("click", fun _ -> selectStep index) + | None -> () + + step + +let followLatestStep () = + match viewState with + | Some view when Array.length historicalSteps > 0 -> + stopReplay () + pinnedIndex <- None + let total = Array.length historicalSteps + let latest = historicalSteps[total - 1] + loadStepArtifact view latest false + setText "mesh-label" latest + setText "timeline-current" (sprintf "V%02d · 实时跟随 · %s" total latest) + setText "replay-state" (sprintf "实时跟随 · 最新 V%02d" total) + updateFollowButton () + renderTimeline () | _ -> () let updateState (payload: SnapshotPayload) = @@ -335,6 +474,12 @@ let updateState (payload: SnapshotPayload) = if currentProjectId <> payload.projectId || currentRunId <> payload.runId then invalidateLoads () displayedNode <- None + stopReplay () + pinnedIndex <- None + historicalSteps <- [||] + setText "replay-button" "占位演示" + setText "replay-state" "占位示例 · 未选择历史" + updateFollowButton () currentProjectId <- payload.projectId currentRunId <- payload.runId @@ -362,11 +507,35 @@ let updateState (payload: SnapshotPayload) = if isNull (box payload.completedSteps) then [||] else payload.completedSteps match viewState, completedSteps with + | Some view, [||] -> + if historicalSteps.Length > 0 then + stopReplay () + historicalSteps <- [||] + pinnedIndex <- None + setText "replay-button" "占位演示" + renderTimeline () + updateFollowButton () + + showFrameMetadata frame + renderFrame frame | Some view, steps -> - if steps.Length = 0 then - showFrameMetadata frame - renderFrame frame - else + if steps <> historicalSteps then + if historicalSteps.Length = 0 then + stopReplay () + setText "replay-button" "播放历史" + + historicalSteps <- steps + + match pinnedIndex with + | Some pinned when pinned >= steps.Length -> pinnedIndex <- None + | _ -> () + + renderTimeline () + updateFollowButton () + + match pinnedIndex with + | Some _ -> () + | None -> let latest = steps[steps.Length - 1] let owner = { ProjectId = payload.projectId @@ -374,9 +543,11 @@ let updateState (payload: SnapshotPayload) = Path = latest } if loadedStep <> Some owner then - loadStepArtifact view latest + loadStepArtifact view latest false setText "mesh-label" latest + setText "timeline-current" (sprintf "V%02d · 实时跟随 · %s" steps.Length latest) + setText "replay-state" (sprintf "实时跟随 · 最新 V%02d" steps.Length) | None, steps -> if steps.Length > 0 then setText "mesh-label" steps[steps.Length - 1] @@ -386,36 +557,67 @@ let updateState (payload: SnapshotPayload) = let updateStreamState message = setText "stream-state" message +let startPlaceholderPlayback () = + replayIndex <- 0 + setText "replay-button" "暂停占位演示" + setText "replay-state" "占位演示中 · 未选择历史" + + let tick () = + if replayIndex >= frames.Length then + stopReplay () + setText "replay-button" "占位演示" + setText "replay-state" "占位演示完成 · 未选择历史" + else + let frame = frames[replayIndex] + showFrameMetadata frame + renderFrame frame + setText "replay-state" (sprintf "占位演示中 · V%02d %s" frame.Version frame.Label) + replayIndex <- replayIndex + 1 + + tick () + replayTimer <- Some(setInterval tick 900) + +let startHistoricalPlayback () = + if Array.length historicalSteps > 0 then + setText "replay-button" "暂停历史回放" + applyHistoricalStep 0 true + updateFollowButton () + renderTimeline () + let total = Array.length historicalSteps + + let tick () = + match pinnedIndex with + | Some pinned when pinned + 1 < total -> + applyHistoricalStep (pinned + 1) true + renderTimeline () + | Some pinned -> + stopReplay () + setText "replay-button" "播放历史" + setText "replay-state" (sprintf "历史回放完成 · 停在 V%02d" (pinned + 1)) + | None -> + stopReplay () + setText "replay-button" "播放历史" + + replayTimer <- Some(setInterval tick 1500) + let toggleReplay () = match replayTimer with | Some handle -> clearInterval handle replayTimer <- None - setText "replay-button" "重建演示" - setText "replay-state" "演示已暂停" - | None -> - replayIndex <- 0 - setText "replay-button" "暂停重建" - setText "replay-state" "重建中 · 几何逐帧替换" - - let tick () = - if replayIndex >= frames.Length then - match replayTimer with - | Some handle -> clearInterval handle - | None -> () - - replayTimer <- None - setText "replay-button" "重建演示" - setText "replay-state" "重建完成 · 几何已更新" - else - let frame = frames[replayIndex] - showFrameMetadata frame - renderFrame frame - setText "replay-state" (sprintf "重建中 · V%02d %s" frame.Version frame.Label) - replayIndex <- replayIndex + 1 - tick () - replayTimer <- Some(setInterval tick 900) + if Array.length historicalSteps > 0 then + let current = Option.defaultValue 0 pinnedIndex + setText "replay-button" "播放历史" + setText "replay-state" (sprintf "历史回放 · 已暂停 · V%02d" (current + 1)) + else + setText "replay-button" "占位演示" + setText "replay-state" "占位演示 · 已暂停" + | None -> + if Array.length historicalSteps > 0 then + startHistoricalPlayback () + else + startPlaceholderPlayback () let disconnectFromServer () = match eventSource with @@ -494,9 +696,12 @@ let boot () = fallbackView () (element "replay-button").addEventListener("click", fun _ -> toggleReplay ()) + (element "follow-button").addEventListener("click", fun _ -> followLatestStep ()) (element "viewport-note").addEventListener("click", fun _ -> retryFailedLoad ()) (element "run-controls").addEventListener("submit", fun event -> event.preventDefault() startRun (selectedRun ())) + renderTimeline () + boot () diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs index 2822e33..e7c567d 100644 --- a/src/SomhairlesDream.Frontend/Bindings.fs +++ b/src/SomhairlesDream.Frontend/Bindings.fs @@ -103,6 +103,9 @@ let createVector3 () : ThreeVector = jsNative [<Emit("window.SOMHAIRLES_VIEWER = { camera: $0, scene: $1, group: $2, canvas: $3 }")>] let exposeViewerDebug (camera: ThreeCamera) (scene: ThreeNode) (group: ThreeNode) (canvas: obj) : unit = jsNative +[<Emit("var child; while ((child = $0.firstChild)) { $0.removeChild(child); }")>] +let clearChildren (node: obj) : unit = jsNative + [<Emit("new THREE.WebGLRenderer($0)")>] let createRenderer (options: obj) : ThreeRenderer = jsNative diff --git a/tools/acceptance/e2e_acceptance.py b/tools/acceptance/e2e_acceptance.py index 1b3ae6c..8271857 100755 --- a/tools/acceptance/e2e_acceptance.py +++ b/tools/acceptance/e2e_acceptance.py @@ -463,8 +463,8 @@ def stale_load_regression(browser, base_url, track, results, evidence): 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. + # Scenario F: entering historical replay pins a real checkpoint; a live + # GLB response still pending from before must not repaint the pinned view. run_j = f"run-stale-{stamp}-j" start_run(run_j) release(held_for(run_j, "steps/01-foundation.glb")) @@ -472,19 +472,199 @@ def stale_load_regression(browser, base_url, track, results, evidence): 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") + ehist = held_for(run_j, "steps/01-foundation.glb") + release(ehist) + wait_text(page, "#viewport-note", "历史几何 · steps/01-foundation.glb") 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')})")) + results.append((gj["ready"] and is_glb(gj) and "历史几何" in gj["note"], + f"stale-load: replay pins a real checkpoint with historical labels " + f"(note='{gj.get('note', '')[:40]}')")) page.screenshot(path=str(evidence / "stale-page-replay.png"), full_page=True) + page.click("#replay-button") + page.wait_for_timeout(300) + gj_pause = page.evaluate(SCENE_JS) + results.append((gj_pause["kids"] == gj["kids"] and gj_pause["note"] == gj["note"], + f"stale-load: historical playback pauses on the selected checkpoint " + f"(state='{page.inner_text('#replay-state')}')")) 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]}')")) + f"stale-load: live GLB after entering historical replay ignored " + f"(note='{gj2['note'][:40]}')")) + + for entry in held: + if not entry["done"]: + release(entry) + page.unroute("**/api/artifacts/steps*") + page.close() + + +def historical_replay_regression(browser, base_url, track, results, evidence): + """Regression for real historical checkpoint replay of a selected run. + + Proves the timeline offers a clearly labeled placeholder fallback while no + run history exists, that pinning a timeline step issues and serves the + selected run's own checkpoint artifact (byte-identical to the reference + GLB) under distinct historical labels, that the rendered geometry actually + changes when a different checkpoint is selected, that a late older + historical response cannot repaint a newer selection, that live snapshot + updates cannot unpin the chosen historical step, and that 跟随最新 returns + the viewport to live scope. 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}) + + track(page) + stamp = int(time.time() * 1000) + held = [] + + def hold_route(route): + q = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query) + held.append({ + "route": route, + "project": (q.get("projectId") or [""])[0], + "run": (q.get("runId") or [""])[0], + "path": (q.get("path") or [""])[0], + "done": False, + }) + + page.route("**/api/artifacts/steps*", hold_route) + page.goto(f"{base_url}/?freeze=1", wait_until="domcontentloaded") + wait_text(page, "#status-label", "待机") + + def start_run(run_id): + page.fill("#run-id-input", run_id) + render_box = page.query_selector("#run-render-input") + if render_box and render_box.is_checked(): + render_box.set_checked(False) + page.click("#run-start-button") + wait_text(page, "#stream-state", "已连接") + + def held_for(run_id, path, timeout_s=120): + deadline = time.time() + timeout_s + while time.time() < deadline: + for entry in held: + if not entry["done"] and entry["run"] == run_id and entry["path"] == path: + return entry + page.wait_for_timeout(300) + raise AssertionError( + f"no held steps request for run={run_id} path={path}; " + f"held={[(e['run'], e['path'], e['done']) for e in held]}") + + def release(entry, status=200): + if status == 200: + q = urllib.parse.urlencode({ + "projectId": entry["project"], "runId": entry["run"], "path": entry["path"], + }) + resp_status, ctype, body = fetch_bytes(f"{base_url}/api/artifacts/steps?{q}") + if resp_status != 200: + raise AssertionError(f"artifact fetch for fulfill failed ({resp_status})") + else: + ctype, body = "text/plain", b"boom" + entry["route"].fulfill(status=status, content_type=ctype, body=body) + entry["done"] = True + + def is_glb(g): + return bool(g.get("kids")) and g["kids"][0]["type"] == "Group" + + def box_ok(g): + return g.get("boxSize") and all( + isinstance(s, (int, float)) and s > 0 for s in g["boxSize"]) + + # Idle fallback must be clearly labeled as placeholder, never as history. + idle_state = page.inner_text("#replay-state") + idle_button = page.inner_text("#replay-button") + results.append(("占位" in idle_state and "占位" in idle_button, + f"historical: idle fallback clearly labeled placeholder " + f"(state='{idle_state}', button='{idle_button}')")) + + # Phase I: pin a checkpoint mid-run; live snapshot updates must not unpin it. + run_id = f"run-replay-{stamp}" + start_run(run_id) + release(held_for(run_id, "steps/01-foundation.glb")) + wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb") + held_before = len(held) + page.locator(".timeline-step").nth(0).click() + e_pinned = held_for(run_id, "steps/01-foundation.glb") + results.append((e_pinned["run"] == run_id and e_pinned["path"] == "steps/01-foundation.glb", + f"historical: pin requests the run's own checkpoint " + f"(run={e_pinned['run']}, path={e_pinned['path']})")) + release(e_pinned) + wait_text(page, "#viewport-note", "历史几何 · steps/01-foundation.glb") + page.wait_for_timeout(400) + g_pinned = page.evaluate(SCENE_JS) + results.append((g_pinned["ready"] and is_glb(g_pinned) + and "历史" in page.inner_text("#replay-state"), + f"historical: pinned step carries historical labels " + f"(state='{page.inner_text('#replay-state')[:36]}')")) + + page.wait_for_function( + "() => document.querySelectorAll('.timeline-step').length === 3", timeout=150000) + page.wait_for_timeout(600) + g_unpin = page.evaluate(SCENE_JS) + stray = [e for e in held[held_before:] + if e["run"] == run_id and e["path"] == "steps/03-cabin.glb"] + results.append((g_unpin["kids"] == g_pinned["kids"] and g_unpin["note"] == g_pinned["note"] + and not stray, + f"historical: live snapshot update cannot unpin the pinned step " + f"(stray 03 requests={len(stray)})")) + + # Phase II: 跟随最新 returns the viewport to live scope with the newest step. + page.click("#follow-button") + e_live3 = held_for(run_id, "steps/03-cabin.glb") + release(e_live3) + wait_text(page, "#viewport-note", "管线几何 · steps/03-cabin.glb") + page.wait_for_timeout(500) + g_live3 = page.evaluate(SCENE_JS) + results.append((g_live3["ready"] and is_glb(g_live3) and box_ok(g_live3), + f"historical: 跟随最新 switches back to live scope and loads the " + f"newest checkpoint (kids={g_live3.get('kids')})")) + box_live3 = g_live3["boxSize"] + + # Phase III: a late older historical response cannot repaint the newer selection. + page.locator(".timeline-step").nth(0).click() + e_late1 = held_for(run_id, "steps/01-foundation.glb") + page.locator(".timeline-step").nth(2).click() + e_hist3 = held_for(run_id, "steps/03-cabin.glb") + release(e_late1) + page.wait_for_timeout(600) + g_late = page.evaluate(SCENE_JS) + results.append((g_late["kids"] == g_live3["kids"] + and "历史几何加载中 · steps/03-cabin.glb" in g_late["note"], + f"historical: late older historical response ignored after newer " + f"selection (note='{g_late['note'][:40]}')")) + release(e_hist3) + wait_text(page, "#viewport-note", "历史几何 · steps/03-cabin.glb") + page.wait_for_timeout(400) + g_hist3 = page.evaluate(SCENE_JS) + results.append((g_hist3["ready"] and is_glb(g_hist3) and g_hist3["note"] != g_late["note"], + f"historical: newer historical selection wins after late older response " + f"(note='{g_hist3['note'][:40]}')")) + + # Phase IV: selecting V01 changes both the served checkpoint identity and + # the actual on-screen geometry. + page.locator(".timeline-step").nth(0).click() + e_hist1 = held_for(run_id, "steps/01-foundation.glb") + q = urllib.parse.urlencode({ + "projectId": e_hist1["project"], "runId": e_hist1["run"], "path": e_hist1["path"], + }) + status, ctype, body = fetch_bytes(f"{base_url}/api/artifacts/steps?{q}") + results.append((status == 200 and sha256_bytes(body) == REFERENCE_SHA["steps/01-foundation.glb"], + f"historical: served checkpoint identity matches reference GLB " + f"(sha256={sha256_bytes(body)[:12]}…)")) + release(e_hist1) + wait_text(page, "#viewport-note", "历史几何 · steps/01-foundation.glb") + page.wait_for_timeout(500) + g_hist1 = page.evaluate(SCENE_JS) + diffs = [abs(a - b) / max(abs(b), 1e-6) for a, b in zip(g_hist1["boxSize"], box_live3)] + results.append((g_hist1["ready"] and is_glb(g_hist1) and max(diffs) >= 0.05, + f"historical: selection changes actual viewport geometry " + f"(max rel diff {max(diffs):.3f})")) + page.screenshot(path=str(evidence / "historical-replay-pinned.png"), full_page=True) + (evidence / "historical-replay-pinned.json").write_text( + json.dumps(g_hist1, indent=2, ensure_ascii=False)) for entry in held: if not entry["done"]: @@ -577,6 +757,7 @@ def main(): results.append((digest == expected, f"sha256 {step_path} -> {digest[:12]}… (expect {expected[:12]}…)")) stale_load_regression(browser, base_url, track, results, evidence) + historical_replay_regression(browser, base_url, track, results, evidence) fail_ms = int(time.time() * 1000) + 2 fail_run_id = f"run-acc-{fail_ms}-failure" |
