feat(entity): Chunk #4 data layer + entity UI (characters & world-building)

Data layer:
- Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests
- Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks
- Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests
- StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity;
  implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search
- WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind
- @mention autocomplete extension + picker in the editor package

Entity UI (apps/web/src/features/entities/):
- Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef
- Tabbed directory with fuzzy search + entity cards (EntitiesView)
- Create/edit/delete drawer (EntityEditor) reusing shared fields
- Relationship editor + force-directed SVG relationship map
- Entity engine stylesheet; wired into nav

Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract.

CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
Schwitzz
2026-10-25 22:42:55 -05:00
parent 81a44b6e95
commit 4efa71515e
36 changed files with 2764 additions and 82 deletions
+105 -1
View File
@@ -1,6 +1,6 @@
import "fake-indexeddb/auto";
import { describe, expect, it } from "vitest";
import { createServerClient, type ProseMirrorDoc, type StorageAdapter } from "@pubscript/core";
import { createServerClient, type ProseMirrorDoc, type Character, type StorageAdapter, type WorldItem } from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] });
@@ -63,6 +63,31 @@ function mockServerFetch(): typeof fetch {
entities.set(`${m[1]!}|${body.kind}|${e.id}`, e);
return json({ entity: e });
}
if (method === "GET" && m) {
const projectId = m[1]!;
const kind = u.searchParams.get("kind");
const q = u.searchParams.get("q") ?? "";
const all = [...entities.values()].filter((e) => e.projectId === projectId && (!kind || e.kind === kind));
const filtered = q
? all.filter((e) => JSON.stringify(e).toLowerCase().includes(q.toLowerCase()))
: all;
return json({ entities: filtered });
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities\/([^/]+)\/([^/]+)$/);
if (method === "GET" && m) {
const projectId = m[1]!;
const kind = m[2]!;
const id = m[3]!;
const e = entities.get(`${projectId}|${kind}|${id}`);
return e ? json({ entity: e }) : json({ error: "not_found" }, 404);
}
if (method === "DELETE" && m) {
const projectId = m[1]!;
const kind = m[2]!;
const id = m[3]!;
entities.delete(`${projectId}|${kind}|${id}`);
return json({ ok: true });
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/);
if (method === "POST" && m) {
const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] };
@@ -132,6 +157,85 @@ const EXPECTED = {
chapterRev: 1,
};
/** Entity CRUD + search scenario both adapters must pass identically. */
async function entityScenario(adapter: StorageAdapter) {
const projectId = "proj-ent";
await adapter.createProject({ title: "Entities" });
const ada: Character = {
id: "ada",
projectId,
name: "Ada Lovelace",
role: "protagonist",
bio: "A mathematician and first computer programmer.",
appearance: "sharp eyes, Victorian dress",
voice: "precise, confident",
relationships: [{ targetId: "babbage", kind: "ally", note: "worked together" }],
arc: "From curiosity to genius.",
status: "active",
tags: ["math", "victorian"],
};
const babbage: Character = {
id: "babbage",
projectId,
name: "Charles Babbage",
role: "mentor",
bio: "An obsessed inventor of computing machines.",
appearance: "bearded, bespectacled",
voice: "grumbly",
relationships: [],
arc: "",
status: "active",
tags: ["inventor"],
};
const clocktower: WorldItem = {
id: "clocktower",
projectId,
kind: "location",
name: "The Clocktower",
fields: { era: "1840s", significance: "meeting place" },
};
await adapter.saveEntity(ada);
await adapter.saveEntity(babbage);
await adapter.saveEntity(clocktower);
// list all
const all = await adapter.listEntities(projectId);
expect(all.map((e) => e.id).sort()).toEqual(["ada", "babbage", "clocktower"]);
// list by kind
const chars = await adapter.listEntities(projectId, "character");
expect(chars.map((e) => e.id).sort()).toEqual(["ada", "babbage"]);
// getEntity: found
expect(((await adapter.getEntity(projectId, "character", "ada")) as Character | null)?.name).toBe("Ada Lovelace");
// getEntity: not found
expect(await adapter.getEntity(projectId, "character", "nope")).toBeNull();
// search: fuzzy match, best-first
const babbageRes = await adapter.searchEntities(projectId, "babbage");
expect(babbageRes.map((e) => e.id)).toContain("babbage");
const adaRes = await adapter.searchEntities(projectId, "ada");
expect(adaRes[0]?.id).toBe("ada");
// search: empty returns all
expect((await adapter.searchEntities(projectId, "")).length).toBe(3);
// delete
await adapter.deleteEntity(projectId, "character", "ada");
expect(await adapter.getEntity(projectId, "character", "ada")).toBeNull();
expect((await adapter.listEntities(projectId)).map((e) => e.id)).toEqual(["babbage", "clocktower"]);
}
describe.each([
{ name: "local (IndexedDB)", adapter: storageLocal },
{ name: "server (REST)", adapter: createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() }) },
])("$name adapter honors the entity contract", async ({ adapter }) => {
it("lists, fetches, searches, and deletes entities", () => entityScenario(adapter));
});
describe("StorageAdapter contract (web/local vs server)", () => {
it("local (IndexedDB) adapter honors the contract", async () => {
await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED);