152 lines
4.7 KiB
TypeScript
152 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" },
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|