fix(oracle-r4): validate selectedStepId across live refreshes
Some checks failed
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

Oracle r4 'Watch Out For' caught a real edge case: if a backend graph
changes shape while the scenarioId stays the same, the prior
selectedStepId could survive the merge and point at a step that no
longer exists. Two new vitest regressions in state/store.test.ts now
pin the contract:

- refreshLive() resets selectedStepId to the scenario's defaultStepId
  when the prior step no longer exists in the merged catalog
- refreshLive() preserves a still-valid selectedStepId

runLiveFetch() now derives stepStillThere from the merged scenario's
own steps (not the old store) and falls back to defaultStepId when
stale. Same single-set call as before; no extra renders.

Confidence: high
Scope-risk: narrow
Not-tested: real backend definition with step IDs that disappear
mid-session (covered by stub + assertion above)
This commit is contained in:
Shad 2026-06-14 00:34:47 +04:00
parent 2e0e4c08e8
commit 49639a0857
2 changed files with 29 additions and 2 deletions

View File

@ -74,4 +74,28 @@ describe("store live-mode + refresh", () => {
await useApp.getState().setMode("snapshot");
expect(useApp.getState().toasts.length).toBe(before);
});
it("refreshLive() resets selectedStepId to defaultStepId when the prior step no longer exists in the merged catalog", async () => {
stubFetch();
await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0];
if (!scenario) throw new Error("no scenario");
useApp.setState({ scenarioId: scenario.id, selectedStepId: "definitely-not-a-real-step-id" });
await useApp.getState().refreshLive();
const after = useApp.getState();
const sc = after.scenarios.find((s) => s.id === after.scenarioId);
expect(sc).toBeDefined();
expect(sc!.steps.some((st) => st.id === after.selectedStepId)).toBe(true);
});
it("refreshLive() preserves a still-valid selectedStepId", async () => {
stubFetch();
await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0];
if (!scenario) throw new Error("no scenario");
const validStep = scenario.steps[scenario.steps.length - 1];
useApp.setState({ scenarioId: scenario.id, selectedStepId: validStep.id });
await useApp.getState().refreshLive();
expect(useApp.getState().selectedStepId).toBe(validStep.id);
});
});

View File

@ -84,14 +84,17 @@ async function runLiveFetch(
const merged = [...scenarios, ...syntheticScenarios];
const first = merged[0];
const currentId = get().scenarioId;
const currentStepId = get().selectedStepId;
const stillThere = merged.find((s) => s.id === currentId);
const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId);
const nextScenario = stillThere ?? first;
set({
scenarios: merged,
liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(),
liveLoading: false,
scenarioId: stillThere ? currentId : first?.id ?? currentId,
selectedStepId: stillThere ? get().selectedStepId : first?.defaultStepId ?? null,
scenarioId: nextScenario?.id ?? currentId,
selectedStepId: stepStillThere ? currentStepId : nextScenario?.defaultStepId ?? null,
});
get().pushToast(
"ok",