From 65ced2e406838437481bc838834904d322284cbb Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 20 Aug 2026 14:11:37 +0100 Subject: [PATCH] test(ui): make initial branch selection user owned --- components/scenario-form.jsx | 224 ++++++++++++++++------ tests/lifecycle-surface-invariant.test.js | 208 ++++++++++++++++++++ 2 files changed, 371 insertions(+), 61 deletions(-) create mode 100644 tests/lifecycle-surface-invariant.test.js diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index bd27b5e..6cbc898 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -209,6 +209,44 @@ function clearSession() { 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() { const [scenario, setScenario] = useState(""); 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) ── */ - 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 const [branchNewResults, setBranchNewResults] = useState({ "branch-a": true }); @@ -291,30 +330,54 @@ export default function ScenarioForm() { return result; }, [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) ─────────── */ useEffect(() => { if (typeof window === "undefined") return; const saved = getSession(); if (!saved) return; + + const hasGraph = Boolean(saved.situationGraph); + setScenario(saved.scenario || ""); - setResult(saved.situationGraph ? { ...saved, situationGraph: saved.situationGraph } : null); + setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : 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) ─ */ useEffect(() => { 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"); if (savedExp === "true") { setShowExperimentView(true); 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(); if (session?.situationGraph) { setShowExperimentView(true); } - }, []); + }, [validCtx]); /* Restore facilitator dismiss preference (Experiment 05) ─── */ useEffect(() => { @@ -471,67 +534,106 @@ export default function ScenarioForm() { return (
{/* ── RTO.26B — standalone branch-scoped experimental view (shown when experiment is active) ───── */} - {showExperimentView && (result?.situationGraph || status === "success") && status !== "loading" && ( + {showExperimentView && validCtx && status !== "loading" && ( <> -
- {/* Workspace (3/4) — branch-scoped fixture only, no API needed */} -
- {(() => { - const activeBranch = BRANCHES.find(b => b.id === activeBranchId); - const branchContext = activeBranch ? { label: activeBranch.label, origin: activeBranch.origin } : null; - return ( - {}} - 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(""); }} - /> - ); - })()} -
+ {!activeBranchId ? ( + /* RTO.28A: no active branch — show branch-selection surface */ +
+
+ {/* Situation remains visible */} + {currentUnderstanding && ( +
+

+ Situation +

+

{currentUnderstanding}

+
+ )} +
- {/* Branch switcher (1/4 sidebar) */} -
- { - 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); - }} - /> + {/* Branch selection */} +
+

Choose a branch to continue

+

These are the current lines of inquiry. Pick whichever you want to work on.

+ + {BRANCHES.map((branch) => ( + + ))} +
-
+ ) : ( + /* Has active branch — show notebook */ +
+ {/* Workspace (3/4) — branch-scoped fixture only, no API needed */} +
+ {(() => { + const activeBranch = BRANCHES.find(b => b.id === activeBranchId); + const branchContext = activeBranch ? { label: activeBranch.label, origin: activeBranch.origin } : null; + return ( + {}} + 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(""); }} + /> + ); + })()} +
+ + {/* Branch switcher (1/4 sidebar) */} +
+ { + 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); + }} + /> +
+
+ )} )} diff --git a/tests/lifecycle-surface-invariant.test.js b/tests/lifecycle-surface-invariant.test.js new file mode 100644 index 0000000..e319a9d --- /dev/null +++ b/tests/lifecycle-surface-invariant.test.js @@ -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(); + } + ); + } +});