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:
@@ -581,6 +581,10 @@ function ThreadContributionsBadge({ nodeId, contributions }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<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 */}
|
{/* 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">
|
<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">
|
<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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user