Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions frontend/annotator/src/adapters/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* The `IdFactory` every host on a platform with `crypto` wants.
*
* It lives in `adapters/` and not in `core/` for one reason: `crypto` is a host
* global, and `src/core/` compiles with no DOM `lib` and no ambient `@types`, so
* this file could not exist there. `adapters/` is where the DOM is allowed —
* this one is not a *renderer*, which is why it sits beside `react/` rather than
* inside it.
*
* `crypto.randomUUID()` is a v4 from a cryptographic source, and it is not a
* detail: ids are compared across a session and must not collide, and a
* `Math.random()` v4 collides sooner than people expect. Available in every
* current browser and in Node 19+, though a browser requires a **secure
* context** — https or localhost. A host serving plain http over a LAN needs its
* own factory, which is exactly what the port is for.
*/

import type { IdFactory } from "../core/ids";

/** A fresh uuid v4 from the platform's own cryptographic source. */
export const randomUuid: IdFactory = () => crypto.randomUUID();
45 changes: 45 additions & 0 deletions frontend/annotator/src/core/_fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* The kernel-written wire fixture, read once and typed as loosely as it is.
*
* `tests/fixtures/wire_annotations.json` is produced by
* `scripts/export_wire_fixtures.py` and kept current by
* `tests/server/test_wire_fixtures.py`. It is the only thing carrying the wire
* contract across the language boundary — the `frontend` CI job installs no
* Python — so every claim the mirror makes is checked against it rather than
* against a hand-written TypeScript copy.
*
* A shared module rather than the four-line loader repeated in each test file,
* because the relative path is the fragile part and it should exist once. The
* `_` prefix marks a harness, the way `tests/server/_flow.py` does; both it and
* `*.test.ts` are excluded from `tsconfig.build.json`, which is what keeps
* `node:fs` out of the shipped engine and out of the headless boundary's
* type gate.
*
* Every field is `unknown` on purpose: these are the bytes a host hands over, and
* a test that pre-typed them would be asserting against its own assumption
* instead of against the payload.
*/

import { readFileSync } from "node:fs";

export interface WireFixture {
readonly annotations: readonly unknown[];
readonly asset: unknown;
readonly schema: unknown;
readonly attribute_kinds: readonly string[];
readonly geometry_types: readonly string[];
readonly implemented_geometry_types: readonly string[];
}

const FIXTURE_URL = new URL(
"../../../../tests/fixtures/wire_annotations.json",
import.meta.url,
);

/** The fixture, parsed. Read through `import.meta.url`, so vitest's cwd is irrelevant. */
export const fixture = JSON.parse(readFileSync(FIXTURE_URL, "utf8")) as WireFixture;

/** A deep copy of the fixture's `n`th annotation, free to mutate into a bad case. */
export function sampleAnnotation(index = 0): Record<string, unknown> {
return structuredClone(fixture.annotations[index]) as Record<string, unknown>;
}
26 changes: 26 additions & 0 deletions frontend/annotator/src/core/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Minting an annotation id — a port, because core cannot mint one itself.
*
* A uuid v4 needs randomness, and randomness in a browser or in Node is
* `crypto`, which is a host global the headless boundary forbids: naming it
* inside `src/core/` fails `tsconfig.core.json`, where there is no DOM `lib` and
* no ambient `@types`. So the capability arrives as a function, and the
* implementation lives outside — `adapters/ids.ts` has the one every host wants.
*
* That is the boundary being productive rather than obstructive, and it is the
* kernel's own shape: a port declared where it is used, an adapter at the edge,
* and a test double that needs no host at all. A test injects a counter and gets
* a deterministic document; nothing has to stub a global.
*
* Why an id at all, when `AnnotationCreate` has none: a drawn annotation needs
* identity from the first click — it is what selection keys on, what the command
* log addresses, and what a renderer keys its elements by. The service mints its
* own on `POST`, and `toAnnotationCreate` drops the local one, so the two never
* meet. See `state/document.ts` on why there is deliberately no rebase.
*/

/**
* Produces a fresh annotation id. Ids must be unique within a document and are
* never interpreted — the engine compares them and nothing else.
*/
export type IdFactory = () => string;
262 changes: 262 additions & 0 deletions frontend/annotator/src/core/state/document.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* The document: identity, containment, draw order, and the wire round-trip.
*
* Acceptance criterion 2 of #40 is the `documentFromWire` block — a document built
* from what the kernel actually produced, and serialized back to the same bytes.
* The fixture carries a bbox, a polygon and a classification tag, so all three
* carryable variants make the trip.
*/

import { describe, expect, it } from "vitest";

