evidence-binder/src/services/bindings.test.ts
tegwick a10f080f12
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Extract binder package from citation-evidence (EBIND-WP-0001)
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>
2026-07-09 01:36:33 +02:00

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/);
});
});