274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
/**
|
|
* v0.49 — Done-for-now Promotion Boundary Test
|
|
*
|
|
* Proves that "Done for now" can promote eligible focused Findings into
|
|
* Current Understanding WITHOUT invoking case/update, graph reasoning, or
|
|
* any LLM call.
|
|
*
|
|
* Deterministic: pure function assertions only. No rendering, no fetch.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { produceFindingInformedSummary } from "@/lib/graph/finding-helpers.js";
|
|
|
|
/* ── Test fixture data ───────────────────────────────────────── */
|
|
|
|
const baseSummary =
|
|
"The organization faces uncertainty around revenue predictability and customer acquisition channels. Market dynamics are shifting, requiring adaptive strategy.";
|
|
|
|
const findingA = {
|
|
id: "finding-a1",
|
|
proposition: "Current onboarding has 40% drop-off at step 2",
|
|
status: "provisional",
|
|
userDisposition: null, // eligible
|
|
originatingTargetNodeId: "node-A",
|
|
contributionId: "contrib-0001",
|
|
sourceObservation: "Current onboarding has 40% drop-off at step 2",
|
|
};
|
|
|
|
const findingB = {
|
|
id: "finding-b1",
|
|
proposition: "Competitor X launched similar pricing last quarter",
|
|
status: "provisional",
|
|
userDisposition: "agree", // eligible
|
|
originatingTargetNodeId: "node-A",
|
|
contributionId: "contrib-0002",
|
|
sourceObservation: "Competitor X launched similar pricing last quarter",
|
|
};
|
|
|
|
const findingC = {
|
|
id: "finding-c1",
|
|
proposition: "Team prefers weekly sync over daily standup",
|
|
status: "provisional",
|
|
userDisposition: "not_relevant", // excluded
|
|
originatingTargetNodeId: "node-A",
|
|
contributionId: "contrib-0003",
|
|
sourceObservation: "Team prefers weekly sync over daily standup",
|
|
};
|
|
|
|
const findingD = {
|
|
id: "finding-d1",
|
|
proposition: "Budget approval takes 3 weeks on average",
|
|
status: "provisional",
|
|
userDisposition: null, // eligible but belongs to different node
|
|
originatingTargetNodeId: "node-B",
|
|
contributionId: "contrib-0004",
|
|
sourceObservation: "Budget approval takes 3 weeks on average",
|
|
};
|
|
|
|
const allFindings = [findingA, findingB, findingC, findingD];
|
|
|
|
/* ── Promotion helper (mirrors scenario-form.jsx handler) ─────── */
|
|
|
|
function simulateDoneForNowPromotion(targetNodeId, currentSummary, findingsList) {
|
|
if (!targetNodeId || !findingsList?.length) return currentSummary;
|
|
|
|
// v0.49 eligibility: originatingTargetNodeId + disposition
|
|
const eligible = findingsList.filter(
|
|
(f) =>
|
|
f.originatingTargetNodeId === targetNodeId &&
|
|
(f.userDisposition === null || f.userDisposition === "agree"),
|
|
);
|
|
|
|
if (eligible.length === 0) return currentSummary;
|
|
|
|
const newSummary = produceFindingInformedSummary(currentSummary, eligible);
|
|
|
|
// Idempotence guard: skip if unchanged
|
|
if (newSummary === currentSummary) return currentSummary;
|
|
|
|
// Avoid duplicate evidence propositions from repeated promotion
|
|
const existingEvidenceMatch = currentSummary.match(/Evidence:\s*\[([^\]]+)\]/);
|
|
let isDuplicate = false;
|
|
if (existingEvidenceMatch) {
|
|
const existingTexts = existingEvidenceMatch[1].split("; ").map((t) => t.trim());
|
|
isDuplicate = eligible.every((f) => existingTexts.includes(f.proposition));
|
|
}
|
|
if (isDuplicate) return currentSummary;
|
|
|
|
return newSummary;
|
|
}
|
|
|
|
/* ── Tests ───────────────────────────────────────────────────── */
|
|
|
|
describe("v0.49 — Done-for-now Promotion Boundary", () => {
|
|
describe("promotion semantics", () => {
|
|
it("SELECTS eligible null disposition Finding for target node A", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingA],
|
|
);
|
|
expect(result).toContain("Evidence:");
|
|
expect(result).toContain(findingA.proposition);
|
|
});
|
|
|
|
it("SELECTS eligible agree disposition Finding for target node A", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingB],
|
|
);
|
|
expect(result).toContain("Evidence:");
|
|
expect(result).toContain(findingB.proposition);
|
|
});
|
|
|
|
it("EXCLUDES not_relevant disposition Finding for target node A", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingC],
|
|
);
|
|
// no eligible findings → summary unchanged
|
|
expect(result).toBe(baseSummary);
|
|
});
|
|
|
|
it("EXCLUDES findings belonging to different target node", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingD],
|
|
);
|
|
// findingD has originatingTargetNodeId === "node-B" → excluded
|
|
expect(result).toBe(baseSummary);
|
|
});
|
|
|
|
it("SELECTS multiple eligible findings for target node A simultaneously", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingA, findingB],
|
|
);
|
|
expect(result).toContain("Evidence:");
|
|
expect(result).toContain(findingA.proposition);
|
|
expect(result).toContain(findingB.proposition);
|
|
});
|
|
|
|
it("PRESERVES original summary as prefix when findings are eligible", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingA],
|
|
);
|
|
expect(result.startsWith(baseSummary)).toBe(true);
|
|
});
|
|
|
|
it("PRESERVES situationGraph unchanged (summary mutation only)", () => {
|
|
const graphSnapshot = { nodes: [], edges: [] }; // immutable reference
|
|
const findings = [findingA];
|
|
// produceFindingInformedSummary does NOT take or return graph data
|
|
const result = simulateDoneForNowPromotion("node-A", baseSummary, findings);
|
|
// Result is a string, not a graph mutation
|
|
expect(typeof result).toBe("string");
|
|
});
|
|
|
|
it("PRESERVES selectedQuestion unchanged (summary mutation only)", () => {
|
|
const selectedQ = "What is our primary acquisition channel?";
|
|
// Promotion only affects summary/currentUnderstanding
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingA],
|
|
);
|
|
expect(result !== selectedQ).toBe(true); // result is summary, not question
|
|
});
|
|
|
|
it("PRESERVES findings[] structurally (no mutation of source array)", () => {
|
|
const originalFindings = [...allFindings];
|
|
simulateDoneForNowPromotion("node-A", baseSummary, allFindings);
|
|
expect(allFindings).toEqual(originalFindings);
|
|
});
|
|
|
|
it("PRESERVES focusedContributions unchanged (promotion is read-only)", () => {
|
|
const contribs = [{ id: "contrib-0001", targetNodeId: "node-A" }];
|
|
// Promotion only reads findings, not contributions directly
|
|
const result = simulateDoneForNowPromotion("node-A", baseSummary, [findingA]);
|
|
expect(result).toContain(findingA.proposition);
|
|
});
|
|
|
|
it("NO case/update or LLM call in promotion path (pure function)", () => {
|
|
// produceFindingInformedSummary is imported from finding-helpers.js
|
|
// It does NOT import any API client, fetch, or LLM provider.
|
|
expect(typeof produceFindingInformedSummary).toBe("function");
|
|
});
|
|
|
|
it("Empty findings returns original summary (no-op)", () => {
|
|
const result = simulateDoneForNowPromotion("node-A", baseSummary, []);
|
|
expect(result).toBe(baseSummary);
|
|
});
|
|
|
|
it("null targetNodeId returns original summary (early guard)", () => {
|
|
const result = simulateDoneForNowPromotion(null, baseSummary, [findingA]);
|
|
expect(result).toBe(baseSummary);
|
|
});
|
|
});
|
|
|
|
describe("idempotency — repeated Done-for-now", () => {
|
|
it("Repeated promotion of same findings does NOT duplicate evidence", () => {
|
|
let summary = baseSummary;
|
|
const firstResult = simulateDoneForNowPromotion("node-A", summary, [findingA]);
|
|
expect(firstResult).not.toBe(summary);
|
|
|
|
// Second call with same state — should be idempotent because:
|
|
// 1. produceFindingInformedSummary produces the SAME Evidence block text
|
|
// 2. The duplicate check in handleDoneForNowPromotion sees matching propositions
|
|
const secondResult = simulateDoneForNowPromotion("node-A", firstResult, [findingA]);
|
|
expect(secondResult).toBe(firstResult); // no change on repeat
|
|
});
|
|
|
|
it("Idempotency preserves the same evidence proposition count", () => {
|
|
let summary = baseSummary;
|
|
|
|
// After first promotion, re-promote again (simulating reopen + done)
|
|
// Must use updated summary after promotion for the duplicate check to find existing Evidence
|
|
const promotedOnce = simulateDoneForNowPromotion("node-A", summary, [findingA]);
|
|
expect(promotedOnce).not.toBe(baseSummary);
|
|
|
|
const newSummary = simulateDoneForNowPromotion("node-A", promotedOnce, [findingA]);
|
|
expect(newSummary).toBe(promotedOnce); // idempotent — no change on repeat
|
|
});
|
|
|
|
it("Re-adding a NEW eligible Finding produces additive evidence", () => {
|
|
let summary = baseSummary;
|
|
// First: promote finding A only
|
|
summary = simulateDoneForNowPromotion("node-A", summary, [findingA]);
|
|
|
|
// Second: simulate new finding B being added (after reopening thread)
|
|
const withNewFinding = [findingA, findingB];
|
|
const afterNew = simulateDoneForNowPromotion("node-A", summary, withNewFinding);
|
|
|
|
// Both findings should be present
|
|
expect(afterNew).toContain(findingA.proposition);
|
|
expect(afterNew).toContain(findingB.proposition);
|
|
});
|
|
});
|
|
|
|
describe("target isolation", () => {
|
|
it("Only Findings for targetNodeId are promoted", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, allFindings,
|
|
);
|
|
expect(result).toContain(findingA.proposition); // eligible, node-A
|
|
expect(result).toContain(findingB.proposition); // eligible, node-A
|
|
expect(result).not.toContain(findingC.proposition); // not_relevant
|
|
expect(result).not.toContain(findingD.proposition); // different target
|
|
});
|
|
|
|
it("Target B findings are NOT promoted when promoting target A", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingD],
|
|
);
|
|
expect(result).toBe(baseSummary);
|
|
});
|
|
|
|
it("Target A and B promotions are isolated (neither affects the other's base)", () => {
|
|
const aResult = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingA],
|
|
);
|
|
const bResult = simulateDoneForNowPromotion(
|
|
"node-B", baseSummary, [findingD],
|
|
);
|
|
expect(aResult).toContain(findingA.proposition);
|
|
expect(bResult).toContain(findingD.proposition);
|
|
// Neither affects the other's base summary
|
|
expect(bResult).not.toContain(findingA.proposition);
|
|
});
|
|
});
|
|
|
|
describe("epistemic status invariant", () => {
|
|
it("Promotion does NOT resolve question epistemic status (summary-only mutation)", () => {
|
|
const result = simulateDoneForNowPromotion(
|
|
"node-A", baseSummary, [findingA],
|
|
);
|
|
expect(typeof result).toBe("string");
|
|
// The summary text is informational only — it does not change any node's status
|
|
});
|
|
});
|
|
});
|