Files
confidence-engine/tests/focused-deconstruct-persistence.test.js
T
robbond 01c57788ee feat(confidence-engine): stabilize user-directed investigation flow
Intentional changes in this checkpoint:
- Deconstruct route: use body.targetNodeId (client identity) over raw.model-invented ID
- ThreadContributionsBadge: compact per-thread contribution indicator with expandable history
- Reopen continuation: resume from accumulated contributions instead of reformulating
- showEvidenceLimit gate: hide evidence-limit card during active investigation paths
- Evidence-limit visibility correction in rendering pipeline
- Section ordering: assumptions and connections after 'Still unclear' in focused result
- Prompt v0.3: preserve user-stated alternatives as separate unknowns; no count inflation
- 3 durable regression tests (target identity, contribution persistence, reopen state)
- evidence-limit card visibility gate test suite

Temporary residue removed:
- test-analysis.mjs (scratch diagnostic)
- 5 diagnostic console.log blocks from reasoning-workspace.jsx
2026-08-23 12:05:51 +01:00

279 lines
12 KiB
JavaScript

/**
* 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);
});
});