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>
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
/**
|
|
* Rect registry unit tests — exercise every public surface plus the
|
|
* §7-contract guarantees:
|
|
* - register/unregister fire subscriber events
|
|
* - getRect returns the live result of the registered callback
|
|
* - invalidate fires a global `rect-changed` event
|
|
* - version bumps on every emit
|
|
* - re-registering the same (kind,id) supersedes the prior callback;
|
|
* the stale unregister cleanup does not delete the new entry.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
createRectRegistry,
|
|
type RectRegistryEvent,
|
|
} from "./rect-registry";
|
|
|
|
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
|
|
// happy-dom/jsdom isn't loaded for this test — synth a DOMRect-shaped
|
|
// object. The registry contract only reads these properties.
|
|
return {
|
|
x,
|
|
y,
|
|
width: w,
|
|
height: h,
|
|
top: y,
|
|
left: x,
|
|
right: x + w,
|
|
bottom: y + h,
|
|
toJSON() {
|
|
return { x, y, width: w, height: h };
|
|
},
|
|
} as DOMRect;
|
|
}
|
|
|
|
describe("createRectRegistry", () => {
|
|
it("returns null for unknown rects", () => {
|
|
const r = createRectRegistry();
|
|
expect(r.getRect("field", "missing")).toBeNull();
|
|
});
|
|
|
|
it("register/getRect roundtrip", () => {
|
|
const r = createRectRegistry();
|
|
r.register("field", "f1", () => fakeRect(1, 2, 3, 4));
|
|
const rect = r.getRect("field", "f1");
|
|
expect(rect).not.toBeNull();
|
|
expect(rect!.x).toBe(1);
|
|
expect(rect!.width).toBe(3);
|
|
});
|
|
|
|
it("getRect reflects live callback results", () => {
|
|
const r = createRectRegistry();
|
|
let xPos = 10;
|
|
r.register("highlight", "h1", () => fakeRect(xPos, 0, 5, 5));
|
|
expect(r.getRect("highlight", "h1")!.x).toBe(10);
|
|
xPos = 200;
|
|
expect(r.getRect("highlight", "h1")!.x).toBe(200);
|
|
});
|
|
|
|
it("returns null when the callback throws", () => {
|
|
const r = createRectRegistry();
|
|
r.register("field", "boom", () => {
|
|
throw new Error("nope");
|
|
});
|
|
expect(r.getRect("field", "boom")).toBeNull();
|
|
});
|
|
|
|
it("emits registered + unregistered events", () => {
|
|
const r = createRectRegistry();
|
|
const events: RectRegistryEvent[] = [];
|
|
r.subscribe((e) => events.push(e));
|
|
const unregister = r.register("evidence-card", "ev1", () => fakeRect(0, 0, 1, 1));
|
|
unregister();
|
|
expect(events).toEqual([
|
|
{ type: "registered", kind: "evidence-card", id: "ev1" },
|
|
{ type: "unregistered", kind: "evidence-card", id: "ev1" },
|
|
]);
|
|
});
|
|
|
|
it("invalidate emits a global rect-changed event and bumps version", () => {
|
|
const r = createRectRegistry();
|
|
const events: RectRegistryEvent[] = [];
|
|
r.subscribe((e) => events.push(e));
|
|
const before = r.getVersion();
|
|
r.invalidate();
|
|
expect(events).toEqual([{ type: "rect-changed" }]);
|
|
expect(r.getVersion()).toBe(before + 1);
|
|
});
|
|
|
|
it("re-registering the same (kind,id) supersedes; stale cleanup is a no-op", () => {
|
|
const r = createRectRegistry();
|
|
const events: RectRegistryEvent[] = [];
|
|
r.subscribe((e) => events.push(e));
|
|
|
|
const firstGetRect = () => fakeRect(1, 1, 1, 1);
|
|
const secondGetRect = () => fakeRect(9, 9, 9, 9);
|
|
|
|
const cleanup1 = r.register("highlight", "x", firstGetRect);
|
|
r.register("highlight", "x", secondGetRect); // supersede
|
|
|
|
// The stale cleanup must not remove the new registration.
|
|
cleanup1();
|
|
|
|
expect(r.getRect("highlight", "x")!.x).toBe(9);
|
|
// Two `registered` events, no `unregistered` event — the second
|
|
// register overwrote without an explicit unregister, and the stale
|
|
// cleanup detected the (kind,id) holds a different callback.
|
|
expect(events.filter((e) => e.type === "unregistered")).toHaveLength(0);
|
|
expect(events.filter((e) => e.type === "registered")).toHaveLength(2);
|
|
});
|
|
|
|
it("subscribe returns an unsubscribe that detaches the listener", () => {
|
|
const r = createRectRegistry();
|
|
let count = 0;
|
|
const off = r.subscribe(() => count++);
|
|
r.invalidate();
|
|
off();
|
|
r.invalidate();
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
it("listener errors do not break sibling listeners", () => {
|
|
const r = createRectRegistry();
|
|
let okCount = 0;
|
|
r.subscribe(() => {
|
|
throw new Error("boom");
|
|
});
|
|
r.subscribe(() => {
|
|
okCount++;
|
|
});
|
|
r.invalidate();
|
|
expect(okCount).toBe(1);
|
|
});
|
|
|
|
it("list enumerates current registrations", () => {
|
|
const r = createRectRegistry();
|
|
r.register("field", "f1", () => null);
|
|
r.register("evidence-card", "ev1", () => null);
|
|
r.register("highlight", "h1", () => null);
|
|
const list = r.list();
|
|
expect(list).toHaveLength(3);
|
|
expect(list).toEqual(
|
|
expect.arrayContaining([
|
|
{ kind: "field", id: "f1" },
|
|
{ kind: "evidence-card", id: "ev1" },
|
|
{ kind: "highlight", id: "h1" },
|
|
]),
|
|
);
|
|
});
|
|
});
|