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"]); }); });