import { fixture } from "../_fixture";
import type { Annotation, AnnotationSchema, AssetDescriptor } from "../types";
import { WireFormatError, parseAnnotations, toAnnotationCreate } from "../wire";
import {
DocumentError,
addAnnotation,
annotationById,
annotationsInDrawOrder,
classNamed,
createDocument,
documentFromWire,
removeAnnotations,
replaceAnnotation,
} from "./document";

const ASSET: AssetDescriptor = { id: "asset-1", width: 640, height: 480 };

const SCHEMA: AnnotationSchema = {
project_id: "project-1",
version: 3,
classes: [
{ name: "sign", geometry: "bbox", color: "#ff0000", attributes: [] },
{ name: "lane", geometry: "polygon", color: null, attributes: [] },
],
};

/** A minimal annotation. The tests that care about a field set it explicitly. */
function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation {
return {
id,
asset_id: ASSET.id,
label_class: "sign",
schema_version: 3,
geometry: { type: "bbox", x: 0, y: 0, width: 10, height: 10 },
attributes: {},
provenance: "human",
model_ref: null,
confidence: null,
...overrides,
};
}

function documentOf(...ids: readonly string[]) {
return createDocument(ASSET, SCHEMA, ids.map((id) => annotation(id)));
}

describe("building a document", () => {
it("keys annotations by id and keeps the order they arrived in", () => {
const document = documentOf("c", "a", "b");
expect([...document.annotations.keys()]).toEqual(["c", "a", "b"]);
expect(annotationsInDrawOrder(document).map((a) => a.id)).toEqual(["c", "a", "b"]);
});

it("starts empty when handed nothing", () => {
const document = createDocument(ASSET, SCHEMA);
expect(document.annotations.size).toBe(0);
expect(annotationsInDrawOrder(document)).toEqual([]);
});

it("refuses two annotations sharing an id", () => {
// The id is the document's only handle, so a repeat is a lost annotation
// rather than a duplicate one.
expect(() => documentOf("a", "a")).toThrow(DocumentError);
expect(() => documentOf("a", "a")).toThrow(/share the id a/);
});

it("refuses an annotation belonging to another asset", () => {
expect(() =>
createDocument(ASSET, SCHEMA, [annotation("a", { asset_id: "asset-2" })]),
).toThrow(/belongs to asset asset-2/);
});
});

describe("adding, replacing and removing", () => {
it("adds at the end of the draw order", () => {
const document = addAnnotation(documentOf("a", "b"), annotation("c"));
expect(annotationsInDrawOrder(document).map((a) => a.id)).toEqual(["a", "b", "c"]);
});

it("refuses to add an id already present", () => {
expect(() => addAnnotation(documentOf("a"), annotation("a"))).toThrow(
/already in this document/,
);
});

it("replaces an annotation without moving it in the draw order", () => {
// `Map.set` on a present key keeps its position, and this is the behaviour
// the whole edit loop rests on: nudging a box must not send it behind
// everything else. An editor where editing changes z-order fights its user.
const before = documentOf("a", "b", "c");
const moved = annotation("a", {
geometry: { type: "bbox", x: 99, y: 99, width: 1, height: 1 },
});
const after = replaceAnnotation(before, moved);
expect(annotationsInDrawOrder(after).map((a) => a.id)).toEqual(["a", "b", "c"]);
expect(annotationById(after, "a")?.geometry).toEqual(moved.geometry);
});

it("refuses to replace an id it does not hold", () => {
// An update that silently created would turn a stale id — the one case worth
// catching — into a second copy of the annotation.
expect(() => replaceAnnotation(documentOf("a"), annotation("b"))).toThrow(
/no annotation b in this document/,
);
});

it("removes by id, and a repeat counts once", () => {
const document = removeAnnotations(documentOf("a", "b", "c"), ["b", "b"]);
expect(annotationsInDrawOrder(document).map((a) => a.id)).toEqual(["a", "c"]);
});

it("removes nothing at all when one id is unknown", () => {
// All or nothing, the kernel's own bulk-delete posture: a partial removal
// leaves the caller working out how far it got.
const before = documentOf("a", "b");
expect(() => removeAnnotations(before, ["a", "zz"])).toThrow(/no annotation zz/);
expect(annotationsInDrawOrder(before).map((a) => a.id)).toEqual(["a", "b"]);
});

it("returns the same document when asked to remove nothing", () => {
const before = documentOf("a");
expect(removeAnnotations(before, [])).toBe(before);
});
});

