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:
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findOrphanedMentions,
|
||||
parseRelationships,
|
||||
findRelationshipAsymmetry,
|
||||
findInconsistencies,
|
||||
SYMMETRIC_RELATIONSHIPS,
|
||||
} from "./consistency.js";
|
||||
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
|
||||
|
||||
const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "She said " },
|
||||
{ type: "CharacterMention", attrs: entityId ? { entityId, name } : { name } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("findOrphanedMentions", () => {
|
||||
it("flags a mention whose entity id is gone", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
||||
const issues = findOrphanedMentions(chapters, new Set(["ada"]));
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: "orphan-mention", entityId: "ghost", chapterId: "ch1" });
|
||||
});
|
||||
|
||||
it("does not flag a mention whose entity still exists", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ada") }];
|
||||
expect(findOrphanedMentions(chapters, new Set(["ada"]))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag a bare name mention (no entityId)", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc(null) }];
|
||||
expect(findOrphanedMentions(chapters, new Set(["ada"]))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRelationships", () => {
|
||||
it("parses the relationships array into a targetId map", () => {
|
||||
const c: Character = {
|
||||
id: "ada",
|
||||
projectId: "p",
|
||||
name: "Ada",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [
|
||||
{ targetId: "maria", kind: "sibling", note: "sisters" },
|
||||
{ targetId: "mark", kind: "ally", note: "" },
|
||||
],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
expect(parseRelationships(c)).toEqual({
|
||||
maria: { kind: "sibling", note: "sisters" },
|
||||
mark: { kind: "ally", note: "" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRelationshipAsymmetry", () => {
|
||||
const ada: Character = {
|
||||
id: "ada",
|
||||
projectId: "p",
|
||||
name: "Ada",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
const maria: Character = {
|
||||
id: "maria",
|
||||
projectId: "p",
|
||||
name: "Maria",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
const brad: Character = {
|
||||
id: "brad",
|
||||
projectId: "p",
|
||||
name: "Brad",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [{ targetId: "ada", kind: "rival", note: "" }],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
const adaBack: Character = {
|
||||
...ada,
|
||||
relationships: [
|
||||
{ targetId: "maria", kind: "sibling", note: "" },
|
||||
{ targetId: "brad", kind: "rival", note: "" },
|
||||
],
|
||||
};
|
||||
|
||||
it("reports a symmetric relationship that is not reciprocated", () => {
|
||||
const issues = findRelationshipAsymmetry([ada, maria]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: "relationship-asymmetry", fromEntity: "ada", toEntity: "maria", relationship: "sibling" });
|
||||
});
|
||||
|
||||
it("does not report when the symmetric relationship is reciprocated", () => {
|
||||
const mariaBack: Character = {
|
||||
...maria,
|
||||
relationships: [{ targetId: "ada", kind: "sibling", note: "" }],
|
||||
};
|
||||
const issues = findRelationshipAsymmetry([adaBack, mariaBack, brad]);
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores directed (non-symmetric) relationship kinds", () => {
|
||||
const mentor: Character = {
|
||||
id: "m",
|
||||
projectId: "p",
|
||||
name: "Mentor",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [{ targetId: "ada", kind: "mentor", note: "" }],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
expect(findRelationshipAsymmetry([ada, mentor])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exposes the symmetric kinds", () => {
|
||||
expect(SYMMETRIC_RELATIONSHIPS.has("sibling")).toBe(true);
|
||||
expect(SYMMETRIC_RELATIONSHIPS.has("mentor")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInconsistencies", () => {
|
||||
it("combines orphan-mention and relationship-asymmetry issues", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
||||
const ada: Character = {
|
||||
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
|
||||
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
|
||||
arc: "", status: "active", tags: [],
|
||||
};
|
||||
const maria: Character = {
|
||||
id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "",
|
||||
relationships: [], arc: "", status: "active", tags: [],
|
||||
};
|
||||
const entities: Entity[] = [ada, maria];
|
||||
const issues = findInconsistencies(chapters, entities);
|
||||
expect(issues.some((i) => i.kind === "orphan-mention")).toBe(true);
|
||||
expect(issues.some((i) => i.kind === "relationship-asymmetry")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user