277 lines
15 KiB
JavaScript
277 lines
15 KiB
JavaScript
import { describe, expect, it } from "vitest";
|
|
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
|
|
|
|
// ── Test fixtures ──────────────────────────────────────────
|
|
|
|
function makeSituationGraph() {
|
|
return {
|
|
centralStatement: "Test scenario",
|
|
nodes: [{ id: "a-unknown", label: "A Unknown", kind: "unknown", status: "unknown" }, { id: "b-unknown", label: "B Unknown", kind: "unknown", status: "unknown" }],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Baseline summary",
|
|
};
|
|
}
|
|
|
|
function makeContributions({ ids = ["contrib-0001", "contrib-0002", "contrib-0003"], targetNodeId = "a-unknown", sequenceValues = [1, 2, 3], questions = [], answers = [] } = {}) {
|
|
return ids.map((id, i) => ({
|
|
id,
|
|
targetNodeId: targetNodeId === "both" ? (i < 2 ? "a-unknown" : "b-unknown") : targetNodeId,
|
|
sequence: sequenceValues[i] ?? i + 1,
|
|
question: questions[i] ?? `Question ${i + 1}`,
|
|
answer: answers[i] ?? `Answer ${i + 1}`,
|
|
observations: [`observation-${id}`],
|
|
}));
|
|
}
|
|
|
|
function makeFindings(contribIds) {
|
|
return contribIds.map((contribId, i) => ({
|
|
id: `finding-find-${String(i).padStart(2, "0")}`,
|
|
proposition: `Proposition ${i + 1}`,
|
|
status: "provisional",
|
|
userDisposition: null,
|
|
originatingTargetNodeId: contribId.startsWith("find-a") ? "a-unknown" : "b-unknown",
|
|
contributionId: contribId,
|
|
sourceObservation: `Source observation ${i + 1}`,
|
|
}));
|
|
}
|
|
|
|
// ── 1. Target scoping ─────────────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — target scoping", () => {
|
|
it("only includes Contributions for the completed target", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = [
|
|
{ id: "contrib-a1", targetNodeId: "a-unknown", sequence: 1, question: "Q1?", answer: "A1" },
|
|
{ id: "contrib-b1", targetNodeId: "b-unknown", sequence: 1, question: "Q2?", answer: "A2" },
|
|
{ id: "contrib-a2", targetNodeId: "a-unknown", sequence: 2, question: "Q3?", answer: "A3" },
|
|
];
|
|
const findings = [
|
|
{ id: "f1", proposition: "P1", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-a1", sourceObservation: "S1" },
|
|
{ id: "f2", proposition: "P2", status: "provisional", userDisposition: null, originatingTargetNodeId: "b-unknown", contributionId: "contrib-b1", sourceObservation: "S2" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result.targetNodeId).toBe("a-unknown");
|
|
expect(result.turns).toHaveLength(2);
|
|
expect(result.turns.map((t) => t.contributionId)).toEqual(["contrib-a1", "contrib-a2"]);
|
|
// Findings from non-scoped Contributions must not appear in eligible or excluded
|
|
const allBucketedIds = new Set([...result.eligibleCanonicalFindings, ...result.excludedFindingProvenance].map((f) => f.findingId));
|
|
expect(allBucketedIds.has("f2")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── 2. Ordering ────────────────────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — ordering", () => {
|
|
it("orders contributions by sequence ascending regardless of input order", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = [
|
|
{ id: "contrib-0003", targetNodeId: "a-unknown", sequence: 3, question: "Q3?", answer: "A3" },
|
|
{ id: "contrib-0001", targetNodeId: "a-unknown", sequence: 1, question: "Q1?", answer: "A1" },
|
|
{ id: "contrib-0002", targetNodeId: "a-unknown", sequence: 2, question: "Q2?", answer: "A2" },
|
|
];
|
|
const findings = [];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result.turns.map((t) => t.sequence)).toEqual([1, 2, 3]);
|
|
expect(result.turns[0].contributionId).toBe("contrib-0001");
|
|
expect(result.turns[1].contributionId).toBe("contrib-0002");
|
|
expect(result.turns[2].contributionId).toBe("contrib-0003");
|
|
});
|
|
});
|
|
|
|
// ── 3. Eligibility ─────────────────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — eligibility bucketing", () => {
|
|
it("routes null and agree to eligibleCanonicalFindings", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = makeContributions({ ids: ["contrib-e1"], targetNodeId: "a-unknown" });
|
|
const findings = [
|
|
{ id: "find-null", proposition: "Null prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-e1", sourceObservation: "Source null" },
|
|
{ id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "a-unknown", contributionId: "contrib-e1", sourceObservation: "Source agree" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result.eligibleCanonicalFindings).toHaveLength(2);
|
|
expect(result.excludedFindingProvenance).toHaveLength(0);
|
|
});
|
|
|
|
it("routes not_relevant and not_quite to excludedFindingProvenance", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = makeContributions({ ids: ["contrib-x1", "contrib-x2"], targetNodeId: "a-unknown" });
|
|
const findings = [
|
|
{ id: "find-notrel", proposition: "Not rel prop", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "a-unknown", contributionId: "contrib-x1", sourceObservation: "Source NR" },
|
|
{ id: "find-notquite", proposition: "Not quite prop", status: "provisional", userDisposition: "not_quite", originatingTargetNodeId: "a-unknown", contributionId: "contrib-x2", sourceObservation: "Source NQ" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result.eligibleCanonicalFindings).toHaveLength(0);
|
|
expect(result.excludedFindingProvenance).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ── 4. Silence is not agreement ───────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — silence is not agreement", () => {
|
|
it("preserves null as distinguishable from agree in endorsement field", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = makeContributions({ ids: ["contrib-s1", "contrib-s2"], targetNodeId: "a-unknown" });
|
|
const findings = [
|
|
{ id: "find-null", proposition: "Silence prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-s1", sourceObservation: "Source silence" },
|
|
{ id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "a-unknown", contributionId: "contrib-s2", sourceObservation: "Source agree" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
const nullFinding = result.eligibleCanonicalFindings.find((f) => f.findingId === "find-null");
|
|
const agreeFinding = result.eligibleCanonicalFindings.find((f) => f.findingId === "find-agree");
|
|
|
|
expect(nullFinding.endorsement).toBe(null);
|
|
expect(agreeFinding.endorsement).toBe("agree");
|
|
expect(nullFinding.endorsement !== agreeFinding.endorsement).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── 5. Correction provenance ──────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — correction provenance", () => {
|
|
it("preserves distinct proposition and sourceObservation for corrected eligible Finding", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = makeContributions({ ids: ["contrib-c1"], targetNodeId: "a-unknown" });
|
|
const findings = [
|
|
{
|
|
id: "find-corrected",
|
|
proposition: "Corrected canonical text — user fixed the observation",
|
|
status: "provisional",
|
|
userDisposition: null,
|
|
originatingTargetNodeId: "a-unknown",
|
|
contributionId: "contrib-c1",
|
|
sourceObservation: "Original model observation that was corrected",
|
|
},
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result.eligibleCanonicalFindings).toHaveLength(1);
|
|
const eligible = result.eligibleCanonicalFindings[0];
|
|
expect(eligible.proposition).toBe("Corrected canonical text — user fixed the observation");
|
|
expect(eligible.sourceObservation).toBe("Original model observation that was corrected");
|
|
expect(eligible.proposition).not.toBe(eligible.sourceObservation);
|
|
});
|
|
});
|
|
|
|
// ── 6. Excluded provenance ────────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — excluded provenance", () => {
|
|
it("retains proposition, sourceObservation, disposition, contributionId for not_relevant Finding", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = makeContributions({ ids: ["contrib-ep1"], targetNodeId: "a-unknown" });
|
|
const findings = [
|
|
{ id: "find-notrel", proposition: "Not relevant proposition text", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "a-unknown", contributionId: "contrib-ep1", sourceObservation: "Immutable original observation" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result.excludedFindingProvenance).toHaveLength(1);
|
|
const excluded = result.excludedFindingProvenance[0];
|
|
expect(excluded.findingId).toBe("find-notrel");
|
|
expect(excluded.proposition).toBe("Not relevant proposition text");
|
|
expect(excluded.sourceObservation).toBe("Immutable original observation");
|
|
expect(excluded.disposition).toBe("not_relevant");
|
|
expect(excluded.contributionId).toBe("contrib-ep1");
|
|
});
|
|
});
|
|
|
|
// ── 7. Membership authority ───────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — membership authority", () => {
|
|
it("excludes Findings whose contributionId does not reference a scoped Contribution even if originatingTargetNodeId matches", () => {
|
|
const graph = makeSituationGraph();
|
|
// contrib-xtra targets "b-unknown" so it is NOT part of the scoped episode for target A
|
|
const contributions = [
|
|
{ id: "contrib-a1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
|
{ id: "contrib-xtra", targetNodeId: "b-unknown", sequence: 1, question: "Q2?", answer: "A2" },
|
|
];
|
|
// Finding references contrib-xtra which is not scoped to target A.
|
|
// Even though originatingTargetNodeId === "a-unknown", membership is determined by contributionId.
|
|
const findings = [
|
|
{ id: "find-orphan", proposition: "Orphan prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-xtra", sourceObservation: "Orig obs" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
// Nothing should be bucketed because the only Finding references a non-scoped Contribution
|
|
expect(result.eligibleCanonicalFindings).toHaveLength(0);
|
|
expect(result.excludedFindingProvenance).toHaveLength(0);
|
|
|
|
const allBucketed = [...result.eligibleCanonicalFindings, ...result.excludedFindingProvenance];
|
|
expect(allBucketed.some((f) => f.findingId === "find-orphan")).toBe(false);
|
|
});
|
|
|
|
it("includes scoped Finding as eligible when its contributionId references a scoped Contribution", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = [
|
|
{ id: "contrib-val1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
|
];
|
|
const findings = [
|
|
{ id: "find-valid2", proposition: "Valid prop 2", status: "provisional", userDisposition: null, originatingTargetNodeId: "x-unknown", contributionId: "contrib-val1", sourceObservation: "Orig valid" },
|
|
];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
// Even though originatingTargetNodeId !== targetNodeId, the Contribution provenance chain authorizes inclusion
|
|
expect(result.eligibleCanonicalFindings).toHaveLength(1);
|
|
expect(result.eligibleCanonicalFindings[0].findingId).toBe("find-valid2");
|
|
});
|
|
});
|
|
|
|
// ── 8. Purity ──────────────────────────────────────────────
|
|
|
|
describe("prepareCompletedEpisode — purity", () => {
|
|
it("does not mutate situationGraph, contributions, or findings", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = [
|
|
{ id: "contrib-p1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
|
];
|
|
const findings = [
|
|
{ id: "find-p1", proposition: "Prop P", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-p1", sourceObservation: "Source P" },
|
|
];
|
|
|
|
// Snapshot originals before call
|
|
const graphSnapshot = JSON.parse(JSON.stringify(graph));
|
|
const contribsSnapshot = contributions.map((c) => JSON.parse(JSON.stringify(c)));
|
|
const findingsSnapshot = findings.map((f) => JSON.parse(JSON.stringify(f)));
|
|
|
|
prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(graph).toEqual(graphSnapshot);
|
|
expect(contributions).toEqual(contribsSnapshot);
|
|
expect(findings).toEqual(findingsSnapshot);
|
|
|
|
// Verify no new properties were added to inputs
|
|
expect(Object.keys(graph)).toEqual(Object.keys(graphSnapshot));
|
|
expect(Object.keys(contributions[0])).toEqual(Object.keys(contribsSnapshot[0]));
|
|
expect(Object.keys(findings[0])).toEqual(Object.keys(findingsSnapshot[0]));
|
|
});
|
|
|
|
it("returns a new object that is independent from inputs", () => {
|
|
const graph = makeSituationGraph();
|
|
const contributions = [
|
|
{ id: "contrib-i1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
|
];
|
|
const findings = [];
|
|
|
|
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
|
|
|
expect(result).not.toBe(graph);
|
|
expect(result).not.toBe(contributions);
|
|
expect(result).not.toBe(findings);
|
|
});
|
|
});
|