describe("immutability, which is what makes undo a pointer swap", () => {
it("never mutates the document it was given", () => {
const before = documentOf("a", "b");
const snapshot = [...before.annotations.keys()];

addAnnotation(before, annotation("c"));
removeAnnotations(before, ["a"]);
replaceAnnotation(before, annotation("a", { label_class: "lane" }));

expect([...before.annotations.keys()]).toEqual(snapshot);
expect(annotationById(before, "a")?.label_class).toBe("sign");
});

it("hands back a new map each time, so a snapshot stays a snapshot", () => {
const before = documentOf("a");
const after = addAnnotation(before, annotation("b"));
expect(after.annotations).not.toBe(before.annotations);
expect(before.annotations.size).toBe(1);
});

it("shares the asset and schema rather than copying them", () => {
// They are inputs, not editable state — #39 will snapshot documents on every
// command and neither should be copied 200 times.
const after = addAnnotation(documentOf("a"), annotation("b"));
expect(after.asset).toBe(ASSET);
expect(after.schema).toBe(SCHEMA);
});
});

describe("the schema, which is looked up and not enforced", () => {
it("finds a class by its exact name", () => {
const document = documentOf("a");
expect(classNamed(document, "sign")?.geometry).toBe("bbox");
expect(classNamed(document, "lane")?.color).toBeNull();
});

it("answers undefined for a class the schema does not declare", () => {
// An annotation naming a class a later version removed is a real state — it
// is what SCHEMA_CHANGE_WOULD_ORPHAN is about — and a document that refused
// to load one would leave a labeller unable to see the annotation at fault.
expect(classNamed(documentOf("a"), "pedestrian")).toBeUndefined();
});

it("accepts an annotation whose geometry its class does not declare", () => {
// Deliberate. That rule has an owner in the kernel, and the proof it must not
// be copied here is one line below: the round-trip fixture violates it.
const document = createDocument(ASSET, SCHEMA, [
annotation("a", { label_class: "sign", geometry: { type: "classification_tag" } }),
]);
expect(annotationById(document, "a")?.geometry.type).toBe("classification_tag");
});

it("accepts an annotation naming no class in the schema", () => {
const document = createDocument(ASSET, SCHEMA, [
annotation("a", { label_class: "pedestrian" }),
]);
expect(classNamed(document, annotationById(document, "a")!.label_class)).toBeUndefined();
});
});

describe("built from the wire, and back again", () => {
const wire = {
asset: fixture.asset,
schema: fixture.schema,
annotations: fixture.annotations,
};

it("loads what the kernel produced", () => {
const document = documentFromWire(wire);
expect(document.annotations.size).toBe(fixture.annotations.length);
expect(document.asset.width).toBeGreaterThan(0);
});

it("round-trips every annotation back to the bytes it came from", () => {
// Acceptance criterion 2. The document is keyed by id and iterated in
// insertion order, so this also proves loading preserved both.
const document = documentFromWire(wire);
const out = annotationsInDrawOrder(document).map((a) =>
JSON.parse(JSON.stringify(a)),
);
expect(out).toEqual(fixture.annotations);
});

it("carries all three geometry variants through the document", () => {
const seen = new Set(
annotationsInDrawOrder(documentFromWire(wire)).map((a) => a.geometry.type),
);
expect([...seen].sort()).toEqual([...fixture.implemented_geometry_types]);
});

it("holds annotations whose class declares a different geometry", () => {
// The fixture's four annotations all say `label_class: "sign"`, whose class
// declares `bbox`, while three carry a polygon or a tag. That is valid data
// the kernel produced — so a document enforcing class↔geometry agreement
// could not load its own round-trip fixture. This is that argument, executed.
const document = documentFromWire(wire);
const disagreeing = annotationsInDrawOrder(document).filter(
(a) => classNamed(document, a.label_class)?.geometry !== a.geometry.type,
);
expect(disagreeing.length).toBeGreaterThan(0);
});

it("projects a loaded annotation onto what a create takes", () => {
const document = documentFromWire(wire);
const first = annotationsInDrawOrder(document)[0];
expect(toAnnotationCreate(first).asset_id).toBe(document.asset.id);
});

it("refuses the whole document when one annotation is malformed", () => {
const bad = [...fixture.annotations, { id: "x" }];
expect(() => documentFromWire({ ...wire, annotations: bad })).toThrow(WireFormatError);
});

it("refuses annotations belonging to a different asset than the one given", () => {
// The parser cannot catch this — each payload is individually valid — so it is
// the document's own invariant, and it is the one that stops a host pairing an
// asset with somebody else's labels.
const foreign = parseAnnotations(fixture.annotations).map((a) => ({
...a,
asset_id: "asset-elsewhere",
}));
expect(() =>
createDocument({ ...ASSET }, { ...SCHEMA }, foreign),
).toThrow(/belongs to asset asset-elsewhere/);
});
});
Loading
Loading