test(ui): make initial branch selection user owned
This commit is contained in:
+163
-61
@@ -209,6 +209,44 @@ function clearSession() {
|
|||||||
try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {}
|
try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives whether the current component state represents a valid investigation
|
||||||
|
* context sufficient to render a workspace surface.
|
||||||
|
*
|
||||||
|
* Valid only when:
|
||||||
|
* - result carries a situationGraph (renderable graph), OR
|
||||||
|
* - status is "success" AND there is a non-empty scenario
|
||||||
|
* (from session restoration with real data).
|
||||||
|
*
|
||||||
|
* This predicate is the single source of truth for all render-gate decisions.
|
||||||
|
* showExperimentView, fixture availability, or sessionStorage keys alone are
|
||||||
|
* NOT sufficient to constitute valid context.
|
||||||
|
*/
|
||||||
|
export function hasValidInvestigationContext(result, status, scenario) {
|
||||||
|
return Boolean(result?.situationGraph) ||
|
||||||
|
(status === "success" && Boolean(scenario?.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the primary surface that must render for the given state tuple.
|
||||||
|
* Enforces exactly-one-primary-surface invariant: no zero, no two.
|
||||||
|
*/
|
||||||
|
export function derivePrimarySurface(result, status, showExperimentView, scenario, activeBranchId) {
|
||||||
|
if (status === "loading") return "LOADING";
|
||||||
|
if (status === "error") return "ERROR_SURFACE";
|
||||||
|
|
||||||
|
const valid = hasValidInvestigationContext(result, status, scenario);
|
||||||
|
|
||||||
|
// RTO.28A: show branch-selection surface when investigation exists but no branch is active
|
||||||
|
if (showExperimentView && valid && !activeBranchId) return "BRANCH_SELECTION";
|
||||||
|
|
||||||
|
if (showExperimentView && valid) return "EXPERIMENT_NOTEBOOK";
|
||||||
|
if (!showExperimentView && valid) return "NORMAL_WORKSPACE";
|
||||||
|
if (!showExperimentView) return "SCENARIO_ENTRY";
|
||||||
|
// showExperimentView === true but no valid context → fall back to entry
|
||||||
|
return "SCENARIO_ENTRY";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ScenarioForm() {
|
export default function ScenarioForm() {
|
||||||
const [scenario, setScenario] = useState("");
|
const [scenario, setScenario] = useState("");
|
||||||
const [status, setStatus] = useState("idle"); // idle | loading | error | success
|
const [status, setStatus] = useState("idle"); // idle | loading | error | success
|
||||||
@@ -225,7 +263,8 @@ export default function ScenarioForm() {
|
|||||||
|
|
||||||
/* ── RTO.25A — passive late-result branch switcher (experimental) ── */
|
/* ── RTO.25A — passive late-result branch switcher (experimental) ── */
|
||||||
|
|
||||||
const [activeBranchId, setActiveBranchId] = useState("branch-a");
|
// RTO.28A: no active branch until the user explicitly chooses one.
|
||||||
|
const [activeBranchId, setActiveBranchId] = useState(null);
|
||||||
// Pre-seed Competitor development with a late result for RTO.27A testing
|
// Pre-seed Competitor development with a late result for RTO.27A testing
|
||||||
const [branchNewResults, setBranchNewResults] = useState({ "branch-a": true });
|
const [branchNewResults, setBranchNewResults] = useState({ "branch-a": true });
|
||||||
|
|
||||||
@@ -291,30 +330,54 @@ export default function ScenarioForm() {
|
|||||||
return result;
|
return result;
|
||||||
}, [activeBranchId, BRANCHES, branchNewResults, doneForNowBranchIds]);
|
}, [activeBranchId, BRANCHES, branchNewResults, doneForNowBranchIds]);
|
||||||
|
|
||||||
|
/* ── Valid investigation predicate ─────────────────────── */
|
||||||
|
|
||||||
|
// Delegated to the exported utility below.
|
||||||
|
const validCtx = hasValidInvestigationContext(result, status, scenario);
|
||||||
|
|
||||||
/* Restore persisted session on mount (Phase 3) ─────────── */
|
/* Restore persisted session on mount (Phase 3) ─────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const saved = getSession();
|
const saved = getSession();
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
|
|
||||||
|
const hasGraph = Boolean(saved.situationGraph);
|
||||||
|
|
||||||
setScenario(saved.scenario || "");
|
setScenario(saved.scenario || "");
|
||||||
setResult(saved.situationGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
||||||
setCurrentUnderstanding(saved.summary || null);
|
setCurrentUnderstanding(saved.summary || null);
|
||||||
setStatus("success");
|
|
||||||
|
// Partial sessions (present but no graph) must NOT suppress the
|
||||||
|
// scenario-entry form. Only promote to success when there is actual
|
||||||
|
// investigation data to render.
|
||||||
|
if (hasGraph) {
|
||||||
|
setStatus("success");
|
||||||
|
setShowExperimentView(true);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* Restore experiment view preference from storage (after hydration) ─ */
|
/* Restore experiment view preference from storage (after hydration) ─ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
// Only promote to experiment mode when there is real investigation
|
||||||
|
// data to render. The ce-show-experiment flag is a presentation
|
||||||
|
// preference, not proof that an investigation exists.
|
||||||
|
if (!validCtx) return;
|
||||||
|
|
||||||
const savedExp = sessionStorage?.getItem("ce-show-experiment");
|
const savedExp = sessionStorage?.getItem("ce-show-experiment");
|
||||||
if (savedExp === "true") {
|
if (savedExp === "true") {
|
||||||
setShowExperimentView(true);
|
setShowExperimentView(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also enable experiment mode if session data provides a graph
|
||||||
|
// (covers the pre-restoration case where scenario was typed but not yet submitted).
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (session?.situationGraph) {
|
if (session?.situationGraph) {
|
||||||
setShowExperimentView(true);
|
setShowExperimentView(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [validCtx]);
|
||||||
|
|
||||||
/* Restore facilitator dismiss preference (Experiment 05) ─── */
|
/* Restore facilitator dismiss preference (Experiment 05) ─── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -471,67 +534,106 @@ export default function ScenarioForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── RTO.26B — standalone branch-scoped experimental view (shown when experiment is active) ───── */}
|
{/* ── RTO.26B — standalone branch-scoped experimental view (shown when experiment is active) ───── */}
|
||||||
{showExperimentView && (result?.situationGraph || status === "success") && status !== "loading" && (
|
{showExperimentView && validCtx && status !== "loading" && (
|
||||||
<>
|
<>
|
||||||
<PulseStyle />
|
<PulseStyle />
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
{!activeBranchId ? (
|
||||||
{/* Workspace (3/4) — branch-scoped fixture only, no API needed */}
|
/* RTO.28A: no active branch — show branch-selection surface */
|
||||||
<div className="lg:col-span-3">
|
<div className="max-w-xl mx-auto space-y-6">
|
||||||
{(() => {
|
<div className="space-y-4">
|
||||||
const activeBranch = BRANCHES.find(b => b.id === activeBranchId);
|
{/* Situation remains visible */}
|
||||||
const branchContext = activeBranch ? { label: activeBranch.label, origin: activeBranch.origin } : null;
|
{currentUnderstanding && (
|
||||||
return (
|
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-4">
|
||||||
<ReasoningWorkspace
|
<h2 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||||
scenario={scenario}
|
Situation
|
||||||
status={status}
|
</h2>
|
||||||
updateStatus="idle"
|
<p className="text-sm leading-relaxed text-gray-700">{currentUnderstanding}</p>
|
||||||
currentUnderstanding={currentUnderstanding}
|
</div>
|
||||||
result={{ situationGraph: null, selectedQuestion: null, newlySurfacedNodeIds: [], diagnostics: null }}
|
)}
|
||||||
answer=""
|
</div>
|
||||||
setAnswer={() => {}}
|
|
||||||
onAnswerSubmit={async (e) => e.preventDefault()}
|
|
||||||
lastSubmittedAnswer=""
|
|
||||||
branchContext={branchContext}
|
|
||||||
experimentalBranches={BRANCHES.length > 0 ? BRANCHES : undefined}
|
|
||||||
branchLocalQuestions={experimentalBranchQuestions.length > 0 ? experimentalBranchQuestions : undefined}
|
|
||||||
branchLocalContributions={experimentalBranchContributions.length > 0 ? experimentalBranchContributions : undefined}
|
|
||||||
branchLocalLateResults={experimentalBranchLateResults.length > 0 ? experimentalBranchLateResults : undefined}
|
|
||||||
inactiveBranchNewResults={Object.keys(inactiveBranchNewResults).length > 0 ? inactiveBranchNewResults : undefined}
|
|
||||||
activeBranchIdForNotebook={activeBranchId}
|
|
||||||
doneForNowBranchIds={doneForNowBranchIds}
|
|
||||||
onDoneForNow={() => {
|
|
||||||
if (activeBranchId && !doneForNowBranchIds.includes(activeBranchId)) {
|
|
||||||
setDoneForNowBranchIds(prev => [...prev, activeBranchId]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onReopenBranch={(id) => {
|
|
||||||
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
|
||||||
setActiveBranchId(id);
|
|
||||||
}}
|
|
||||||
onRestart={() => { setStatus("idle"); setResult(null); setScenario(""); }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Branch switcher (1/4 sidebar) */}
|
{/* Branch selection */}
|
||||||
<div className="lg:col-span-1">
|
<div className="space-y-4">
|
||||||
<ExperimentalBranchSwitcher
|
<h2 className="text-xs font-medium text-gray-700">Choose a branch to continue</h2>
|
||||||
branches={BRANCHES}
|
<p className="text-sm text-gray-500">These are the current lines of inquiry. Pick whichever you want to work on.</p>
|
||||||
activeBranchId={activeBranchId}
|
|
||||||
branchNewResults={branchNewResults}
|
{BRANCHES.map((branch) => (
|
||||||
branchPauseState={doneForNowBranchIds}
|
<button
|
||||||
onBranchSelect={(id) => {
|
key={branch.id}
|
||||||
if (id === activeBranchId) return;
|
onClick={() => setActiveBranchId(branch.id)}
|
||||||
// Reopen: if the selected branch is paused, clear its pause state
|
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
|
||||||
if (doneForNowBranchIds.includes(id)) {
|
>
|
||||||
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
<span className="block font-medium text-sm text-gray-900">{branch.label}</span>
|
||||||
}
|
{branch.origin && (
|
||||||
setActiveBranchId(id);
|
<span className="block mt-1 text-xs leading-relaxed text-gray-500/80">
|
||||||
}}
|
{branch.origin}
|
||||||
/>
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
/* Has active branch — show notebook */
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||||
|
{/* Workspace (3/4) — branch-scoped fixture only, no API needed */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
{(() => {
|
||||||
|
const activeBranch = BRANCHES.find(b => b.id === activeBranchId);
|
||||||
|
const branchContext = activeBranch ? { label: activeBranch.label, origin: activeBranch.origin } : null;
|
||||||
|
return (
|
||||||
|
<ReasoningWorkspace
|
||||||
|
scenario={scenario}
|
||||||
|
status={status}
|
||||||
|
updateStatus="idle"
|
||||||
|
currentUnderstanding={currentUnderstanding}
|
||||||
|
result={{ situationGraph: null, selectedQuestion: null, newlySurfacedNodeIds: [], diagnostics: null }}
|
||||||
|
answer=""
|
||||||
|
setAnswer={() => {}}
|
||||||
|
onAnswerSubmit={async (e) => e.preventDefault()}
|
||||||
|
lastSubmittedAnswer=""
|
||||||
|
branchContext={branchContext}
|
||||||
|
experimentalBranches={BRANCHES.length > 0 ? BRANCHES : undefined}
|
||||||
|
branchLocalQuestions={experimentalBranchQuestions.length > 0 ? experimentalBranchQuestions : undefined}
|
||||||
|
branchLocalContributions={experimentalBranchContributions.length > 0 ? experimentalBranchContributions : undefined}
|
||||||
|
branchLocalLateResults={experimentalBranchLateResults.length > 0 ? experimentalBranchLateResults : undefined}
|
||||||
|
inactiveBranchNewResults={Object.keys(inactiveBranchNewResults).length > 0 ? inactiveBranchNewResults : undefined}
|
||||||
|
activeBranchIdForNotebook={activeBranchId}
|
||||||
|
doneForNowBranchIds={doneForNowBranchIds}
|
||||||
|
onDoneForNow={() => {
|
||||||
|
if (activeBranchId && !doneForNowBranchIds.includes(activeBranchId)) {
|
||||||
|
setDoneForNowBranchIds(prev => [...prev, activeBranchId]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReopenBranch={(id) => {
|
||||||
|
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
||||||
|
setActiveBranchId(id);
|
||||||
|
}}
|
||||||
|
onRestart={() => { setStatus("idle"); setResult(null); setScenario(""); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch switcher (1/4 sidebar) */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<ExperimentalBranchSwitcher
|
||||||
|
branches={BRANCHES}
|
||||||
|
activeBranchId={activeBranchId}
|
||||||
|
branchNewResults={branchNewResults}
|
||||||
|
branchPauseState={doneForNowBranchIds}
|
||||||
|
onBranchSelect={(id) => {
|
||||||
|
if (id === activeBranchId) return;
|
||||||
|
// Reopen: if the selected branch is paused, clear its pause state
|
||||||
|
if (doneForNowBranchIds.includes(id)) {
|
||||||
|
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
||||||
|
}
|
||||||
|
setActiveBranchId(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* RTO.27F — Primary lifecycle surface invariant
|
||||||
|
*
|
||||||
|
* Tests that exactly one primary surface renders in every meaningful state:
|
||||||
|
* no zero, no two. Exercises the exported predicates from
|
||||||
|
* scenario-form.jsx directly to avoid SSR/React-Testing-Library complications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { hasValidInvestigationContext, derivePrimarySurface } from "@/components/scenario-form";
|
||||||
|
|
||||||
|
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const EMPTY_RESULT = null;
|
||||||
|
const RESULT_WITH_GRAPH = { situationGraph: {}, selectedQuestion: "q1" };
|
||||||
|
const SCENARIO_EMPTY = "";
|
||||||
|
const SCENARIO_SET = "Some situation description";
|
||||||
|
|
||||||
|
/* ── hasValidInvestigationContext tests ─────────────────────────── */
|
||||||
|
|
||||||
|
describe("hasValidInvestigationContext", () => {
|
||||||
|
it("rejects idle + no result + empty scenario", () => {
|
||||||
|
expect(hasValidInvestigationContext(EMPTY_RESULT, "idle", SCENARIO_EMPTY)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects loading state", () => {
|
||||||
|
expect(hasValidInvestigationContext(EMPTY_RESULT, "loading", SCENARIO_SET)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts result with situationGraph", () => {
|
||||||
|
expect(hasValidInvestigationContext(RESULT_WITH_GRAPH, "success", SCENARIO_EMPTY)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts success + non-empty scenario (session restoration)", () => {
|
||||||
|
expect(hasValidInvestigationContext(EMPTY_RESULT, "success", SCENARIO_SET)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects success + empty scenario (no investigation data)", () => {
|
||||||
|
expect(hasValidInvestigationContext(EMPTY_RESULT, "success", SCENARIO_EMPTY)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects error state even with graph (graph is valid but surface is ERROR)", () => {
|
||||||
|
// Note: hasGraph returns true — the derivePrimarySurface function handles this by routing to ERROR_SURFACE first.
|
||||||
|
expect(hasValidInvestigationContext(RESULT_WITH_GRAPH, "error", SCENARIO_SET)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects false scenario input (empty string)", () => {
|
||||||
|
expect(hasValidInvestigationContext(EMPTY_RESULT, "success", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whitespace-only scenario", () => {
|
||||||
|
expect(hasValidInvestigationContext(EMPTY_RESULT, "success", " ")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── derivePrimarySurface tests (7 required cases) ─────────────── */
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 1: idle / no result / experiment=false", () => {
|
||||||
|
it("returns SCENARIO_ENTRY", () => {
|
||||||
|
const surface = derivePrimarySurface(EMPTY_RESULT, "idle", false, SCENARIO_EMPTY);
|
||||||
|
expect(surface).toBe("SCENARIO_ENTRY");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 2: idle / no result / experiment=true (stale flag)", () => {
|
||||||
|
it("returns SCENARIO_ENTRY (not blank) — this is the RTO.27F critical fix", () => {
|
||||||
|
const surface = derivePrimarySurface(EMPTY_RESULT, "idle", true, SCENARIO_EMPTY);
|
||||||
|
expect(surface).toBe("SCENARIO_ENTRY");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 3: valid investigation / experiment=true / branch selected", () => {
|
||||||
|
it("returns EXPERIMENT_NOTEBOOK when a branch is active", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "success", true, SCENARIO_SET, "branch-a");
|
||||||
|
expect(surface).toBe("EXPERIMENT_NOTEBOOK");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — RTO.28A: branch-selection surface", () => {
|
||||||
|
it("returns BRANCH_SELECTION when valid investigation exists but activeBranchId is null", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "success", true, SCENARIO_SET, null);
|
||||||
|
expect(surface).toBe("BRANCH_SELECTION");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns EXPERIMENT_NOTEBOOK when valid investigation exists and activeBranchId is set", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "success", true, SCENARIO_SET, "branch-a");
|
||||||
|
expect(surface).toBe("EXPERIMENT_NOTEBOOK");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns EXPERIMENT_NOTEBOOK when valid investigation exists and activeBranchId is branch-b", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "success", true, SCENARIO_SET, "branch-b");
|
||||||
|
expect(surface).toBe("EXPERIMENT_NOTEBOOK");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 4: valid investigation / experiment=false", () => {
|
||||||
|
it("returns NORMAL_WORKSPACE", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "success", false, SCENARIO_SET);
|
||||||
|
expect(surface).toBe("NORMAL_WORKSPACE");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 5: loading", () => {
|
||||||
|
it("returns LOADING regardless of other flags", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "loading", true, SCENARIO_SET);
|
||||||
|
expect(surface).toBe("LOADING");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
|
||||||
|
// Even with empty scenario or no graph — loading always wins
|
||||||
|
const surface2 = derivePrimarySurface(null, "loading", false, "");
|
||||||
|
expect(surface2).toBe("LOADING");
|
||||||
|
expect(surface2).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 6: error / valid context", () => {
|
||||||
|
it("returns ERROR_SURFACE when graph exists", () => {
|
||||||
|
const surface = derivePrimarySurface(RESULT_WITH_GRAPH, "error", false, SCENARIO_SET);
|
||||||
|
expect(surface).toBe("ERROR_SURFACE");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ERROR_SURFACE even with no graph (safe fallback)", () => {
|
||||||
|
const surface = derivePrimarySurface(null, "error", false, SCENARIO_SET);
|
||||||
|
expect(surface).toBe("ERROR_SURFACE");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derivePrimarySurface — CASE 7: partial/stale restored session", () => {
|
||||||
|
it("returns SCENARIO_ENTRY when no graph and idle (stale experiment flag)", () => {
|
||||||
|
const surface = derivePrimarySurface(EMPTY_RESULT, "idle", true, SCENARIO_EMPTY);
|
||||||
|
expect(surface).toBe("SCENARIO_ENTRY");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BRANCH_SELECTION when session has scenario + success (valid context, no branch)", () => {
|
||||||
|
const surface = derivePrimarySurface(EMPTY_RESULT, "success", true, SCENARIO_SET);
|
||||||
|
expect(surface).toBe("BRANCH_SELECTION");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns SCENARIO_ENTRY for stale experiment preference without any session data", () => {
|
||||||
|
const surface = derivePrimarySurface(EMPTY_RESULT, "idle", true, "");
|
||||||
|
expect(surface).toBe("SCENARIO_ENTRY");
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Exhaustive invariant: every state maps to exactly one surface ─ */
|
||||||
|
|
||||||
|
describe("PRIMARY_SURFACE_COUNT — exhaustive invariant (zero allowed, two forbidden)", () => {
|
||||||
|
const testCases = [
|
||||||
|
// idle variants
|
||||||
|
{ result: null, status: "idle", exp: false, scenario: "", ab: null, expected: "SCENARIO_ENTRY" },
|
||||||
|
{ result: null, status: "idle", exp: true, scenario: "", ab: null, expected: "SCENARIO_ENTRY" },
|
||||||
|
{ result: null, status: "idle", exp: false, scenario: "x", ab: null, expected: "SCENARIO_ENTRY" },
|
||||||
|
// loading variants
|
||||||
|
{ result: null, status: "loading", exp: false, scenario: "", ab: null, expected: "LOADING" },
|
||||||
|
{ result: null, status: "loading", exp: true, scenario: "x", ab: null, expected: "LOADING" },
|
||||||
|
{ result: RESULT_WITH_GRAPH, status: "loading", exp: false, scenario: "x", ab: null, expected: "LOADING" },
|
||||||
|
// error variants
|
||||||
|
{ result: null, status: "error", exp: false, scenario: "x", ab: null, expected: "ERROR_SURFACE" },
|
||||||
|
{ result: RESULT_WITH_GRAPH, status: "error", exp: true, scenario: "x", ab: null, expected: "ERROR_SURFACE" },
|
||||||
|
// success — no graph, empty scenario (no valid context)
|
||||||
|
{ result: null, status: "success", exp: false, scenario: "", ab: null, expected: "SCENARIO_ENTRY" },
|
||||||
|
{ result: null, status: "success", exp: true, scenario: "", ab: null, expected: "SCENARIO_ENTRY" },
|
||||||
|
// success — no graph, non-empty scenario (session restored without graph)
|
||||||
|
{ result: null, status: "success", exp: false, scenario: "x", ab: null, expected: "NORMAL_WORKSPACE" },
|
||||||
|
{ result: null, status: "success", exp: true, scenario: "x", ab: null, expected: "BRANCH_SELECTION" },
|
||||||
|
// success — with graph, no branch → BRANCH_SELECTION
|
||||||
|
{ result: RESULT_WITH_GRAPH, status: "success", exp: true, scenario: "x", ab: null, expected: "BRANCH_SELECTION" },
|
||||||
|
// success — with graph, branch-a selected → EXPERIMENT_NOTEBOOK
|
||||||
|
{ result: RESULT_WITH_GRAPH, status: "success", exp: true, scenario: "x", ab: "branch-a", expected: "EXPERIMENT_NOTEBOOK" },
|
||||||
|
// success — with graph, branch-b selected → EXPERIMENT_NOTEBOOK
|
||||||
|
{ result: RESULT_WITH_GRAPH, status: "success", exp: true, scenario: "x", ab: "branch-b", expected: "EXPERIMENT_NOTEBOOK" },
|
||||||
|
// success — with graph, experiment=false (production)
|
||||||
|
{ result: RESULT_WITH_GRAPH, status: "success", exp: false, scenario: "x", ab: null, expected: "NORMAL_WORKSPACE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tc of testCases) {
|
||||||
|
it(
|
||||||
|
`${tc.status}|exp=${tc.exp}|graph=${!!tc.result?.situationGraph}|ab=${tc.ab}|"${tc.scenario}" → ${tc.expected}`,
|
||||||
|
() => {
|
||||||
|
const surface = derivePrimarySurface(tc.result, tc.status, tc.exp, tc.scenario, tc.ab);
|
||||||
|
expect(surface).toBe(tc.expected);
|
||||||
|
// The invariant: exactly one surface, never null/undefined/blank
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface).not.toBe("");
|
||||||
|
expect(surface).toBeDefined();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user