feat(confidence-engine): support finding corrections

This commit is contained in:
2026-08-27 13:43:23 +01:00
parent 5c926154cd
commit 22e1d7484b
3 changed files with 292 additions and 3 deletions
+219
View File
@@ -812,3 +812,222 @@ describe("applyFindingsToSummary — disposition-aware", () => {
expect(content).toContain("notQuiteTexts");
});
});
// ── updateFindingProposition mutation seam (ScenarioForm) ───
describe("updateFindingProposition — ScenarioForm mutation seam", () => {
function simulateUpdateFindingProposition(findings, findingId, newProposition) {
return findings.map((f) =>
f.id === findingId
? { ...f, proposition: newProposition, userDisposition: null }
: f,
);
}
const baseFindings = [
{
id: "f1",
proposition: "Original text A",
userDisposition: null,
sourceObservation: "obs-001",
contributionId: "contrib-0001",
originatingTargetNodeId: "node-A",
createdAt: "2024-01-01T00:00:00Z",
status: "confirmed",
},
{
id: "f2",
proposition: "Original text B",
userDisposition: null,
sourceObservation: "obs-002",
contributionId: "contrib-0001",
originatingTargetNodeId: "node-B",
createdAt: "2024-01-01T00:01:00Z",
status: "confirmed",
},
];
it("target finding.id is unchanged", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].id).toBe("f1");
});
it("target proposition changes to new value", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "Corrected text A");
expect(result[0].proposition).toBe("Corrected text A");
});
it("target userDisposition resets to null", () => {
const findingsWithNull = baseFindings.map((f) => f.id === "f2" ? { ...f, userDisposition: "not_relevant" } : f);
const result = simulateUpdateFindingProposition(findingsWithNull, "f2", "Corrected text B");
expect(result[1].userDisposition).toBeNull();
});
it("sourceObservation remains unchanged on target", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].sourceObservation).toBe("obs-001");
});
it("contributionId remains unchanged on target", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].contributionId).toBe("contrib-0001");
});
it("finding.id remains unchanged on target (reconfirmed)", () => {
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result[0].id).toBe("f1");
});
it("another Finding (non-target) remains the exact existing object", () => {
const before = baseFindings.find((f) => f.id === "f2");
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
const after = result.find((f) => f.id === "f2");
expect(after).toBe(before); // same object reference
});
it("no Finding is added or removed — count unchanged", () => {
const beforeCount = baseFindings.length;
const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
expect(result.length).toBe(beforeCount);
});
});
// ── onUpdateFindingProposition prop chain verification ───────────
describe("onUpdateFindingProposition — prop chain verification", () => {
it("ScenarioForm exposes updateFindingProposition callback with correct arity", async () => {
const testFindings = [{ id: "f1", proposition: "P", userDisposition: null }];
const updateFindingProposition = (findingId, newProposition) => {
return testFindings.map((f) =>
f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
);
};
const updated = updateFindingProposition("f1", "Corrected");
expect(updated[0].proposition).toBe("Corrected");
expect(updated[0].userDisposition).toBeNull();
});
it("ReasoningWorkspace accepts onUpdateFindingProposition as prop", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain("onUpdateFindingProposition");
});
it("ReasoningWorkspace passes onUpdateFindingProposition to FocusedInvestigationWorkspace", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
const fiwsCall = content.match(/<FocusedInvestigationWorkspace[\s\S]*?\/>/g);
expect(fiwsCall).not.toBeNull();
expect(fiwsCall[0]).toContain("onUpdateFindingProposition");
});
it("ScenarioForm passes onUpdateFindingProposition to ReasoningWorkspace", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "scenario-form.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain("onUpdateFindingProposition={updateFindingProposition}");
});
it("no correction state introduced in ReasoningWorkspace — only FQB owns editingFindingId + draft", async () => {
const rwPath = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const rwContent = await fs.readFile(rwPath, "utf-8");
// ReasoningWorkspace should NOT declare editingFindingId or draft as its own state
// (they are only declared inside FocusedQuestionBody)
const reasonFuncMatch = rwContent.match(/export default function ReasoningWorkspace\([\s\S]*?return \(/);
expect(reasonFuncMatch).not.toBeNull();
const funcBody = reasonFuncMatch[0];
expect(funcBody).not.toMatch(/editingFindingId/);
expect(funcBody).not.toMatch(/setEditingFindingId/);
});
it("Not quite invokes correction for exact Finding.id", async () => {
// Simulate: user clicks "Not quite" → startEditing(id, proposition)
let state = { editingFindingId: null, draft: "" };
const startEditing = (id, proposition) => {
state.editingFindingId = id;
state.draft = proposition ?? "";
};
startEditing("f1", "Original text A");
expect(state.editingFindingId).toBe("f1");
expect(state.draft).toBe("Original text A");
});
it("cancel does not invoke canonical mutation", async () => {
let mutations = [];
const originalMutation = (id, val) => { mutations.push({ id, val }); };
let state = { editingFindingId: "f1", draft: "changed" };
// Simulate cancel — clears state without calling mutation
state.editingFindingId = null;
state.draft = "";
expect(mutations.length).toBe(0);
expect(state.editingFindingId).toBeNull();
expect(state.draft).toBe("");
});
it("valid save invokes proposition mutation with exact Finding.id + trimmed text", () => {
let mutations = [];
const mockMutation = (id, val) => { mutations.push({ id, val }); };
let state = { editingFindingId: "f1", draft: " corrected text " };
const saveEditing = () => {
const trimmed = (state.draft ?? "").trim();
if (!trimmed || !state.editingFindingId) { return; }
mockMutation(state.editingFindingId, trimmed);
state.editingFindingId = null;
state.draft = "";
};
saveEditing();
expect(mutations).toEqual([{ id: "f1", val: "corrected text" }]);
expect(state.editingFindingId).toBeNull();
});
it("whitespace-only save does not invoke mutation", () => {
let mutations = [];
const mockMutation = (id, val) => { mutations.push({ id, val }); };
let state = { editingFindingId: "f1", draft: " \n\t " };
const saveEditing = () => {
const trimmed = (state.draft ?? "").trim();
if (!trimmed || !state.editingFindingId) { return; }
mockMutation(state.editingFindingId, trimmed);
state.editingFindingId = null;
state.draft = "";
};
saveEditing();
expect(mutations.length).toBe(0);
expect(state.editingFindingId).not.toBeNull(); // state preserved when no-op
});
it("Not quite and Not relevant coexist on same Finding", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
// Both buttons should appear in the same rendering block for findings
const findingLiBlock = content.match(/<ul className="list-disc pl-5 space-y-2"[\s\S]*?<\/ul>/g);
expect(findingLiBlock).not.toBeNull();
expect(findingLiBlock[0]).toContain("Not quite");
expect(findingLiBlock[0]).toContain("not relevant");
});
it("restore button still present after Not quite introduced", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain(">restore<");
});
});