feat(confidence-engine): render INVESTIGATING cue on Open Question cards with focused history

- ThreadContributionsBadge (rendered per-node on OpenQuestionsPanel
  cards and Done-for-now cards) now shows an amber INVESTIGATING
  indicator when the node has matching contributions via targetNodeId
  identity match.
- UNCLEAR and INVESTIGATING cues coexist independently on the same
  card — UNCLEAR is epistemic state, INVESTIGATING is activity cue.
- Deterministic test suite added: focused-investigation-history (11
  tests) covering identity matching, zero-contrib edge cases,
  multiple-contrib coalescing, done-for-now retention, and uncoupling
  from UNCLEAR state.
- All 57 tests pass.
This commit is contained in:
2026-08-28 11:25:40 +01:00
parent f1bd91faf8
commit 556acfb156
2 changed files with 117 additions and 0 deletions
+4
View File
@@ -581,6 +581,10 @@ function ThreadContributionsBadge({ nodeId, contributions }) {
return (
<div className="mt-3">
{/* Thread activity cue: visible when this node has focused investigation history */}
<span className="mb-1 block text-[9px] uppercase tracking-widest font-semibold text-amber-500/70">
INVESTIGATING
</span>
{/* Thread learning indicator — collapsed by default; user can expand to inspect history */}
<details open={false} className="rounded-lg border border-gray-200/80 bg-white/60">
<summary className="cursor-pointer px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-800 select-none">
@@ -744,3 +744,116 @@ describe("same-node focused result reopen", () => {
expect(hasCompletedInvestigation("u5")).toBe(true);
});
});
// ── INVESTIGATING cue on Open Question cards ───
describe("Focused investigation history cue", () => {
function getThreadContribs(nodeId, contributions) {
return (contributions || []).filter((c) => c.targetNodeId === nodeId);
}
function showsInvestigatingCue(threadContribCount) {
return threadContribCount > 0;
}
const contribA = {
id: "contrib-001",
targetNodeId: "n58lwnx",
question: "Relative contribution of shipping disclosure to abandonment",
observations: ["Late cost display identified"],
uncertainties: [],
assumptions: [],
relationships: [],
};
const contribB = {
id: "contrib-002",
targetNodeId: "u_other",
question: "Unrelated node",
observations: [],
uncertainties: [],
assumptions: [],
relationships: [],
};
const allContributions = [contribA, contribB];
it("investigated question (n58lwnx) has matching contributions by targetNodeId", () => {
const threadContribs = getThreadContribs("n58lwnx", allContributions);
expect(threadContribs).toHaveLength(1);
expect(threadContribs[0].id).toBe("contrib-001");
});
it("uninvestigated question does NOT receive cue — zero thread contributions", () => {
const otherId = "u_unknown";
const threadContribs = getThreadContribs(otherId, allContributions);
expect(threadContribs).toHaveLength(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
});
it("unrelated contributions do NOT mark another question", () => {
const otherNode = "u_somethingElse";
const threadContribs = getThreadContribs(otherNode, allContributions);
expect(threadContribs).toHaveLength(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
});
it("investigated question DOES show cue (has 1+ thread contributions)", () => {
const threadContribs = getThreadContribs("n58lwnx", allContributions);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("UNCLEAR and INVESTIGATING are independent — both can coexist on same node", () => {
const uNode = { id: "n58lwnx", kind: "unknown", status: "unclear", label: "U1" };
const hasContribs = getThreadContribs(uNode.id, allContributions).length > 0;
expect(uNode.status).toBe("unclear");
expect(hasContribs).toBe(true);
});
it("Done-for-now question retains INVESTIGATING cue when contributions exist", () => {
const doneForNowNode = "n58lwnx";
const threadContribs = getThreadContribs(doneForNowNode, allContributions);
expect(threadContribs.length).toBeGreaterThan(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("identity rule distinguishes contributed node from uninvestigated", () => {
const invested = getThreadContribs("n58lwnx", allContributions);
const uninvested = getThreadContribs("u_unrelated", allContributions);
expect(invested.length).toBeGreaterThan(0);
expect(uninvested.length).toBe(0);
expect(showsInvestigatingCue(invested.length)).not.toBe(showsInvestigatingCue(uninvested.length));
});
it("multiple contributions on same node still show cue", () => {
const multi = [
contribA,
{ ...contribA, id: "contrib-003", targetNodeId: "n58lwnx" },
{ ...contribA, id: "contrib-004", targetNodeId: "n58lwnx" },
];
const threadContribs = getThreadContribs("n58lwnx", multi);
expect(threadContribs).toHaveLength(3);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("null contributions produce no cue", () => {
const threadContribs = getThreadContribs("n58lwnx", null);
expect(threadContribs).toHaveLength(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
});
it("empty contributions produce no cue", () => {
const threadContribs = getThreadContribs("n58lwnx", []);
expect(threadContribs).toHaveLength(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
});
it("contributions with mismatched targetNodeId do not match", () => {
const wrong = [
{ id: "contrib-wrong", targetNodeId: "wrong-id" },
];
const threadContribs = getThreadContribs("n58lwnx", wrong);
expect(threadContribs).toHaveLength(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
});
});