diff --git a/apps/modal-backend/pyproject.toml b/apps/modal-backend/pyproject.toml index f73f648..4148d4e 100644 --- a/apps/modal-backend/pyproject.toml +++ b/apps/modal-backend/pyproject.toml @@ -54,7 +54,7 @@ markers = [ # PR that drops the total below this FAILS; a PR that raises coverage bumps # the floor. Never lower it to make a PR pass — that defeats the ratchet. [tool.coverage.report] -fail_under = 84 +fail_under = 85 [tool.mypy] python_version = "3.12" diff --git a/apps/modal-backend/tests/test_view_estimator.py b/apps/modal-backend/tests/test_view_estimator.py index 10dfca5..f2d375f 100644 --- a/apps/modal-backend/tests/test_view_estimator.py +++ b/apps/modal-backend/tests/test_view_estimator.py @@ -68,3 +68,114 @@ def test_parse_view_confidence_coercion() -> None: assert parse_view({"level": "map", "projection": "top_down", "pitch_deg": -90, "confidence": "junk"})["confidence"] == 0.5 assert DEFAULT_VIEW["confidence"] == 0.0 + + +# ── estimate_view (the one-VLM-call path; mocked create, no live calls) ─────── + + +def _fake_reply(content: str, finish_reason: str = "stop"): + from types import SimpleNamespace + + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content=content), finish_reason=finish_reason + ) + ] + ) + + +async def test_estimate_view_success_parses_and_sends_the_image(monkeypatch) -> None: + from providers import llm + from providers.view_estimator import estimate_view + + captured: dict = {} + + async def fake_create(client, **kwargs): + captured.update(kwargs) + return _fake_reply( + '{"level":"street","projection":"perspective","pitch_deg":-10,' + '"scale_tier":"district","confidence":0.9}' + ) + + monkeypatch.setattr(llm, "_create_with_retry", fake_create) + monkeypatch.setattr(llm, "_client", lambda: object()) + out = await estimate_view(b"img-bytes", caption="a cobbled lane") + assert out == { + "level": "street", + "projection": "perspective", + "pitch_deg": -10.0, + "scale_tier": "district", + "confidence": 0.9, + } + user_parts = captured["messages"][1]["content"] + assert 'Caption: "a cobbled lane"' in user_parts[0]["text"] + assert user_parts[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") + assert captured["temperature"] == 0.0 + + +async def test_estimate_view_omits_the_caption_clause_when_absent(monkeypatch) -> None: + from providers import llm + from providers.view_estimator import estimate_view + + captured: dict = {} + + async def fake_create(client, **kwargs): + captured.update(kwargs) + return _fake_reply('{"level":"map","projection":"top_down","pitch_deg":-90}') + + monkeypatch.setattr(llm, "_create_with_retry", fake_create) + monkeypatch.setattr(llm, "_client", lambda: object()) + out = await estimate_view(b"img") + assert out["level"] == "map" + assert captured["messages"][1]["content"][0]["text"] == "Classify this image's camera." + + +async def test_estimate_view_unparseable_reply_degrades_to_default_and_warns( + monkeypatch, +) -> None: + import obs + from providers import llm + from providers.view_estimator import DEFAULT_VIEW, estimate_view + + events: list[tuple[str, str]] = [] + + async def fake_create(client, **kwargs): + return _fake_reply("I cannot classify that image.", finish_reason="stop") + + monkeypatch.setattr(llm, "_create_with_retry", fake_create) + monkeypatch.setattr(llm, "_client", lambda: object()) + monkeypatch.setattr(obs, "log", lambda level, event, **kw: events.append((level, event))) + assert await estimate_view(b"img") == DEFAULT_VIEW + assert ("warn", "view.parse_failed") in events + + +async def test_estimate_view_upstream_failure_degrades_to_default_and_warns( + monkeypatch, +) -> None: + import obs + from providers import llm + from providers.view_estimator import DEFAULT_VIEW, estimate_view + + events: list[tuple[str, str]] = [] + + async def boom(client, **kwargs): + raise RuntimeError("upstream 502") + + monkeypatch.setattr(llm, "_create_with_retry", boom) + monkeypatch.setattr(llm, "_client", lambda: object()) + monkeypatch.setattr(obs, "log", lambda level, event, **kw: events.append((level, event))) + assert await estimate_view(b"img") == DEFAULT_VIEW + assert ("warn", "view.estimate_failed") in events + + +def test_model_env_override(monkeypatch) -> None: + from providers.view_estimator import _model + + monkeypatch.delenv("WORLD_BENCH_JUDGE_MODEL", raising=False) + monkeypatch.delenv("OPENROUTER_VLM_MODEL", raising=False) + assert _model() == "google/gemini-3-flash-preview" + monkeypatch.setenv("OPENROUTER_VLM_MODEL", "qwen/qwen-vl") + assert _model() == "qwen/qwen-vl" + monkeypatch.setenv("WORLD_BENCH_JUDGE_MODEL", "judge/x") + assert _model() == "judge/x" diff --git a/apps/web/lib/session-owner.test.ts b/apps/web/lib/session-owner.test.ts index 5b0e0ef..9afd958 100644 --- a/apps/web/lib/session-owner.test.ts +++ b/apps/web/lib/session-owner.test.ts @@ -1,11 +1,15 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // In-memory stand-in for the `session_owners` collection: insertOne enforces the -// unique _id (throws 11000 on a dup, like Mongo), findOne reads it back. +// unique _id (throws 11000 on a dup, like Mongo), findOne reads it back. The +// knobs simulate the two failure modes claimOrVerify must distinguish: an +// unrelated insert error (rethrown) and a lost delete race (dup + missing doc). const store = new Map(); +const knobs = { failInsert: null as "generic" | "dup" | null, findNull: false }; const fakeCollection = { async insertOne(doc: { _id: string; owner_token: string }) { - if (store.has(doc._id)) { + if (knobs.failInsert === "generic") throw new Error("boom"); + if (knobs.failInsert === "dup" || store.has(doc._id)) { const err = new Error("dup") as Error & { code?: number }; err.code = 11000; throw err; @@ -14,6 +18,7 @@ const fakeCollection = { return { acknowledged: true }; }, async findOne(filter: { _id: string }) { + if (knobs.findNull) return null; return store.get(filter._id) ?? null; }, }; @@ -22,10 +27,29 @@ vi.mock("./db", () => ({ getDb: async () => ({ collection: () => fakeCollection }), })); -import { claimOrVerify } from "./session-owner"; +// Cookie jar behind the mocked next/headers (route-handler cookies() store). +const jar = vi.hoisted(() => ({ + token: null as string | null, + sets: [] as { name: string; value: string; opts: Record }[], +})); +vi.mock("next/headers", () => ({ + cookies: async () => ({ + get: (name: string) => (jar.token ? { name, value: jar.token } : undefined), + set: (name: string, value: string, opts: Record) => { + jar.sets.push({ name, value, opts }); + jar.token = value; + }, + }), +})); + +import { claimOrVerify, requireOwner, verifyOwnerReadonly } from "./session-owner"; describe("claimOrVerify (session ownership)", () => { - beforeEach(() => store.clear()); + beforeEach(() => { + store.clear(); + knobs.failInsert = null; + knobs.findNull = false; + }); it("first caller claims an unowned session", async () => { expect(await claimOrVerify("s1", "tokenA")).toBe("ok"); @@ -47,4 +71,128 @@ describe("claimOrVerify (session ownership)", () => { expect(await claimOrVerify("legacy", "firstSeen")).toBe("ok"); expect(await claimOrVerify("legacy", "stranger")).toBe("forbidden"); }); + + it("an unrelated insert error is rethrown, not swallowed as a dup", async () => { + knobs.failInsert = "generic"; + await expect(claimOrVerify("s1", "tokenA")).rejects.toThrow("boom"); + }); + + it("a lost delete race (dup insert, doc gone on re-read) is claimable", async () => { + knobs.failInsert = "dup"; + knobs.findNull = true; + expect(await claimOrVerify("s1", "tokenA")).toBe("ok"); + }); +}); + +// ── The route gates (cookie mint + claim/verify + wire errors) ─────────────── + +const CONFIGURED = () => { + vi.stubEnv("MONGODB_URI", "mongodb://test"); + vi.stubEnv("MONGODB_DB", "ofb_test"); +}; + +describe("requireOwner (mutation gate)", () => { + beforeEach(() => { + store.clear(); + knobs.failInsert = null; + knobs.findNull = false; + jar.token = null; + jar.sets.length = 0; + }); + afterEach(() => vi.unstubAllEnvs()); + + it("rejects an unsafe session id with a 400 before touching anything", async () => { + CONFIGURED(); + const out = await requireOwner("nope; drop table"); + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.res.status).toBe(400); + expect(await out.res.json()).toEqual({ error: "invalid session id" }); + } + expect(jar.sets).toHaveLength(0); + }); + + it("no persistence configured → open (demo mode must not crash)", async () => { + vi.stubEnv("MONGODB_URI", ""); + vi.stubEnv("MONGODB_DB", ""); + expect(await requireOwner("session_1")).toEqual({ ok: true }); + expect(jar.sets).toHaveLength(0); + expect(store.size).toBe(0); + }); + + it("first write mints an httpOnly cookie and claims the session", async () => { + CONFIGURED(); + const out = await requireOwner("session_1"); + expect(out.ok).toBe(true); + expect(jar.sets).toHaveLength(1); + const cookie = jar.sets[0]!; + expect(cookie.name).toBe("ofb_owner"); + expect(cookie.opts.httpOnly).toBe(true); + expect(cookie.opts.sameSite).toBe("lax"); + expect(store.get("session_1")?.owner_token).toBe(cookie.value); + }); + + it("the owner's later mutations pass without re-minting", async () => { + CONFIGURED(); + jar.token = "mine"; + await claimOrVerify("session_1", "mine"); + expect(await requireOwner("session_1")).toEqual({ ok: true }); + expect(jar.sets).toHaveLength(0); // existing cookie reused + }); + + it("a stranger's cookie is rejected with the friendly 403", async () => { + CONFIGURED(); + await claimOrVerify("session_1", "owner-token"); + jar.token = "stranger-token"; + const out = await requireOwner("session_1"); + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.res.status).toBe(403); + expect(await out.res.json()).toEqual({ + error: "this session belongs to another browser", + }); + } + }); +}); + +describe("verifyOwnerReadonly (streaming-route gate: never claims, never mints)", () => { + beforeEach(() => { + store.clear(); + knobs.failInsert = null; + knobs.findNull = false; + jar.token = null; + jar.sets.length = 0; + }); + afterEach(() => vi.unstubAllEnvs()); + + it("unsafe id → 400; unconfigured store → open", async () => { + CONFIGURED(); + const bad = await verifyOwnerReadonly("../etc/passwd"); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.res.status).toBe(400); + vi.stubEnv("MONGODB_URI", ""); + expect(await verifyOwnerReadonly("session_1")).toEqual({ ok: true }); + }); + + it("an unowned session is allowed and left unclaimed (claim happens on write)", async () => { + CONFIGURED(); + expect(await verifyOwnerReadonly("session_1")).toEqual({ ok: true }); + expect(store.size).toBe(0); // no claim + expect(jar.sets).toHaveLength(0); // no cookie mint + }); + + it("an owned session admits the matching cookie and 403s everyone else", async () => { + CONFIGURED(); + await claimOrVerify("session_1", "owner-token"); + jar.token = "owner-token"; + expect(await verifyOwnerReadonly("session_1")).toEqual({ ok: true }); + jar.token = "stranger"; + const wrong = await verifyOwnerReadonly("session_1"); + expect(wrong.ok).toBe(false); + if (!wrong.ok) expect(wrong.res.status).toBe(403); + jar.token = null; // no cookie at all → same refusal + const none = await verifyOwnerReadonly("session_1"); + expect(none.ok).toBe(false); + if (!none.ok) expect(none.res.status).toBe(403); + }); }); diff --git a/apps/web/lib/stream-client.test.ts b/apps/web/lib/stream-client.test.ts index 572eac9..2e578b7 100644 --- a/apps/web/lib/stream-client.test.ts +++ b/apps/web/lib/stream-client.test.ts @@ -1,6 +1,34 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Controllable MSE seam: `supported` stays false by default so the existing +// degraded-fallback test keeps exercising the real no-MediaSource path; the +// socket-driven suite flips it on and reads the controller call counters. +const mse = vi.hoisted(() => { + const calls = { append: 0, end: 0, destroy: 0 }; + return { + supported: false, + calls, + controller: { + appendPacket: async () => { + calls.append += 1; + }, + endOfStream: () => { + calls.end += 1; + }, + destroy: () => { + calls.destroy += 1; + }, + }, + }; +}); + +vi.mock("./mse-player", () => ({ + canPlayLTXStream: () => mse.supported, + attachMSE: () => mse.controller, +})); import { + getWSUrl, MAX_RECONNECTS, packetPlan, reconnectDelayMs, @@ -89,3 +117,220 @@ describe("startLTXStream degraded fallback", () => { client.close(); // noop, must not throw }); }); + +// ── Hand-driven socket harness (FakeEventSource pattern, WS flavour) ───────── + +class FakeWebSocket { + static OPEN = 1; + static instances: FakeWebSocket[] = []; + url: string; + binaryType = ""; + readyState = 0; + sent: string[] = []; + closedWith: number | undefined; + private listeners = new Map unknown)[]>(); + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + addEventListener(type: string, fn: (ev: never) => unknown): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), fn]); + } + send(data: string): void { + this.sent.push(data); + } + close(code?: number): void { + this.closedWith = code; + } + /** Drive a listener set by hand; awaits async handlers (the message path). */ + async fire(type: string, ev: unknown = {}): Promise { + for (const fn of this.listeners.get(type) ?? []) { + await (fn as (e: unknown) => unknown)(ev); + } + } + async open(): Promise { + this.readyState = 1; + await this.fire("open"); + } + startMessage(): { action: string; position: number; session_id: string } { + return JSON.parse(this.sent[0]!) as never; + } +} + +/** Build a real binary LTXF frame ("LTXF" + BE header length + JSON + payload) + * so the parse path runs for real instead of being mocked. */ +function ltxfFrame(header: Record, payloadLen = 4): ArrayBuffer { + const json = new TextEncoder().encode(JSON.stringify(header)); + const buf = new Uint8Array(8 + json.length + payloadLen); + buf.set([0x4c, 0x54, 0x58, 0x46], 0); // "LTXF" + new DataView(buf.buffer).setUint32(4, json.length); + buf.set(json, 8); + return buf.buffer; +} + +describe("startLTXStream over a hand-driven fake socket", () => { + const media = (sequence: number, extra: Record = {}) => + ltxfFrame({ media_type: "video/mp4", sequence, ...extra }); + + let onStatus: ReturnType; + let onError: ReturnType; + let plays: number; + + function start() { + const video = document.createElement("video"); + (video as { play: () => Promise }).play = () => { + plays += 1; + return Promise.resolve(); + }; + return startLTXStream({ + wsUrl: "ws://worker.test/stream", + video, + prompt: "a windmill", + startImageDataUrl: "data:image/jpeg;base64,abc", + onStatus, + onError, + }); + } + const ws = (i = 0) => FakeWebSocket.instances[i]!; + + beforeEach(() => { + mse.supported = true; + mse.calls.append = 0; + mse.calls.end = 0; + mse.calls.destroy = 0; + FakeWebSocket.instances = []; + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.useFakeTimers(); + onStatus = vi.fn(); + onError = vi.fn(); + plays = 0; + }); + afterEach(() => { + mse.supported = false; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("dials, sends the start message on open, and waits for the first chunk", async () => { + const client = start(); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(ws().url).toBe("ws://worker.test/stream"); + expect(ws().binaryType).toBe("arraybuffer"); + await ws().open(); + const msg = JSON.parse(ws().sent[0]!); + expect(msg.action).toBe("start"); + expect(msg.position).toBe(0); // fresh stream + expect(msg.prompt).toBe("a windmill"); + expect(msg.session_id).toMatch(/^ltx_stream_/); + expect(msg.loopy_mode).toBe(true); + expect(client.status).toBe("waiting_for_first_chunk"); + }); + + it("appends packets, flips to playing once, skips dups, ends on final", async () => { + const client = start(); + await ws().open(); + await ws().fire("message", { data: ltxfFrame({ media_type: "video/mp4", sequence: 0, is_init_segment: true }) }); + expect(mse.calls.append).toBe(1); // init always appends + expect(client.status).toBe("playing"); + expect(plays).toBe(1); + await ws().fire("message", { data: media(0) }); + await ws().fire("message", { data: media(1) }); + expect(mse.calls.append).toBe(3); + expect(plays).toBe(1); // playing latched — no re-play per packet + await ws().fire("message", { data: media(1) }); // resume replay dup + expect(mse.calls.append).toBe(3); + await ws().fire("message", { data: "not-binary" }); // ignored + expect(mse.calls.append).toBe(3); + await ws().fire("message", { data: media(2, { final: true }) }); + expect(mse.calls.append).toBe(4); + expect(mse.calls.end).toBe(1); + // The close that follows `final` neither re-dials nor double-ends. + await ws().fire("close", { code: 1006 }); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(mse.calls.end).toBe(1); + expect(client.status).toBe("playing"); + }); + + it("a clean 1000 close without final ends playback gracefully", async () => { + start(); + await ws().open(); + await ws().fire("message", { data: media(0) }); + await ws().fire("close", { code: 1000 }); + expect(mse.calls.end).toBe(1); + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + it("corrupt frame → error status, and the following close does not retry", async () => { + const client = start(); + await ws().open(); + const bad = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]).buffer; // wrong magic + await ws().fire("message", { data: bad }); + expect(client.status).toBe("error"); + expect(onError).toHaveBeenCalledWith(expect.stringContaining("LTXF")); + await ws().fire("close", { code: 1006 }); + expect(FakeWebSocket.instances).toHaveLength(1); // no reconnect on corrupt data + }); + + it("a dropped socket re-dials after backoff and resumes the SAME session", async () => { + const client = start(); + await ws().open(); + await ws().fire("message", { data: media(0) }); + await ws().fire("message", { data: media(1) }); + await ws().fire("close", { code: 1006 }); + expect(FakeWebSocket.instances).toHaveLength(1); // not before the timer + vi.advanceTimersByTime(reconnectDelayMs(0)); + expect(FakeWebSocket.instances).toHaveLength(2); + await ws(1).open(); + const resumed = ws(1).startMessage(); + expect(resumed.position).toBe(2); // lastSequence + 1 + expect(resumed.session_id).toBe(ws(0).startMessage().session_id); + // Mid-stream resume must not regress the visible status to "waiting". + expect(client.status).toBe("playing"); + }); + + it("gives up after MAX_RECONNECTS drops with a friendly error", async () => { + const client = start(); + await ws().open(); + for (let attempt = 0; attempt < MAX_RECONNECTS; attempt += 1) { + await ws(attempt).fire("close", { code: 1006 }); + vi.advanceTimersByTime(reconnectDelayMs(attempt)); + expect(FakeWebSocket.instances).toHaveLength(attempt + 2); + await ws(attempt + 1).open(); + } + await ws(MAX_RECONNECTS).fire("close", { code: 1006 }); + vi.advanceTimersByTime(60_000); + expect(FakeWebSocket.instances).toHaveLength(MAX_RECONNECTS + 1); + expect(client.status).toBe("error"); + expect(onError).toHaveBeenCalledWith("Stream dropped and could not be resumed."); + }); + + it("close() tears down: cancels the pending re-dial, destroys MSE, closes 1000", async () => { + const client = start(); + await ws().open(); + await ws().fire("close", { code: 1006 }); // reconnect now pending + client.close(); + vi.advanceTimersByTime(60_000); + expect(FakeWebSocket.instances).toHaveLength(1); // timer cancelled + expect(mse.calls.destroy).toBe(1); + + // An OPEN socket gets the clean close code on user close. + const client2 = start(); + await ws(1).open(); + client2.close(); + expect(ws(1).closedWith).toBe(1000); + await ws(1).fire("close", { code: 1000 }); // browser follows up — no retry + expect(FakeWebSocket.instances).toHaveLength(2); + }); +}); + +describe("getWSUrl", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("returns the configured worker URL, or null when unset", () => { + vi.stubEnv("NEXT_PUBLIC_LTX_WS_URL", "ws://worker.example/ltx"); + expect(getWSUrl()).toBe("ws://worker.example/ltx"); + vi.stubEnv("NEXT_PUBLIC_LTX_WS_URL", ""); + expect(getWSUrl()).toBeNull(); + }); +}); diff --git a/apps/web/lib/world-crud.test.ts b/apps/web/lib/world-crud.test.ts new file mode 100644 index 0000000..a029764 --- /dev/null +++ b/apps/web/lib/world-crud.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Entity } from "@openflipbook/config"; + +// In-memory `world_state` collection so the optimistic mutate() wrappers and +// the read slices run for real without MongoDB (same stand-in shape as the +// world-map suite): findOne, insertOne with unique-_id 11000, replaceOne +// filtered on the seen updated_at. +const mongo = vi.hoisted(() => { + const docs = new Map>(); + const state = { failFind: false }; + const col = { + async findOne(filter: { _id: string }) { + if (state.failFind) throw new Error("db down"); + return docs.get(filter._id) ?? null; + }, + async insertOne(doc: { _id: string; updated_at: Date }) { + if (docs.has(doc._id)) { + const err = new Error("dup") as Error & { code?: number }; + err.code = 11000; + throw err; + } + docs.set(doc._id, doc); + return { acknowledged: true }; + }, + async replaceOne(filter: { _id: string; updated_at: Date }, next: { _id: string; updated_at: Date }) { + const cur = docs.get(filter._id); + if (!cur || cur.updated_at.getTime() !== filter.updated_at.getTime()) { + return { matchedCount: 0 }; + } + docs.set(filter._id, next); + return { matchedCount: 1 }; + }, + }; + return { docs, state, col }; +}); + +vi.mock("./db", () => ({ + getDb: async () => ({ collection: () => mongo.col }), +})); + +import { + deleteEntity, + getWorldState, + listPriorEntitiesForExtraction, + mergeEntities, + pinEntity, + renameEntity, + resolveEntitiesForPrompt, + setEntityAppearance, + undoDeleteEntity, +} from "./world"; + +// EntityDoc factory (the stored shape; entityToWire maps it to the wire Entity). +function doc(id: string, over: Record = {}) { + return { + id, + kind: "person", + name: id, + aliases: [] as string[], + appearance: "", + reference_image_url: null, + facts: [] as string[], + state: {}, + first_seen_node_id: "n1", + last_seen_node_id: "n1", + appears_on_node_ids: ["n1"], + appearance_bboxes: {}, + appearance_borders: {}, + pinned_by_user: false, + confidence: 0.9, + updated_at: new Date("2026-01-01T00:00:00Z"), + ...over, + }; +} + +function seed(sessionId: string, entities: Record[]) { + mongo.docs.set(sessionId, { + _id: sessionId, + entities, + updated_at: new Date("2026-01-01T00:00:00Z"), + schema_version: 1, + }); +} + +beforeEach(() => { + mongo.docs.clear(); + mongo.state.failFind = false; +}); + +describe("getWorldState", () => { + it("unknown session → empty snapshot at epoch", async () => { + const snap = await getWorldState("s_missing"); + expect(snap).toEqual({ + session_id: "s_missing", + entities: [], + updated_at: new Date(0).toISOString(), + }); + }); + + it("hides tombstoned entities from snapshot readers", async () => { + seed("s1", [doc("Alice"), doc("Ghost", { deleted_at: new Date() })]); + const snap = await getWorldState("s1"); + expect(snap.entities.map((e) => e.name)).toEqual(["Alice"]); + expect(snap.entities[0]!.updated_at).toBe("2026-01-01T00:00:00.000Z"); + }); +}); + +describe("user-override CRUD (optimistic mutate path)", () => { + it("pinEntity flips the flag; an unknown id is a safe no-op", async () => { + seed("s1", [doc("Alice")]); + const snap = await pinEntity("s1", "Alice", true); + expect(snap.entities[0]!.pinned_by_user).toBe(true); + const noop = await pinEntity("s1", "nobody", true); + expect(noop.entities.map((e) => e.pinned_by_user)).toEqual([true]); + }); + + it("renameEntity demotes the old name to aliases and never self-aliases", async () => { + seed("s1", [doc("Alice", { aliases: ["Al"] })]); + const snap = await renameEntity("s1", "Alice", " Alicia ", null); + const e = snap.entities[0]!; + expect(e.name).toBe("Alicia"); + expect(e.aliases).toEqual(["Al", "Alice"]); + // Renaming BACK: the new primary name must not survive as its own alias. + const back = await renameEntity("s1", "Alice", "Alice", null); + expect(back.entities[0]!.name).toBe("Alice"); + expect(back.entities[0]!.aliases).not.toContain("Alice"); + }); + + it("renameEntity rejects an empty name", async () => { + seed("s1", [doc("Alice")]); + await expect(renameEntity("s1", "Alice", " ", null)).rejects.toThrow( + "name cannot be empty", + ); + }); + + it("delete tombstones (hidden from reads) and undo restores", async () => { + seed("s1", [doc("Alice"), doc("Bob")]); + await deleteEntity("s1", "Bob"); + expect((await getWorldState("s1")).entities.map((e) => e.name)).toEqual(["Alice"]); + await undoDeleteEntity("s1", "Bob"); + expect((await getWorldState("s1")).entities.map((e) => e.name).sort()).toEqual([ + "Alice", + "Bob", + ]); + }); + + it("setEntityAppearance trims + stores the reference image; empty rejects", async () => { + seed("s1", [doc("Alice")]); + const snap = await setEntityAppearance("s1", "Alice", " red cloak ", "https://img/x.png"); + expect(snap.entities[0]!.appearance).toBe("red cloak"); + expect(snap.entities[0]!.reference_image_url).toBe("https://img/x.png"); + await expect(setEntityAppearance("s1", "Alice", " ", null)).rejects.toThrow( + "appearance cannot be empty", + ); + }); + + it("mergeEntities consolidates into the target and removes the source", async () => { + seed("s1", [ + doc("Alice", { + aliases: ["Al"], + facts: ["carries a lamp"], + state: { posture: "sitting" }, + appears_on_node_ids: ["n1"], + }), + doc("Alicia", { + aliases: ["Ali"], + facts: ["wears a red cloak"], + state: { posture: "standing", lit: true }, + appears_on_node_ids: ["n2"], + pinned_by_user: true, + }), + ]); + const snap = await mergeEntities("s1", "Alicia", "Alice"); + expect(snap.entities.map((e) => e.name)).toEqual(["Alice"]); + const merged = snap.entities[0]!; + expect(merged.aliases).toEqual(["Al", "Alicia", "Ali"]); + expect(merged.facts).toEqual(["carries a lamp", "wears a red cloak"]); + // Target-wins on shared keys; source-only keys survive. + expect(merged.state).toEqual({ posture: "sitting", lit: true }); + expect(merged.appears_on_node_ids).toEqual(["n1", "n2"]); + expect(merged.pinned_by_user).toBe(true); // pin propagates from source + }); + + it("mergeEntities with self or a missing record changes nothing", async () => { + seed("s1", [doc("Alice")]); + const self = await mergeEntities("s1", "Alice", "Alice"); + expect(self.entities).toHaveLength(1); + const ghost = await mergeEntities("s1", "nobody", "Alice"); + expect(ghost.entities.map((e) => e.name)).toEqual(["Alice"]); + }); +}); + +describe("resolveEntitiesForPrompt (planner continuity slice)", () => { + it("empty registry / no doc → []", async () => { + expect(await resolveEntitiesForPrompt({ sessionId: "s_missing", query: "q" })).toEqual([]); + seed("s1", [doc("Ghost", { deleted_at: new Date() })]); + expect(await resolveEntitiesForPrompt({ sessionId: "s1", query: "q" })).toEqual([]); + }); + + it("ships the slim context shape and never a tombstone", async () => { + seed("s1", [ + doc("Alice", { appearance: "red cloak", state: { posture: "sitting" } }), + doc("Ghost", { deleted_at: new Date() }), + ]); + const out = await resolveEntitiesForPrompt({ + sessionId: "s1", + query: "Alice enters the hall", + parentTitle: "The Hall", + }); + expect(out).toEqual([ + { + id: "Alice", + kind: "person", + name: "Alice", + aliases: [], + appearance: "red cloak", + reference_image_url: null, + state: { posture: "sitting" }, + }, + ]); + }); + + it("best-effort: a DB failure resolves to [] instead of breaking generation", async () => { + mongo.state.failFind = true; + expect(await resolveEntitiesForPrompt({ sessionId: "s1", query: "q" })).toEqual([]); + }); +}); + +describe("listPriorEntitiesForExtraction (prior slice scoring)", () => { + it("no doc / all tombstoned → []", async () => { + expect(await listPriorEntitiesForExtraction("s_missing")).toEqual([]); + seed("s1", [doc("Ghost", { deleted_at: new Date() })]); + expect(await listPriorEntitiesForExtraction("s1")).toEqual([]); + }); + + it("caption whole-word hits outrank recency; substring-only hits do not", async () => { + seed("s1", [ + doc("Ana", { aliases: ["Annie"], updated_at: new Date("2026-01-01") }), + doc("Fresh", { updated_at: new Date("2026-06-01") }), + ]); + // "banana" contains "ana" but not as a word → Fresh keeps the top slot. + const noHit = await listPriorEntitiesForExtraction("s1", "a banana stand"); + expect(noHit.map((e) => e.name)).toEqual(["Fresh", "Ana"]); + // A real word-boundary mention (name + alias) pulls the stale entity front. + const hit = await listPriorEntitiesForExtraction("s1", "Ana, called Annie, waves"); + expect(hit.map((e) => e.name)).toEqual(["Ana", "Fresh"]); + expect(hit[0]).toEqual({ + id: "Ana", + kind: "person", + name: "Ana", + aliases: ["Annie"], + appearance: "", + }); + }); + + it("alias mentions score too (recency held equal)", async () => { + const t = new Date("2026-03-01"); + seed("s1", [ + doc("Bystander", { updated_at: t }), + doc("Old Sailor", { aliases: ["the captain"], updated_at: t }), + ]); + const out = await listPriorEntitiesForExtraction("s1", "the captain steers"); + expect(out.map((e) => e.name)).toEqual(["Old Sailor", "Bystander"]); + }); +}); + +// Type-level guard that the wire mapping above stays the real Entity shape. +const _wireCheck = (e: Entity): string => e.id; +void _wireCheck; diff --git a/apps/web/lib/world-map.test.ts b/apps/web/lib/world-map.test.ts index daea2c4..004591f 100644 --- a/apps/web/lib/world-map.test.ts +++ b/apps/web/lib/world-map.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vitest"; - -import type { EntityGeoEdit, WorldEntityGeo } from "@openflipbook/config"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EntityGeoEdit, SceneView, WorldEntityGeo } from "@openflipbook/config"; + +// In-memory Mongo stand-in so the optimistic read-modify-write wrappers run for +// real (findOne / insertOne-with-dup / replaceOne-filtered-on-updated_at) +// without a database. recordError calls are captured for the INV-4 assertion. +const mongo = vi.hoisted(() => { + const docs = new Map>(); + const errors: Record[] = []; + const col = { + async findOne(filter: { _id: string }) { + return docs.get(filter._id) ?? null; + }, + async insertOne(doc: { _id: string; updated_at: Date }) { + if (docs.has(doc._id)) { + const err = new Error("dup") as Error & { code?: number }; + err.code = 11000; + throw err; + } + docs.set(doc._id, doc); + return { acknowledged: true }; + }, + async replaceOne(filter: { _id: string; updated_at: Date }, next: { _id: string; updated_at: Date }) { + const cur = docs.get(filter._id); + if (!cur || cur.updated_at.getTime() !== filter.updated_at.getTime()) { + return { matchedCount: 0 }; + } + docs.set(filter._id, next); + return { matchedCount: 1 }; + }, + }; + return { docs, errors, col }; +}); -import { __test, ladderDisagreement, registerPlanToImage } from "./world-map"; +vi.mock("./db", () => ({ + getDb: async () => ({ collection: () => mongo.col }), + recordError: async (input: Record) => { + mongo.errors.push(input); + }, +})); + +import { + __test, + applyEntityEdits, + deriveGeoFromExtraction, + getWorldMap, + ladderDisagreement, + registerPlanToImage, + removeEntityGeos, + upsertEntityGeos, +} from "./world-map"; const { applyGeoUpsert, recomputeBounds, applyEntityEdit, blastRadius, buildGeoReferences } = __test; @@ -362,3 +409,192 @@ describe("registerPlanToImage (plan plane → image register)", () => { ).toBeNull(); }); }); + +// ── Mongo wrappers + the extraction seeding bridge (fake collection) ──────── + +const mapView = (crop = { x: 0, y: 0, w: 100, h: 60 }): SceneView => ({ + node_id: "n1", + level: "map", + observer: null, + map_crop: crop, +}); + +describe("world-map Mongo wrappers (optimistic read-modify-write)", () => { + beforeEach(() => { + mongo.docs.clear(); + mongo.errors.length = 0; + }); + + it("getWorldMap on an unknown session → empty snapshot at epoch", async () => { + const snap = await getWorldMap("s_missing"); + expect(snap.session_id).toBe("s_missing"); + expect(snap.entities).toEqual([]); + expect(snap.bounds).toEqual({ x: 0, y: 0, w: 0, h: 0 }); + expect(snap.updated_at).toBe(new Date(0).toISOString()); + }); + + it("upsertEntityGeos inserts then replaces, recomputing bounds each write", async () => { + const first = await upsertEntityGeos("s1", [geo("a", "derived", 0, 0)]); + expect(first.entities).toHaveLength(1); + expect(first.bounds.w).toBeCloseTo(6); // one 6×6 footprint + // Second write goes down the replaceOne path (doc now exists). + const second = await upsertEntityGeos("s1", [geo("b", "derived", 10, 0)]); + expect(second.entities.map((e) => e.id).sort()).toEqual(["a", "b"]); + expect(second.bounds.w).toBeCloseTo(16); // -3 → 13 + // Round-trips through getWorldMap (snapshotFromDoc path). + const read = await getWorldMap("s1"); + expect(read.entities).toHaveLength(2); + expect(read.updated_at).toBe(second.updated_at); + }); + + it("applyEntityEdits([]) reads without writing; edits run through the pure core", async () => { + await upsertEntityGeos("s2", [geo("geo_a", "derived", 10, 10)]); + const before = await applyEntityEdits("s2", []); + expect(before.entities[0]!.pos).toEqual({ x: 10, y: 10 }); + const after = await applyEntityEdits("s2", [ + { op: "move", target: "geo_a", dx: 5, dy: -3 }, + { op: "set_height", target: "geo_a", height: 9 }, + ]); + expect(after.entities[0]!.pos).toEqual({ x: 15, y: 7 }); + expect(after.entities[0]!.height).toBe(9); + expect(after.entities[0]!.source).toBe("user"); + }); + + it("removeEntityGeos drops ids + re-roots orphans; empty ids is a read", async () => { + const parent = geo("geo_p", "user", 10, 10); + const child = { ...geo("geo_c", "derived", 1, 1), parent_id: "geo_p" }; + await upsertEntityGeos("s3", [parent, child]); + const noop = await removeEntityGeos("s3", []); + expect(noop.entities).toHaveLength(2); + const after = await removeEntityGeos("s3", ["geo_p"]); + expect(after.entities.map((e) => e.id)).toEqual(["geo_c"]); + expect(after.entities[0]!.parent_id ?? null).toBeNull(); + }); +}); + +describe("deriveGeoFromExtraction (extraction → derived map geometry)", () => { + beforeEach(() => { + mongo.docs.clear(); + mongo.errors.length = 0; + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const item = (id: string, over: Record = {}) => ({ + entity_id: id, + kind: "place" as const, + label: id, + bbox: { x_pct: 0.4, y_pct: 0.4, w_pct: 0.2, h_pct: 0.2 }, + ...over, + }); + + it("no items → current snapshot, no write", async () => { + const snap = await deriveGeoFromExtraction("s4", mapView(), 16 / 9, []); + expect(snap.entities).toEqual([]); + expect(mongo.docs.size).toBe(0); + }); + + it("seeds discounted derived geos from top-down map bboxes (+ rung stamp)", async () => { + const snap = await deriveGeoFromExtraction( + "s5", + mapView(), + 16 / 9, + [item("e1", { confidence: 0.8, visual: "tall" })], + "top_down", + -90, + null, + "city", + ); + expect(snap.entities).toHaveLength(1); + const g = snap.entities[0]!; + expect(g.id).toBe("geo_e1"); + expect(g.entity_id).toBe("e1"); + expect(g.parent_id).toBeNull(); + expect(g.source).toBe("derived"); + expect(g.confidence).toBeCloseTo(0.48); // 0.8 × 0.6 discount + expect(g.scale_tier).toBe("city"); + expect(g.visual).toBe("tall"); + // bbox centre (0.5, 0.5) into the 100×60 crop. + expect(g.pos).toEqual({ x: 50, y: 30 }); + expect(g.footprint).toEqual({ w: 20, d: 12 }); + }); + + it("defaults confidence to 0.5 (→ 0.3 stored) and omits the rung when absent", async () => { + const snap = await deriveGeoFromExtraction("s6", mapView(), 16 / 9, [item("e2")]); + expect(snap.entities[0]!.confidence).toBeCloseTo(0.3); + expect(snap.entities[0]!.scale_tier).toBeUndefined(); + }); + + it("seeding into a parent learns its scale; INV-4 disagreement hits recordError", async () => { + // Parent claims the OUTWARD direction (district→city should GROW) but its + // 10-wide footprint over a 20-wide interior learns scale 0.5 → warn, keep. + await upsertEntityGeos("s7", [ + { ...geo("geo_parent", "user", 30, 18), footprint: { w: 10, d: 10 }, scale_tier: "district" }, + ]); + const snap = await deriveGeoFromExtraction( + "s7", + mapView(), + 16 / 9, + [item("e3")], + "top_down", + -90, + "geo_parent", + "city", + ); + const child = snap.entities.find((e) => e.id === "geo_e3")!; + expect(child.parent_id).toBe("geo_parent"); + const parent = snap.entities.find((e) => e.id === "geo_parent")!; + // footprint extent 10 ÷ interior extent 20 (one 20×12 child) = 0.5. + expect(parent.scale).toBeCloseTo(0.5); + expect(parent.source).toBe("user"); // scale-only write keeps authority + expect(mongo.errors).toHaveLength(1); + expect(mongo.errors[0]!.kind).toBe("world-map.inv4"); + expect(String(mongo.errors[0]!.message)).toMatch(/OUTWARD/); + }); + + it("a missing parent id seeds children without learning any scale", async () => { + const snap = await deriveGeoFromExtraction( + "s8", + mapView(), + 16 / 9, + [item("e4")], + "top_down", + -90, + "geo_ghost", + ); + expect(snap.entities.map((e) => e.id)).toEqual(["geo_e4"]); + expect(mongo.errors).toHaveLength(0); + }); + + it("persists borders + height_m only behind WORLD_SEGMENT_BORDERS", async () => { + const border = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ]; + vi.stubEnv("WORLD_SEGMENT_BORDERS", ""); + const off = await deriveGeoFromExtraction("s9", mapView(), 16 / 9, [ + item("e5", { border, height_m: 12 }), + ]); + expect(off.entities[0]!.border).toBeUndefined(); + expect(off.entities[0]!.height_m).toBeUndefined(); + + vi.stubEnv("WORLD_SEGMENT_BORDERS", "1"); + const on = await deriveGeoFromExtraction("s10", mapView(), 16 / 9, [ + item("e6", { border, height_m: 12 }), + item("e7", { border: border.slice(0, 2), height_m: 0 }), // <3 pts, no height + ]); + const g6 = on.entities.find((e) => e.id === "geo_e6")!; + // Image-normalized polygon mapped linearly into the 100×60 crop. + expect(g6.border).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + ]); + expect(g6.height_m).toBe(12); + const g7 = on.entities.find((e) => e.id === "geo_e7")!; + expect(g7.border).toBeUndefined(); + expect(g7.height_m).toBeUndefined(); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 329e16c..714cb81 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -31,14 +31,14 @@ export default defineConfig({ include: ["lib/**/*.{ts,tsx}", "hooks/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], exclude: ["**/*.test.{ts,tsx}"], // The RATCHET: floors pinned just under the measured baseline - // (2026-07-19: lines/stmts 71.9, functions 73.4, branches 86.2). CI + // (2026-07-20: lines/stmts 77.6, functions 80.8, branches 86.8). CI // runs vitest with --coverage so a PR that drops below any floor // FAILS; a PR that raises coverage bumps the floor to the new number. // Never lower these to make a PR pass — that defeats the ratchet. thresholds: { - lines: 70, - statements: 70, - functions: 72, + lines: 76, + statements: 76, + functions: 79, branches: 85, }, },