/** * Focused test suite for the second contribution persistence invariant. * * The flow during a focused deconstruct success is: * 1. handleDeconstructSubmit (workspace) calls fetch to /api/focused-investigation/deconstruct * 2. Route returns { success: true, observations[], uncertainties[], ... } * 3. workspace calls onFocusedContribution({ targetNodeId, question, answer, ... }) * 4. parent scenario-form calls appendFocusedContribution(contrib) -> [ ...prev, contrib ] * 5. useEffect watching focusedContributions triggers saveSession(...) * * Invariant: a SECOND successful deconstruct appends to the existing collection, * not replacing it. Both contributions survive a session reload. */ import { describe, expect, it } from "vitest"; // -- helpers that mirror production code exactly -- function simulateAppendContributions(contributions, newContribution) { return [ ...contributions, { ...newContribution, id: `contrib-${String(contributions.length + 1).padStart(4, "0")}`, sequence: contributions.length + 1, }, ]; } function simulateSaveSession(state) { return JSON.stringify({ scenario: state.scenario, situationGraph: state.situationGraph, selectedQuestion: state.selectedQuestion, summary: state.summary, updatedAt: new Date().toISOString(), focusedContributions: state.focusedContributions, }); } function simulateRestoreSession(raw) { if (!raw) return null; const parsed = JSON.parse(raw); return { ...parsed, focusedContributions: parsed.focusedContributions || [], }; } /** Simulates a successful deconstruct API response (after the targetNodeId fix). */ function simulateDeconstructResponse(body) { return { success: true, targetNodeId: body.targetNodeId, // fixed: always uses body value observations: [`${body.targetNodeId}-observation`], uncertainties: [`${body.targetNodeId}-uncertainty`], assumptions: [`${body.targetNodeId}-assumption`], relationships: [{ from: body.targetNodeId, to: "context", type: "informs" }], possibleFollowUpQuestions: [`What about ${body.targetLabel}?`], }; } // -- Second contribution persistence after deconstruct success -- describe("second contribution persistence after deconstruct success", () => { it("first deconstruct creates one contribution, session saves it", () => { let state = { scenario: "test", situationGraph: {}, focusedContributions: [] }; const contrib1Data = simulateDeconstructResponse({ targetNodeId: "nk-001", targetLabel: "First thread" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contrib1Data.targetNodeId, question: "What is the main risk?", answer: "Regulatory compliance in EU.", ...contrib1Data, }); expect(state.focusedContributions).toHaveLength(1); expect(state.focusedContributions[0].id).toBe("contrib-0001"); expect(state.focusedContributions[0].targetNodeId).toBe("nk-001"); expect(state.focusedContributions[0].question).toBe("What is the main risk?"); const raw = simulateSaveSession(state); const restored = simulateRestoreSession(raw); expect(restored.focusedContributions).toHaveLength(1); expect(restored.focusedContributions[0].targetNodeId).toBe("nk-001"); }); it("second deconstruct appends a NEW contribution (not replace)", () => { let state = { scenario: "test", situationGraph: {}, focusedContributions: [] }; // First contribution const contrib1Data = simulateDeconstructResponse({ targetNodeId: "nk-001", targetLabel: "First thread" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contrib1Data.targetNodeId, question: "What is the main risk?", answer: "Regulatory compliance.", ...contrib1Data, }); // Second deconstruct -- user reopens SAME thread and submits a different answer const contrib2Data = simulateDeconstructResponse({ targetNodeId: "nk-001", targetLabel: "Second follow-up" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contrib2Data.targetNodeId, question: "Which EU regulation applies?", answer: "GDPR Article 32.", ...contrib2Data, }); // CRITICAL INVARIANT: TWO contributions exist (not replaced) expect(state.focusedContributions).toHaveLength(2); expect(state.focusedContributions[0].id).toBe("contrib-0001"); expect(state.focusedContributions[0].question).toBe("What is the main risk?"); expect(state.focusedContributions[1].id).toBe("contrib-0002"); expect(state.focusedContributions[1].question).toBe("Which EU regulation applies?"); // Both share the same original targetNodeId (distinct records for same node) expect(state.focusedContributions[0].targetNodeId).toBe("nk-001"); expect(state.focusedContributions[1].targetNodeId).toBe("nk-001"); }); it("session save/restore survives two deconstructs on same thread", () => { let state = { scenario: "test", situationGraph: {}, focusedContributions: [] }; // First contribution const contrib1Data = simulateDeconstructResponse({ targetNodeId: "nk-002", targetLabel: "Risk" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contrib1Data.targetNodeId, question: "Q1?", answer: "A1", ...contrib1Data, }); // Save let raw = simulateSaveSession(state); let restored = simulateRestoreSession(raw); expect(restored.focusedContributions).toHaveLength(1); // Second contribution (simulates reopening the same thread after reload) const contrib2Data = simulateDeconstructResponse({ targetNodeId: "nk-002", targetLabel: "Follow-up" }); restored.focusedContributions = simulateAppendContributions(restored.focusedContributions, { targetNodeId: contrib2Data.targetNodeId, question: "Q2?", answer: "A2", ...contrib2Data, }); // Save again raw = simulateSaveSession(restored); restored = simulateRestoreSession(raw); expect(restored.focusedContributions).toHaveLength(2); expect(restored.focusedContributions[0].question).toBe("Q1?"); expect(restored.focusedContributions[1].question).toBe("Q2?"); }); it("second deconstruct on DIFFERENT thread also appends correctly", () => { let state = { scenario: "test", situationGraph: {}, focusedContributions: [] }; // First: thread A const contribA = simulateDeconstructResponse({ targetNodeId: "nk-A", targetLabel: "Thread A" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contribA.targetNodeId, question: "A?", answer: "A1", ...contribA, }); // Second: thread B (user selects a different open question) const contribB = simulateDeconstructResponse({ targetNodeId: "nk-B", targetLabel: "Thread B" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contribB.targetNodeId, question: "B?", answer: "B1", ...contribB, }); expect(state.focusedContributions).toHaveLength(2); const threadAContribs = state.focusedContributions.filter((c) => c.targetNodeId === "nk-A"); const threadBContribs = state.focusedContributions.filter((c) => c.targetNodeId === "nk-B"); expect(threadAContribs).toHaveLength(1); expect(threadAContribs[0].question).toBe("A?"); expect(threadBContribs).toHaveLength(1); expect(threadBContribs[0].question).toBe("B?"); }); it("third contribution also appends -- no upper bound limit on count", () => { let contributions = []; for (let i = 1; i <= 5; i++) { const body = { targetNodeId: `nk-${i}`, targetLabel: `Thread ${i}` }; const data = simulateDeconstructResponse(body); contributions = simulateAppendContributions(contributions, { targetNodeId: data.targetNodeId, question: `Q${i}?`, answer: `A${i}`, ...data, }); expect(contributions).toHaveLength(i); expect(contributions[i - 1].id).toBe(`contrib-${String(i).padStart(4, "0")}`); expect(contributions[i - 1].sequence).toBe(i); } }); it("deconstruct failure does NOT append (preserves prior contributions)", () => { let state = { scenario: "test", situationGraph: {}, focusedContributions: [] }; // First contribution succeeds const contribData = simulateDeconstructResponse({ targetNodeId: "nk-ok", targetLabel: "OK" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contribData.targetNodeId, question: "Q?", answer: "A", ...contribData, }); expect(state.focusedContributions).toHaveLength(1); // Second "deconstruct" fails -- no append call occurs const beforeCount = state.focusedContributions.length; // Simulate failure: the contribution is NOT added expect(state.focusedContributions).toHaveLength(beforeCount); }); it("contribution fields survive exact LLM round-trip through append", () => { const rawLLMResponse = { targetNodeId: "nk-003", observations: [ "The founder has deep tacit knowledge of operational processes.", "Documentation exists but is outdated.", ], uncertainties: ["Whether the founder's knowledge can be transferred without loss."], assumptions: ["That the board understands the documentation gap."], relationships: [ { from: "founder", to: "processes", type: "holds" }, { from: "docs", to: "knowledge", type: "partially_captures" }, ], possibleFollowUpQuestions: [ "What processes are undocumented?", "Who in the board is most aware of this gap?", ], }; const contrib = simulateAppendContributions([], { ...rawLLMResponse, question: "How much operational knowledge is undocumented?", answer: "Most of it -- the founder's mind is the repository.", }); expect(contrib[0].id).toBeTruthy(); expect(contrib[0].targetNodeId).toBe("nk-003"); expect(contrib[0].observations).toEqual(rawLLMResponse.observations); expect(contrib[0].uncertainties).toEqual(rawLLMResponse.uncertainties); expect(contrib[0].assumptions).toEqual(rawLLMResponse.assumptions); expect(contrib[0].relationships).toEqual(rawLLMResponse.relationships); expect(contrib[0].possibleFollowUpQuestions).toEqual(rawLLMResponse.possibleFollowUpQuestions); }); it("full lifecycle: deconstruct -> append -> save -> reload -> second deconstruct -> append again", () => { // Phase 1: First deconstruct let state = { scenario: "test", situationGraph: {}, focusedContributions: [] }; const contrib1 = simulateDeconstructResponse({ targetNodeId: "nk-x", targetLabel: "Thread 1" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contrib1.targetNodeId, question: "Initial Q?", answer: "Initial A.", ...contrib1, }); // Save + reload simulates user leaving and returning let session = simulateSaveSession(state); state = simulateRestoreSession(session) || { scenario: "test", situationGraph: {}, focusedContributions: [] }; expect(state.focusedContributions).toHaveLength(1); expect(state.focusedContributions[0].question).toBe("Initial Q?"); // Phase 2: Second deconstruct after reload const contrib2 = simulateDeconstructResponse({ targetNodeId: "nk-x", targetLabel: "Thread 1 follow-up" }); state.focusedContributions = simulateAppendContributions(state.focusedContributions, { targetNodeId: contrib2.targetNodeId, question: "Follow-up Q?", answer: "Follow-up A.", ...contrib2, }); expect(state.focusedContributions).toHaveLength(2); expect(state.focusedContributions[0].question).toBe("Initial Q?"); // First preserved expect(state.focusedContributions[1].question).toBe("Follow-up Q?"); // Second appended expect(state.focusedContributions[0].targetNodeId).toBe(state.focusedContributions[1].targetNodeId); // Final save confirms both survive session = simulateSaveSession(state); state = simulateRestoreSession(session); expect(state.focusedContributions).toHaveLength(2); }); });