/** * Tests for template matching. */ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { App, TFile } from "obsidian"; import { matchTemplates } from "../../analysis/templateMatching"; import type { ChainTemplatesConfig, ChainRolesConfig } from "../../dictionary/types"; import type { IndexedEdge } from "../../analysis/graphIndex"; import type { EdgeVocabulary } from "../../vocab/types"; describe("templateMatching", () => { let mockApp: App; let mockChainRoles: ChainRolesConfig; let mockEdgeVocabulary: EdgeVocabulary | null; beforeEach(() => { // Mock App mockApp = { vault: { getAbstractFileByPath: vi.fn(), cachedRead: vi.fn(), }, metadataCache: { getFileCache: vi.fn(), getFirstLinkpathDest: vi.fn(), }, } as any; // Mock Chain Roles mockChainRoles = { roles: { causal: { edge_types: ["causes", "caused_by", "resulted_in"], }, influences: { edge_types: ["influences", "influenced_by", "wirkt_auf"], }, structural: { edge_types: ["part_of", "contains"], }, }, }; mockEdgeVocabulary = null; }); it("should match template with rich format and all slots filled (including 1-hop outgoing neighbors)", async () => { const templatesConfig: ChainTemplatesConfig = { templates: [ { name: "trigger_transformation_outcome", description: "Test template", slots: [ { id: "trigger", allowed_node_types: ["experience"] }, { id: "transformation", allowed_node_types: ["insight"] }, { id: "outcome", allowed_node_types: ["decision"] }, ], links: [ { from: "trigger", to: "transformation", allowed_edge_roles: ["causal", "influences"], }, { from: "transformation", to: "outcome", allowed_edge_roles: ["causal"], }, ], }, ], }; // Mock edges: A -> B -> C // A is current context, B is outgoing neighbor, C is 1-hop from B const allEdges: IndexedEdge[] = [ { rawEdgeType: "wirkt_auf", source: { file: "Tests/01_experience_trigger.md", sectionHeading: "Kontext" }, target: { file: "03_insight_transformation", heading: "Kern" }, scope: "section", evidence: { file: "Tests/01_experience_trigger.md", sectionHeading: "Kontext", lineRange: { start: 5, end: 6 }, }, }, { rawEdgeType: "resulted_in", source: { file: "Tests/03_insight_transformation.md", sectionHeading: "Kern" }, target: { file: "04_decision_outcome", heading: "Entscheidung" }, scope: "section", evidence: { file: "Tests/03_insight_transformation.md", sectionHeading: "Kern", lineRange: { start: 3, end: 4 }, }, }, ]; // Mock metadataCache for link resolution (basename -> full path) // Note: buildCandidateNodes resolves from currentContext.file, not from edge source (mockApp.metadataCache.getFirstLinkpathDest as any) = vi.fn((link: string, source: string) => { if (link === "03_insight_transformation" && source === "Tests/01_experience_trigger.md") { return { path: "Tests/03_insight_transformation.md", extension: "md" } as TFile; } if (link === "04_decision_outcome") { // Can be resolved from any source in Tests/ folder if (source.startsWith("Tests/")) { return { path: "Tests/04_decision_outcome.md", extension: "md" } as TFile; } } return null; }); // Mock file reads (mockApp.vault.getAbstractFileByPath as any).mockImplementation((path: string) => { if (path === "Tests/01_experience_trigger.md" || path === "Tests/03_insight_transformation.md" || path === "Tests/04_decision_outcome.md") { return { extension: "md", path } as TFile; } return null; }); (mockApp.vault.cachedRead as any).mockImplementation((file: TFile) => { if (file.path === "Tests/01_experience_trigger.md") { return Promise.resolve(`--- type: experience --- ## Kontext `); } if (file.path === "Tests/03_insight_transformation.md") { return Promise.resolve(`--- type: insight --- ## Kern `); } if (file.path === "Tests/04_decision_outcome.md") { return Promise.resolve(`--- type: decision --- ## Entscheidung `); } return Promise.resolve(`--- type: unknown --- `); }); const matches = await matchTemplates( mockApp, { file: "Tests/01_experience_trigger.md", heading: "Kontext" }, allEdges, templatesConfig, mockChainRoles, mockEdgeVocabulary, { includeNoteLinks: true, includeCandidates: false } ); expect(matches.length).toBeGreaterThan(0); const match = matches[0]; expect(match?.templateName).toBe("trigger_transformation_outcome"); expect(match?.missingSlots).toEqual([]); expect(match?.slotAssignments["trigger"]?.file).toBe("Tests/01_experience_trigger.md"); expect(match?.slotAssignments["transformation"]?.file).toBe("Tests/03_insight_transformation.md"); expect(match?.slotAssignments["outcome"]?.file).toBe("Tests/04_decision_outcome.md"); expect(match?.satisfiedLinks).toBe(2); expect(match?.requiredLinks).toBe(2); }); it("should detect missing slot when edge is missing", async () => { const templatesConfig: ChainTemplatesConfig = { templates: [ { name: "trigger_transformation_outcome", slots: [ { id: "trigger", allowed_node_types: ["experience"] }, { id: "transformation", allowed_node_types: ["insight"] }, { id: "outcome", allowed_node_types: ["decision"] }, ], links: [ { from: "trigger", to: "transformation", allowed_edge_roles: ["causal"], }, { from: "transformation", to: "outcome", allowed_edge_roles: ["causal"], }, ], }, ], }; // Mock edges: A -> B (missing B -> C) const allEdges: IndexedEdge[] = [ { rawEdgeType: "causes", source: { file: "NoteA.md", sectionHeading: "Kontext" }, target: { file: "NoteB.md", heading: null }, scope: "section", evidence: { file: "NoteA.md", sectionHeading: "Kontext", lineRange: { start: 5, end: 6 }, }, }, ]; // Mock metadataCache for link resolution (mockApp.metadataCache.getFirstLinkpathDest as any) = vi.fn((link: string, source: string) => { if (link === "03_insight_transformation" && source === "Tests/01_experience_trigger.md") { return { path: "Tests/03_insight_transformation.md", extension: "md" } as TFile; } return null; }); (mockApp.vault.getAbstractFileByPath as any).mockImplementation((path: string) => { if (path === "Tests/01_experience_trigger.md" || path === "Tests/03_insight_transformation.md") { return { extension: "md", path } as TFile; } return null; }); (mockApp.vault.cachedRead as any).mockImplementation((file: TFile) => { if (file.path === "Tests/01_experience_trigger.md") { return Promise.resolve(`--- type: experience --- ## Kontext `); } if (file.path === "Tests/03_insight_transformation.md") { return Promise.resolve(`--- type: insight --- `); } return Promise.resolve(`--- type: unknown --- `); }); const matches = await matchTemplates( mockApp, { file: "Tests/01_experience_trigger.md", heading: "Kontext" }, allEdges, templatesConfig, mockChainRoles, mockEdgeVocabulary, { includeNoteLinks: true, includeCandidates: false } ); expect(matches.length).toBeGreaterThan(0); const match = matches[0]; expect(match?.templateName).toBe("trigger_transformation_outcome"); // Should have missing slots (outcome slot cannot be filled) expect(match?.missingSlots.length).toBeGreaterThan(0); }); it("should produce deterministic results regardless of edge order", async () => { const templatesConfig: ChainTemplatesConfig = { templates: [ { name: "simple_chain", slots: [ { id: "start", allowed_node_types: ["experience"] }, { id: "end", allowed_node_types: ["decision"] }, ], links: [ { from: "start", to: "end", allowed_edge_roles: ["causal"], }, ], }, ], }; // Create edges in different orders const edges1: IndexedEdge[] = [ { rawEdgeType: "causes", source: { file: "Tests/01_experience_trigger.md", sectionHeading: "Kontext" }, target: { file: "02_decision_outcome", heading: null }, scope: "section", evidence: { file: "Tests/01_experience_trigger.md", sectionHeading: "Kontext", lineRange: { start: 5, end: 6 }, }, }, ]; const edges2: IndexedEdge[] = [ { rawEdgeType: "causes", source: { file: "Tests/01_experience_trigger.md", sectionHeading: "Kontext" }, target: { file: "02_decision_outcome", heading: null }, scope: "section", evidence: { file: "Tests/01_experience_trigger.md", sectionHeading: "Kontext", lineRange: { start: 5, end: 6 }, }, }, ]; // Mock metadataCache for link resolution (mockApp.metadataCache.getFirstLinkpathDest as any) = vi.fn((link: string, source: string) => { if (link === "02_decision_outcome" && source === "Tests/01_experience_trigger.md") { return { path: "Tests/02_decision_outcome.md", extension: "md" } as TFile; } return null; }); (mockApp.vault.getAbstractFileByPath as any).mockImplementation((path: string) => { if (path === "Tests/01_experience_trigger.md" || path === "Tests/02_decision_outcome.md") { return { extension: "md", path } as TFile; } return null; }); (mockApp.vault.cachedRead as any).mockImplementation((file: TFile) => { if (file.path === "Tests/01_experience_trigger.md") { return Promise.resolve(`--- type: experience --- ## Kontext `); } if (file.path === "Tests/02_decision_outcome.md") { return Promise.resolve(`--- type: decision --- `); } return Promise.resolve(`--- type: unknown --- `); }); const matches1 = await matchTemplates( mockApp, { file: "Tests/01_experience_trigger.md", heading: "Kontext" }, edges1, templatesConfig, mockChainRoles, mockEdgeVocabulary, { includeNoteLinks: true, includeCandidates: false } ); const matches2 = await matchTemplates( mockApp, { file: "Tests/01_experience_trigger.md", heading: "Kontext" }, edges2, templatesConfig, mockChainRoles, mockEdgeVocabulary, { includeNoteLinks: true, includeCandidates: false } ); expect(matches1.length).toBe(matches2.length); if (matches1.length > 0 && matches2.length > 0) { expect(matches1[0]?.templateName).toBe(matches2[0]?.templateName); expect(matches1[0]?.score).toBe(matches2[0]?.score); expect(matches1[0]?.missingSlots).toEqual(matches2[0]?.missingSlots); } }); });