Extracts citation-evidence/src/binder/ into this repo as the standalone @citation-evidence/evidence-binder package: headless binding service + in-memory link repo, active-state machine, the SharedContracts §7 rect-registry contract (registry, change pumps, hooks, SVG overlay), and the target-neutral reference FormRenderer. - toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint); imports rewritten from @shared/@engine aliases to the engine's @citation-evidence/engine package specifiers - dependency boundary (engine + anchor only; no source/work/umbrella) enforced via eslint no-restricted-imports - docs: extraction inventory + contract deltas, ADR-0001 (reference UI kept as supported exports), refreshed README/SCOPE/INTENT, populated capabilities index - typecheck + lint green, 37 tests passing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
/**
|
|
* Binding service + in-memory link repo tests.
|
|
*
|
|
* Exercises every public surface plus the §4 events the service emits.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import type {
|
|
EvidenceLink,
|
|
EvidenceTarget,
|
|
} from "@citation-evidence/engine/shared";
|
|
import type {
|
|
EvidenceItemId,
|
|
EvidenceLinkId,
|
|
} from "@citation-evidence/engine/shared";
|
|
|
|
import { createEventBus } from "@citation-evidence/engine";
|
|
import type { EngineEvent } from "@citation-evidence/engine";
|
|
|
|
import { createInMemoryLinkRepo } from "../repos/in-memory-links";
|
|
import { createBindingService } from "./bindings";
|
|
|
|
function makeFixture() {
|
|
const bus = createEventBus();
|
|
const repo = createInMemoryLinkRepo();
|
|
const events: EngineEvent[] = [];
|
|
bus.onAny((e) => events.push(e));
|
|
let counter = 0;
|
|
const now = () => `2026-05-25T00:00:0${counter++}.000Z`;
|
|
const service = createBindingService(repo, bus, now);
|
|
return { bus, repo, events, service };
|
|
}
|
|
|
|
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
|
|
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
|
|
const EV1 = "ev_test_one" as EvidenceItemId;
|
|
const EV2 = "ev_test_two" as EvidenceItemId;
|
|
|
|
describe("createBindingService", () => {
|
|
it("linkEvidenceToTarget creates a link, emits EvidenceLinkCreated, and persists it", () => {
|
|
const { service, repo, events } = makeFixture();
|
|
|
|
const link = service.linkEvidenceToTarget({
|
|
evidenceItemId: EV1,
|
|
target: FIELD_A,
|
|
});
|
|
|
|
expect(link.evidenceItemId).toBe(EV1);
|
|
expect(link.targetType).toBe("form-field");
|
|
expect(link.targetId).toBe("summary");
|
|
expect(link.relation).toBe("supports");
|
|
expect(link.status).toBe("candidate");
|
|
expect(link.createdAt).toBe(link.updatedAt);
|
|
|
|
expect(repo.get(link.id)).toEqual(link);
|
|
|
|
const created = events.filter((e) => e.type === "EvidenceLinkCreated");
|
|
expect(created).toHaveLength(1);
|
|
expect(created[0]).toMatchObject({ linkId: link.id, link });
|
|
});
|
|
|
|
it("honours explicit relation/status/confidence", () => {
|
|
const { service } = makeFixture();
|
|
|
|
const link = service.linkEvidenceToTarget({
|
|
evidenceItemId: EV1,
|
|
target: FIELD_A,
|
|
relation: "contradicts",
|
|
status: "conflicting",
|
|
confidence: 0.42,
|
|
createdBy: "tegwick",
|
|
});
|
|
|
|
expect(link.relation).toBe("contradicts");
|
|
expect(link.status).toBe("conflicting");
|
|
expect(link.confidence).toBe(0.42);
|
|
expect(link.createdBy).toBe("tegwick");
|
|
});
|
|
|
|
it("listEvidenceForTarget returns only links for the requested target", () => {
|
|
const { service } = makeFixture();
|
|
const a1 = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
|
|
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_B });
|
|
const a2 = service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
|
|
|
|
const linksForA = service.listEvidenceForTarget(FIELD_A);
|
|
expect(linksForA.map((l) => l.id).sort()).toEqual([a1.id, a2.id].sort());
|
|
});
|
|
|
|
it("listTargetsForEvidence returns all targets an evidence item is linked to", () => {
|
|
const { service } = makeFixture();
|
|
const a = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
|
|
const b = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_B });
|
|
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
|
|
|
|
const targets = service.listTargetsForEvidence(EV1);
|
|
expect(targets.map((l) => l.id).sort()).toEqual([a.id, b.id].sort());
|
|
});
|
|
|
|
it("unlinkEvidence removes the link and reports success/failure", () => {
|
|
const { service } = makeFixture();
|
|
const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
|
|
|
|
expect(service.unlinkEvidence(link.id)).toBe(true);
|
|
expect(service.getLink(link.id)).toBeNull();
|
|
expect(service.unlinkEvidence(link.id)).toBe(false);
|
|
expect(service.unlinkEvidence("evlink_unknown" as EvidenceLinkId)).toBe(false);
|
|
});
|
|
|
|
it("updateLink merges patch, bumps updatedAt, and emits EvidenceLinkUpdated", () => {
|
|
const { service, events } = makeFixture();
|
|
const original = service.linkEvidenceToTarget({
|
|
evidenceItemId: EV1,
|
|
target: FIELD_A,
|
|
});
|
|
|
|
const updated = service.updateLink(original.id, {
|
|
status: "confirmed",
|
|
confidence: 0.9,
|
|
});
|
|
|
|
expect(updated.status).toBe("confirmed");
|
|
expect(updated.confidence).toBe(0.9);
|
|
expect(updated.relation).toBe(original.relation);
|
|
expect(updated.updatedAt).not.toBe(original.updatedAt);
|
|
|
|
const updatedEvents = events.filter((e) => e.type === "EvidenceLinkUpdated");
|
|
expect(updatedEvents).toHaveLength(1);
|
|
expect((updatedEvents[0] as Extract<EngineEvent, { type: "EvidenceLinkUpdated" }>).link).toEqual(updated);
|
|
});
|
|
|
|
it("updateLink throws on unknown id", () => {
|
|
const { service } = makeFixture();
|
|
expect(() =>
|
|
service.updateLink("evlink_unknown" as EvidenceLinkId, { status: "verified" }),
|
|
).toThrow(/unknown id/);
|
|
});
|
|
|
|
it("setActiveEvidence emits EvidenceItemActivated with source=form-field", () => {
|
|
const { service, events } = makeFixture();
|
|
service.setActiveEvidence(EV1);
|
|
const activated = events.filter((e) => e.type === "EvidenceItemActivated");
|
|
expect(activated).toHaveLength(1);
|
|
expect(activated[0]).toMatchObject({ evidenceItemId: EV1, source: "form-field" });
|
|
});
|
|
});
|
|
|
|
describe("EvidenceLinkRepository (in-memory)", () => {
|
|
it("rejects duplicate ids on create", () => {
|
|
const repo = createInMemoryLinkRepo();
|
|
const link: EvidenceLink = {
|
|
id: "evlink_x" as EvidenceLinkId,
|
|
evidenceItemId: EV1,
|
|
targetType: "form-field",
|
|
targetId: "f",
|
|
relation: "supports",
|
|
status: "candidate",
|
|
createdAt: "2026-05-25T00:00:00.000Z",
|
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
};
|
|
repo.create(link);
|
|
expect(() => repo.create(link)).toThrow(/duplicate/);
|
|
});
|
|
|
|
it("update throws on unknown id", () => {
|
|
const repo = createInMemoryLinkRepo();
|
|
const link: EvidenceLink = {
|
|
id: "evlink_unknown" as EvidenceLinkId,
|
|
evidenceItemId: EV1,
|
|
targetType: "form-field",
|
|
targetId: "f",
|
|
relation: "supports",
|
|
status: "candidate",
|
|
createdAt: "2026-05-25T00:00:00.000Z",
|
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
|
};
|
|
expect(() => repo.update(link)).toThrow(/unknown/);
|
|
});
|
|
});
|