summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md28
-rw-r--r--public/App.js382
-rw-r--r--public/index.html17
-rw-r--r--public/styles.css30
-rw-r--r--src/SomhairlesDream.Frontend/App.fs287
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs3
-rwxr-xr-xtools/acceptance/e2e_acceptance.py197
7 files changed, 823 insertions, 121 deletions
diff --git a/README.md b/README.md
index 470dc8b..977c984 100644
--- a/README.md
+++ b/README.md
@@ -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"