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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user