301e842c5c
- Consistency tab in EntitiesView surfacing findInconsistencies() results - ContextRail + MentionPopover wired into WritingView for entity context/hover - CommandPalette indexes entities and chapters for cross-entity search - link field type for entity references in character fields - findDeadCharacterMentions consistency check + tests - Expand seed with a location, note, and a natural Elias Vell mention so the dead-character-after-death case is actually exercised - Author docs/DATA_MODEL.md documenting core schemas, types, storage contract - Rename CharacterMention id attr -> entityId to match core backlinks/consistency
225 lines
6.9 KiB
TypeScript
225 lines
6.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
findOrphanedMentions,
|
|
parseRelationships,
|
|
findRelationshipAsymmetry,
|
|
findDeadCharacterMentions,
|
|
findInconsistencies,
|
|
SYMMETRIC_RELATIONSHIPS,
|
|
} from "./consistency.js";
|
|
import { collectMentionsInDoc } from "./backlinks.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, relationship-asymmetry, and dead-character issues", () => {
|
|
// Chapter mentions "ghost" (orphan) and "ada" (dead character still alive in prose).
|
|
const chapters = [
|
|
{
|
|
id: "ch1",
|
|
title: "Ch 1",
|
|
doc: {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Ada met " },
|
|
{ type: "CharacterMention", attrs: { entityId: "ada", name: "Ada" } },
|
|
{ type: "text", text: " and a " },
|
|
{ type: "CharacterMention", attrs: { entityId: "ghost", name: "ghost" } },
|
|
],
|
|
},
|
|
],
|
|
} as ProseMirrorDoc,
|
|
},
|
|
];
|
|
const ada: Character = {
|
|
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
|
|
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
|
|
arc: "", status: "dead", 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);
|
|
expect(issues.some((i) => i.kind === "dead-character-mentioned")).toBe(true);
|
|
});
|
|
|
|
it("does not flag a dead character who is not mentioned in any chapter", () => {
|
|
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
|
const ada: Character = {
|
|
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
|
|
arc: "", status: "dead", tags: [],
|
|
relationships: [],
|
|
};
|
|
const issues = findDeadCharacterMentions(chapters, [ada]);
|
|
expect(issues).toHaveLength(0);
|
|
});
|
|
|
|
it("collects every entity id referenced in a doc", () => {
|
|
const docWithTwo = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Ada and " },
|
|
{ type: "CharacterMention", attrs: { entityId: "maria", name: "Maria" } },
|
|
{ type: "text", text: " met " },
|
|
{ type: "CharacterMention", attrs: { entityId: "brad", name: "Brad" } },
|
|
],
|
|
},
|
|
],
|
|
} as ProseMirrorDoc;
|
|
const ids = collectMentionsInDoc(docWithTwo);
|
|
expect([...ids].sort()).toEqual(["brad", "maria"]);
|
|
});
|
|
});
|