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)
102 lines
4.2 KiB
TypeScript
102 lines
4.2 KiB
TypeScript
// Regression: refreshLive() must re-fetch when already in live mode,
|
|
// and setMode("live") repeated must not early-return.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { useApp } from "./store";
|
|
|
|
function stubFetch() {
|
|
const calls: string[] = [];
|
|
const store = new Map<string, string>();
|
|
globalThis.sessionStorage = {
|
|
getItem: (k) => store.get(k) ?? null,
|
|
setItem: (k, v) => { store.set(k, String(v)); },
|
|
removeItem: (k) => { store.delete(k); },
|
|
clear: () => store.clear(),
|
|
key: () => null,
|
|
length: 0,
|
|
} as Storage;
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
calls.push(url);
|
|
if (url.endsWith("/api/v1/auth/dev-login")) {
|
|
return new Response(JSON.stringify({ access_token: "T" }), { status: 200 });
|
|
}
|
|
if (url.endsWith("/api/v1/auth/me")) {
|
|
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
|
|
}
|
|
if (url.includes("/api/ea2/work-items")) {
|
|
return new Response(JSON.stringify({ items: [] }), { status: 200 });
|
|
}
|
|
return new Response("not stubbed", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
return calls;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null });
|
|
});
|
|
|
|
describe("store live-mode + refresh", () => {
|
|
it("setMode('live') performs a network fetch", async () => {
|
|
const calls = stubFetch();
|
|
await useApp.getState().setMode("live");
|
|
const meCalls = calls.filter((u) => u.endsWith("/api/v1/auth/me")).length;
|
|
const wiCalls = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
|
expect(meCalls).toBeGreaterThanOrEqual(1);
|
|
expect(wiCalls).toBeGreaterThanOrEqual(1);
|
|
expect(useApp.getState().mode).toBe("live");
|
|
expect(useApp.getState().liveFetchedAt).toBeTruthy();
|
|
});
|
|
|
|
it("refreshLive() triggers a second fetch and bumps liveFetchedAt", async () => {
|
|
const calls = stubFetch();
|
|
await useApp.getState().setMode("live");
|
|
const initialFetched = useApp.getState().liveFetchedAt!;
|
|
const initialWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
|
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await useApp.getState().refreshLive();
|
|
|
|
const afterWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
|
expect(afterWi).toBeGreaterThan(initialWi);
|
|
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
|
|
});
|
|
|
|
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
|
|
const calls = stubFetch();
|
|
await useApp.getState().refreshLive();
|
|
expect(calls.length).toBe(0);
|
|
expect(useApp.getState().mode).toBe("snapshot");
|
|
});
|
|
|
|
it("setMode('snapshot') when already snapshot does not re-toast", async () => {
|
|
stubFetch();
|
|
const before = useApp.getState().toasts.length;
|
|
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);
|
|
});
|
|
});
|