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
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { fuzzyScore, fuzzyMatch, fuzzySort } from "./fuzzy.js";
describe("fuzzyScore", () => {
it("matches an empty query as no-match (score 0)", () => {
expect(fuzzyScore("", "mara").score).toBe(0);
});
it("matches a whole-string query at score 1", () => {
const r = fuzzyScore("mara", "Mara");
expect(r.score).toBe(1);
expect(r.indices).toEqual([0, 1, 2, 3]);
});
it("matches a subsequence in order", () => {
const r = fuzzyScore("mr", "Mara");
expect(r.score).toBeGreaterThan(0);
expect(r.indices).toEqual([0, 2]);
});
it("does not match a non-subsequence", () => {
expect(fuzzyScore("z", "Mara").score).toBe(0);
expect(fuzzyScore("marz", "Mara").score).toBe(0);
});
it("scores consecutive matches higher than scattered ones", () => {
const consecutive = fuzzyScore("mar", "maria").score;
const scattered = fuzzyScore("mr", "maria").score;
expect(consecutive).toBeGreaterThan(scattered);
});
it("scores word-start matches higher", () => {
const wordStart = fuzzyScore("h", "harbor").score;
const mid = fuzzyScore("r", "harbor").score;
expect(wordStart).toBeGreaterThan(mid);
});
});
describe("fuzzyMatch", () => {
it("is true for a subsequence and false otherwise", () => {
expect(fuzzyMatch("mr", "Mara")).toBe(true);
expect(fuzzyMatch("z", "Mara")).toBe(false);
});
});
describe("fuzzySort", () => {
const items = [
{ id: "ada", name: "Ada Lovelace" },
{ id: "maria", name: "Maria Harbor" },
{ id: "mark", name: "Mark Twain" },
];
it("returns all items for an empty query, preserving order", () => {
expect(fuzzySort(items, "", (i) => i.name).map((i) => i.id)).toEqual(["ada", "maria", "mark"]);
});
it("drops non-matches and orders by descending score", () => {
const sorted = fuzzySort(items, "mar", (i) => i.name);
expect(sorted.map((i) => i.id)).toContain("maria");
expect(sorted.map((i) => i.id)).not.toContain("ada");
// "Maria Harbor" (word start) should beat "Mark Twain" (word start) only on tie-break; both match.
expect(sorted.length).toBe(2);
});
it("is stable for equal scores", () => {
// "ada" and "amy" both score 2 for query "a" (word-start match); input
// order must be preserved.
const stable = [
{ id: "a", name: "ada" },
{ id: "b", name: "amy" },
];
const sorted = fuzzySort(stable, "a", (i) => i.name);
expect(sorted.map((i) => i.id)).toEqual(["a", "b"]);
});
});