generated from coulomb/repo-seed
Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
- src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
- src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
- enum-conformance test parses SharedContracts.md and asserts the
runtime lists match exactly
T02 Binding service + in-memory link repo + active-state machine
- src/binder/repos/in-memory-links.ts: Map-backed EvidenceLinkRepository
- src/binder/services/bindings.ts: link/unlink/list/update/setActive
emitting §4 EvidenceLinkCreated / EvidenceLinkUpdated /
EvidenceItemActivated
- src/binder/state/active.ts: (target, evidence, annotation) reducer
+ ActiveStateProvider + useActiveState hook
- extended engine/events/types.ts with EvidenceLinkCreated,
EvidenceLinkUpdated, FormFieldActivated payloads
T03 Rect registry (SharedContracts §7 — contract FROZEN)
- src/binder/visual-guide/rect-registry.ts: register/getRect/subscribe
+ invalidate + getVersion for useSyncExternalStore
- events.ts: scroll/resize/focus pumps via window + ResizeObserver +
IntersectionObserver, rAF-throttled
- react-hooks.ts: RectRegistryProvider, useRegisterRect(kind,id,ref),
useRectRegistryVersion
T04 Form schema + renderer
- src/app/forms/demo-schema.ts: text/textarea/date minimal schema
- src/binder/FormRenderer.tsx: renders schema, each field registers
as rect kind="field"; active field gets aria-current="true"
- placed in binder/ (not work/) because work cannot import binder per
DependencyMap.md §2 and the renderer needs the rect-registry hook;
workplan T04 was amended in-place to document this
T05 Side-by-side Forms layout + click-to-link
- src/app/forms/FormsApp.tsx + src/app/App.tsx top-bar router with
hash route #/forms/demo
- BinderProvider mounted at app root so links survive tab switching
- stage-evidence-then-click-field linking interaction with banner
+ per-field link-count chip
T06 Active-evidence cycling
- src/app/forms/ActiveEvidenceChips.tsx: chips per active target,
Tab cycles natively, first chip auto-activates on field focus,
each chip registers as rect kind="evidence-card"
- ScrollBridge in FormsApp wires activeAnnotationId to viewer scroll
- EvidenceSidebar + EvidenceStrip highlight the active item via the
new useLastActivatedEvidence hook in work/EngineContext
T07 SVG visual-guide overlay
- src/binder/visual-guide/Overlay.tsx: single fixed-positioned SVG,
draws field→card and card→highlight bezier curves for the active
triple, rAF-throttled via the registry
- src/anchor exposes getHighlightClientRects(annotationId); the
spike viewer wraps highlights in [data-highlight-id] so the helper
can locate them
- src/app/forms/HighlightRectBridge.tsx: registers the active
annotation's rect via that helper
T08 End-to-end test (PRD scenario steps 5-9)
- tests/integration/forms-overlay-e2e.dom.test.tsx: full path from
Review-mode capture through Forms-mode link to active triple +
aria-current assertions + 2 SVG paths in the overlay
- additional integration coverage: forms-link-flow + forms-active-cycling
Gates: typecheck ✓ · lint ✓ · build ✓ · 152/152 tests across 21 files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
180
src/binder/services/bindings.test.ts
Normal file
180
src/binder/services/bindings.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 "@shared/evidence-link";
|
||||
import type {
|
||||
EvidenceItemId,
|
||||
EvidenceLinkId,
|
||||
} from "@shared/ids";
|
||||
|
||||
import { createEventBus } from "@engine/events";
|
||||
import type { EngineEvent } from "@engine/events";
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
114
src/binder/services/bindings.ts
Normal file
114
src/binder/services/bindings.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Binding service — links EvidenceItems to structured targets.
|
||||
*
|
||||
* Implements `wiki/ArchitectureOverview.md` §4.6 + SharedContracts §2.4
|
||||
* (status enum), §2.5 (relation enum). Emits §4 events:
|
||||
* `EvidenceLinkCreated`, `EvidenceLinkUpdated`, `EvidenceItemActivated`.
|
||||
*
|
||||
* MVP semantics:
|
||||
* - `linkEvidenceToTarget` defaults `relation="supports"`, `status="candidate"`.
|
||||
* - `unlinkEvidence` is hard-delete; the rejected-status path is left to
|
||||
* a later ADR.
|
||||
* - `setActiveEvidence` emits an `EvidenceItemActivated` event with
|
||||
* `source="form-field"` so the viewer/sidebar can react.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EvidenceLink,
|
||||
EvidenceLinkStoredStatus,
|
||||
EvidenceRelation,
|
||||
EvidenceTarget,
|
||||
} from "@shared/evidence-link";
|
||||
import type { EvidenceItemId, EvidenceLinkId } from "@shared/ids";
|
||||
import { newId } from "@shared/ids";
|
||||
|
||||
import type { EventBus } from "@engine/events";
|
||||
|
||||
import type { EvidenceLinkRepository } from "../repos/in-memory-links";
|
||||
|
||||
export interface LinkEvidenceToTargetInput {
|
||||
readonly evidenceItemId: EvidenceItemId;
|
||||
readonly target: EvidenceTarget;
|
||||
readonly relation?: EvidenceRelation;
|
||||
readonly status?: EvidenceLinkStoredStatus;
|
||||
readonly confidence?: number;
|
||||
readonly createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLinkStatusInput {
|
||||
readonly status?: EvidenceLinkStoredStatus;
|
||||
readonly relation?: EvidenceRelation;
|
||||
readonly confidence?: number;
|
||||
}
|
||||
|
||||
export interface BindingService {
|
||||
linkEvidenceToTarget(input: LinkEvidenceToTargetInput): EvidenceLink;
|
||||
unlinkEvidence(id: EvidenceLinkId): boolean;
|
||||
updateLink(id: EvidenceLinkId, input: UpdateLinkStatusInput): EvidenceLink;
|
||||
getLink(id: EvidenceLinkId): EvidenceLink | null;
|
||||
listEvidenceForTarget(target: EvidenceTarget): readonly EvidenceLink[];
|
||||
listTargetsForEvidence(evidenceItemId: EvidenceItemId): readonly EvidenceLink[];
|
||||
setActiveEvidence(evidenceItemId: EvidenceItemId): void;
|
||||
}
|
||||
|
||||
export function createBindingService(
|
||||
links: EvidenceLinkRepository,
|
||||
bus: EventBus,
|
||||
now: () => string = () => new Date().toISOString(),
|
||||
): BindingService {
|
||||
return {
|
||||
linkEvidenceToTarget(input) {
|
||||
const ts = now();
|
||||
const link: EvidenceLink = {
|
||||
id: newId("evidence-link"),
|
||||
evidenceItemId: input.evidenceItemId,
|
||||
targetType: input.target.targetType,
|
||||
targetId: input.target.targetId,
|
||||
relation: input.relation ?? "supports",
|
||||
status: input.status ?? "candidate",
|
||||
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
||||
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
const stored = links.create(link);
|
||||
bus.emit({ type: "EvidenceLinkCreated", linkId: stored.id, link: stored });
|
||||
return stored;
|
||||
},
|
||||
unlinkEvidence(id) {
|
||||
return links.delete(id);
|
||||
},
|
||||
updateLink(id, input) {
|
||||
const existing = links.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`BindingService.updateLink: unknown id ${id}`);
|
||||
}
|
||||
const next: EvidenceLink = {
|
||||
...existing,
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.relation !== undefined ? { relation: input.relation } : {}),
|
||||
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
||||
updatedAt: now(),
|
||||
};
|
||||
const stored = links.update(next);
|
||||
bus.emit({ type: "EvidenceLinkUpdated", linkId: stored.id, link: stored });
|
||||
return stored;
|
||||
},
|
||||
getLink(id) {
|
||||
return links.get(id);
|
||||
},
|
||||
listEvidenceForTarget(target) {
|
||||
return links.listForTarget(target);
|
||||
},
|
||||
listTargetsForEvidence(evidenceItemId) {
|
||||
return links.listForEvidenceItem(evidenceItemId);
|
||||
},
|
||||
setActiveEvidence(evidenceItemId) {
|
||||
bus.emit({
|
||||
type: "EvidenceItemActivated",
|
||||
evidenceItemId,
|
||||
source: "form-field",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
1
src/binder/services/index.ts
Normal file
1
src/binder/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./bindings";
|
||||
Reference in New Issue
Block a user