363 lines
11 KiB
JavaScript
363 lines
11 KiB
JavaScript
import { describe, expect, it } from "vitest";
|
|
import { parseGraphUpdateProposal } from "@/lib/graph/update-proposal.js";
|
|
import { applyGraphUpdate } from "@/lib/graph/utils.js";
|
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
|
|
|
function makeValidProposal(overrides = {}) {
|
|
return {
|
|
addedNodes: [],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n-unknown",
|
|
previousStatus: "unknown",
|
|
newStatus: "resolved",
|
|
previousValue: null,
|
|
newValue: "1.9 complaints per 100 units",
|
|
reason: "The answer directly provides the normalized complaint rate.",
|
|
},
|
|
],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: ["n-unknown"],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
answerMeaning: {
|
|
userSupportedMeaning:
|
|
"The user directly provided the updated complaint rate.",
|
|
possibleInference: null,
|
|
supportCategory: "other",
|
|
resolutionGuidance: "may_resolve",
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("parseGraphUpdateProposal", () => {
|
|
it("parses a valid proposal", () => {
|
|
const result = parseGraphUpdateProposal(makeValidProposal());
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.updatedNodes).toHaveLength(1);
|
|
});
|
|
|
|
it("fails on malformed JSON", () => {
|
|
const result = parseGraphUpdateProposal("{not json");
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("fails when required update content is invalid", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
updatedNodes: [{ nodeId: "n-unknown" }],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("removes null array entries and logs them", () => {
|
|
const result = parseGraphUpdateProposal(
|
|
JSON.stringify({
|
|
...makeValidProposal(),
|
|
addedNodes: [null],
|
|
}),
|
|
);
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.addedNodes).toEqual([]);
|
|
expect(result.normalisationsApplied).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ change: "Removed null array entry" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("fills missing optional arrays with empty arrays", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
updatedNodes: [],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.addedNodes).toEqual([]);
|
|
expect(result.proposal.addedEdges).toEqual([]);
|
|
expect(result.normalisationsApplied.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("normalises confirmed node-kind alias and preserves IDs", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
addedNodes: [
|
|
{
|
|
id: "n-new",
|
|
label: "Reported update",
|
|
description: "A new reported claim",
|
|
kind: "reported_statement",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.addedNodes[0].kind).toBe("reported_claim");
|
|
expect(result.proposal.addedNodes[0].id).toBe("n-new");
|
|
});
|
|
|
|
it("rejects supportCategory values outside the existing enum", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
answerMeaning: {
|
|
userSupportedMeaning:
|
|
"I'd normally avoid more risk, but for the right opportunity I might accept some.",
|
|
possibleInference:
|
|
"This may support later clarification, but the condition remains material.",
|
|
supportCategory: "conditional_preference",
|
|
resolutionGuidance:
|
|
"Identify and quantify the threshold conditions that trigger risk acceptance.",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects the earlier live Regression B wording variant when it is outside the existing enum", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
answerMeaning: {
|
|
userSupportedMeaning:
|
|
"I'd normally avoid more risk, but for the right opportunity I might accept some.",
|
|
possibleInference:
|
|
"This may support later clarification, but the condition remains material.",
|
|
supportCategory: "conditional_qualification",
|
|
resolutionGuidance: "may resolve once the condition is clarified",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("accepts valid structured answerMeaning enum values", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
answerMeaning: {
|
|
userSupportedMeaning:
|
|
"I'd normally avoid more risk, but for the right opportunity I might accept some.",
|
|
possibleInference:
|
|
"This may support later clarification, but the condition remains material.",
|
|
supportCategory: "conditional_tradeoff",
|
|
resolutionGuidance: "may_resolve",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.answerMeaning).toMatchObject({
|
|
supportCategory: "conditional_tradeoff",
|
|
resolutionGuidance: "may_resolve",
|
|
});
|
|
});
|
|
|
|
it("normalises the observed external relationship wording affects to canonical other at the proposal boundary", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
addedEdges: [
|
|
{
|
|
id: "e-affects",
|
|
fromNodeId: "n-driver",
|
|
toNodeId: "n-unknown",
|
|
relationship: "affects",
|
|
confidence: "medium",
|
|
description: "Model-style relationship wording from live output.",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.addedEdges[0].relationship).toBe("other");
|
|
expect(result.normalisationsApplied).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ change: "Converted affects to other" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("leaves already-valid canonical relationship values unchanged", () => {
|
|
const canonicalRelationships = [
|
|
"supports",
|
|
"weakens",
|
|
"contradicts",
|
|
"depends_on",
|
|
"causes",
|
|
"may_cause",
|
|
"measures",
|
|
"compares_with",
|
|
"updates",
|
|
"other",
|
|
];
|
|
|
|
for (const relationship of canonicalRelationships) {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
addedEdges: [
|
|
{
|
|
id: `e-${relationship}`,
|
|
fromNodeId: "n-driver",
|
|
toNodeId: "n-unknown",
|
|
relationship,
|
|
confidence: "medium",
|
|
description: `Canonical relationship ${relationship}`,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.addedEdges[0].relationship).toBe(relationship);
|
|
}
|
|
});
|
|
|
|
it("still rejects unsupported arbitrary relationship values", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
addedEdges: [
|
|
{
|
|
id: "e-unsupported",
|
|
fromNodeId: "n-driver",
|
|
toNodeId: "n-unknown",
|
|
relationship: "influences_decisively",
|
|
confidence: "medium",
|
|
description: "Unsupported relationship wording.",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("applies only the canonical internal relationship after normalisation", () => {
|
|
const parsed = parseGraphUpdateProposal({
|
|
...makeValidProposal({
|
|
updatedNodes: [],
|
|
resolvedUnknownNodeIds: [],
|
|
}),
|
|
addedEdges: [
|
|
{
|
|
id: "e-affects",
|
|
fromNodeId: "n-driver",
|
|
toNodeId: "n-unknown",
|
|
relationship: "affects",
|
|
confidence: "medium",
|
|
description: "Model-style relationship wording from live output.",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(parsed.success).toBe(true);
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "Relationship application fixture",
|
|
nodes: [
|
|
makeNode({ id: "n-driver", label: "Driver", status: "known" }),
|
|
makeNode({ id: "n-unknown", label: "Unknown" }),
|
|
],
|
|
edges: [],
|
|
currentSummary: "Initial summary",
|
|
});
|
|
|
|
const applied = applyGraphUpdate(graph, parsed.proposal);
|
|
|
|
expect(applied.success).toBe(true);
|
|
expect(applied.edges).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ relationship: "other" }),
|
|
]),
|
|
);
|
|
expect(applied.edges.some((edge) => edge.relationship === "affects")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("unknown node enum values still fail", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
addedNodes: [
|
|
{
|
|
id: "n-new",
|
|
label: "Bad node",
|
|
description: "Bad node",
|
|
kind: "unsupported_kind",
|
|
status: "supported",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
},
|
|
],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects unsupported answerMeaning enum labels at parse time", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
...makeValidProposal(),
|
|
answerMeaning: {
|
|
userSupportedMeaning: "Risk and growth are both important.",
|
|
possibleInference: null,
|
|
supportCategory: "constraint_preference_mix",
|
|
resolutionGuidance: "needs more nuance",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("defaults missing selectedQuestion to null", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.selectedQuestion).toBeNull();
|
|
});
|
|
|
|
it("defaults missing answerMeaning to null", () => {
|
|
const result = parseGraphUpdateProposal({
|
|
addedNodes: [],
|
|
updatedNodes: [],
|
|
addedEdges: [],
|
|
removedEdgeIds: [],
|
|
resolvedUnknownNodeIds: [],
|
|
affectedNodeIds: [],
|
|
selectedQuestion: null,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.answerMeaning).toBeNull();
|
|
});
|
|
|
|
it("parses a valid selectedQuestion", () => {
|
|
const result = parseGraphUpdateProposal(
|
|
makeValidProposal({
|
|
selectedQuestion: {
|
|
nodeId: "n-follow-up",
|
|
question: "How should commercial value be defined for this decision?",
|
|
reason: "A consequential unknown remains unresolved.",
|
|
},
|
|
}),
|
|
);
|
|
expect(result.success).toBe(true);
|
|
expect(result.proposal.selectedQuestion?.nodeId).toBe("n-follow-up");
|
|
});
|
|
|
|
it("does not invent a next question field outside the contract", () => {
|
|
const result = parseGraphUpdateProposal(makeValidProposal());
|
|
expect(result.proposal.nextQuestion).toBeUndefined();
|
|
});
|
|
});
|