- Added a new command to fix findings in the current section of a markdown file, enhancing user experience by automating issue resolution. - Introduced settings for configuring actions related to missing notes and headings, allowing for customizable behavior during the fixing process. - Enhanced the chain inspector to support template matching, providing users with insights into template utilization and potential gaps in their content. - Updated the analysis report to include detailed metadata about edges and role matches, improving the clarity and usefulness of inspection results. - Improved error handling and user notifications for fixing findings and template matching processes, ensuring better feedback during execution.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|