feat(confidence-engine): support finding corrections
This commit is contained in:
@@ -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<");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user