mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 05:46:42 +00:00
mock api testing
This commit is contained in:
parent
f23b331902
commit
2d3c5d75ec
24 changed files with 3009 additions and 1188 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -22,3 +22,7 @@ data.json
|
|||
.DS_Store
|
||||
|
||||
*.env
|
||||
|
||||
# Test output artifacts
|
||||
tests/vault-output/
|
||||
PLAN.md
|
||||
|
|
|
|||
119
PLAN.md
119
PLAN.md
|
|
@ -1,119 +0,0 @@
|
|||
# Plan: Automated Testing for OpenAugi Plugin
|
||||
|
||||
## Problem
|
||||
|
||||
Currently all testing is manual: open Obsidian, create notes, run commands, inspect output. This means:
|
||||
- Claude Code can't verify changes without you manually testing
|
||||
- No regression safety net
|
||||
- Slow iteration cycle
|
||||
|
||||
## Analysis: Obsidian API Coupling
|
||||
|
||||
After reviewing every service, here's the coupling breakdown:
|
||||
|
||||
| Layer | Services | Obsidian APIs Used |
|
||||
|-------|----------|--------------------|
|
||||
| **Pure logic** (no Obsidian) | `OpenAIService`, `sanitizeFilename`, `BacklinkMapper`, `estimateTokens` | None - just `fetch` and string ops |
|
||||
| **Light coupling** | `FileService` | `Vault.adapter.exists`, `Vault.createFolder`, `Vault.create` |
|
||||
| **Heavy coupling** | `DistillService`, `ContextGatheringService` | `MetadataCache`, `vault.read`, `vault.getMarkdownFiles`, link resolution, Dataview API |
|
||||
|
||||
## Strategy: Mock Obsidian, Test Against Real Files
|
||||
|
||||
We create a **mock Obsidian API** backed by Node.js `fs`, pointed at a **test vault** with fixture markdown files. This lets us test ~80% of logic without Obsidian running.
|
||||
|
||||
No E2E/Electron testing for now — that's complex, fragile, and low-value compared to service-level testing.
|
||||
|
||||
## What We'll Build
|
||||
|
||||
### 1. Add Vitest (test framework)
|
||||
|
||||
- Install `vitest` as dev dependency
|
||||
- Add `npm test` and `npm run test:watch` scripts
|
||||
- Configure to handle TypeScript, mock the `obsidian` module globally
|
||||
|
||||
### 2. Create mock Obsidian API (`tests/mocks/obsidian-mock.ts`)
|
||||
|
||||
A filesystem-backed mock that simulates:
|
||||
- **`TFile`** — wraps real files with `path`, `basename`, `extension`, `stat`
|
||||
- **`Vault`** — `read()`, `create()`, `getMarkdownFiles()`, `adapter.exists()`, `createFolder()`, `getAbstractFileByPath()` — all backed by Node.js `fs`
|
||||
- **`MetadataCache`** — parses `[[wikilinks]]` from markdown files to build `resolvedLinks`, `getFileCache()`, and `getFirstLinkpathDest()`
|
||||
- **`App`** — ties vault + metadataCache together
|
||||
- **`Notice`** — no-op (just logs)
|
||||
|
||||
This is ~150 lines of code and gives us the ability to test DistillService, ContextGatheringService, and FileService against real markdown files.
|
||||
|
||||
### 3. Create test vault with fixtures (`tests/vault/`)
|
||||
|
||||
A small set of markdown files committed to the repo that cover key scenarios:
|
||||
|
||||
```
|
||||
tests/vault/
|
||||
├── Root Note.md # Has [[links]] to other notes
|
||||
├── Linked Note A.md # Forward-linked from root
|
||||
├── Linked Note B.md # Forward-linked from root
|
||||
├── Backlink Source.md # Links back to Root Note
|
||||
├── Journal Note.md # Has ### 2026-02-20 date headers
|
||||
├── Dataview Note.md # Has ```dataview blocks
|
||||
├── Collection Note.md # Has - [x] [[checked]] checkboxes
|
||||
├── Context Note.md # Has context: section
|
||||
├── Special Characters!.md # Tests filename sanitization
|
||||
├── Deeply Linked/
|
||||
│ └── Deep Note.md # Tests depth traversal
|
||||
└── Excluded Folder/
|
||||
└── Should Skip.md # Tests folder exclusion
|
||||
```
|
||||
|
||||
### 4. Write tests
|
||||
|
||||
**Pure unit tests** (no mocks needed):
|
||||
- `filename-utils.test.ts` — `sanitizeFilename`, `BacklinkMapper.processBacklinks`
|
||||
- `openai-service.test.ts` — prompt construction, `extractCustomContext`, response parsing (mock `fetch`)
|
||||
|
||||
**Integration tests** (with mock Obsidian):
|
||||
- `distill-service.test.ts` — link extraction, backlink discovery, content aggregation, journal filtering, date parsing
|
||||
- `context-gathering-service.test.ts` — BFS traversal, character limits, folder exclusion, backlink snippets
|
||||
- `file-service.test.ts` — file creation, collision handling, session folders, backlink mapping
|
||||
|
||||
### 5. npm scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
```
|
||||
|
||||
## What This Enables
|
||||
|
||||
After setup, Claude Code can:
|
||||
1. Make a code change
|
||||
2. Run `npm test`
|
||||
3. See if anything broke
|
||||
4. Add new test cases for edge cases discovered during development
|
||||
|
||||
You can also add test notes to `tests/vault/` to capture edge cases you find during manual testing — they become permanent regression tests.
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Install | `vitest` dev dependency |
|
||||
| Create | `vitest.config.ts` — test config |
|
||||
| Create | `tests/mocks/obsidian-mock.ts` — mock Obsidian API |
|
||||
| Create | `tests/vault/*.md` — ~10 fixture notes |
|
||||
| Create | `tests/filename-utils.test.ts` |
|
||||
| Create | `tests/openai-service.test.ts` |
|
||||
| Create | `tests/distill-service.test.ts` |
|
||||
| Create | `tests/file-service.test.ts` |
|
||||
| Create | `tests/context-gathering-service.test.ts` |
|
||||
| Update | `package.json` — add test scripts + vitest dep |
|
||||
| Update | `tsconfig.json` — exclude tests from build |
|
||||
| Update | `.gitignore` — add `tests/vault-output/` for test artifacts |
|
||||
|
||||
## Not in Scope (for now)
|
||||
|
||||
- **E2E testing with real Obsidian** — requires Playwright + Electron orchestration, fragile
|
||||
- **OpenAI API integration tests** — would cost real API credits; we mock `fetch` instead
|
||||
- **UI modal testing** — Obsidian modals are tightly coupled to the DOM
|
||||
- **Task dispatch testing** — requires tmux, hard to automate in CI
|
||||
2509
package-lock.json
generated
2509
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
"esbuild": "^0.17.3",
|
||||
"obsidian": "^1.8.7",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^4.7.4"
|
||||
"typescript": "^4.7.4",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
264
tests/context-gathering-service.test.ts
Normal file
264
tests/context-gathering-service.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ContextGatheringService } from '../src/services/context-gathering-service';
|
||||
import { DistillService } from '../src/services/distill-service';
|
||||
import { OpenAIService } from '../src/services/openai-service';
|
||||
import { DEFAULT_SETTINGS } from '../src/types/settings';
|
||||
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||
import * as path from 'path';
|
||||
|
||||
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||
|
||||
describe('ContextGatheringService', () => {
|
||||
let app: ReturnType<typeof createMockApp>;
|
||||
let distillService: DistillService;
|
||||
let service: ContextGatheringService;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createMockApp(VAULT_DIR);
|
||||
const openAIService = new OpenAIService('test-key', 'gpt-5');
|
||||
distillService = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
|
||||
service = new ContextGatheringService(app as any, distillService, DEFAULT_SETTINGS);
|
||||
});
|
||||
|
||||
describe('gatherContext - linked-notes mode', () => {
|
||||
it('discovers root note and its forward links at depth 1', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: false,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
const names = result.notes.map(n => n.file.basename);
|
||||
expect(names).toContain('Root Note');
|
||||
expect(names).toContain('Linked Note A');
|
||||
expect(names).toContain('Linked Note B');
|
||||
expect(result.totalNotes).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('respects depth limit', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: false,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
// At depth 1, we should NOT see "Deep Note" (which is depth 2: Root → A → Deep)
|
||||
const names = result.notes.map(n => n.file.basename);
|
||||
expect(names).not.toContain('Deep Note');
|
||||
});
|
||||
|
||||
it('discovers deeper links at depth 2', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 2,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: false,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
// At depth 2, "Deep Note" should be discovered (Root → Linked Note A → Deep Note)
|
||||
const names = result.notes.map(n => n.file.basename);
|
||||
expect(names).toContain('Deep Note');
|
||||
});
|
||||
|
||||
it('includes backlinks when enabled', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: true,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
const names = result.notes.map(n => n.file.basename);
|
||||
// "Backlink Source" links TO Root Note, so should be discovered
|
||||
expect(names).toContain('Backlink Source');
|
||||
|
||||
// Verify the backlink note is marked as such
|
||||
const backlinkNote = result.notes.find(n => n.file.basename === 'Backlink Source');
|
||||
expect(backlinkNote?.isBacklink).toBe(true);
|
||||
expect(backlinkNote?.discoveredVia).toContain('backlink');
|
||||
});
|
||||
|
||||
it('respects character limit', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
// Use a very small character limit
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 2,
|
||||
maxCharacters: 200, // Very small — root note alone should exceed this
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: false,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
// Some notes should be excluded due to size limit
|
||||
const excludedNotes = result.notes.filter(n => !n.included);
|
||||
expect(excludedNotes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws when root note is missing in linked-notes mode', async () => {
|
||||
await expect(
|
||||
service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
})
|
||||
).rejects.toThrow('Root note required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder exclusion', () => {
|
||||
it('excludes notes from specified folders', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 3,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: ['Excluded Folder'],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: true,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
const includedNames = result.notes
|
||||
.filter(n => n.included)
|
||||
.map(n => n.file.basename);
|
||||
expect(includedNames).not.toContain('Should Skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateContent', () => {
|
||||
it('aggregates forward links and backlinks separately', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const gathered = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: true,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
expect(gathered.aggregatedContent).toContain('# Note:');
|
||||
expect(gathered.totalCharacters).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes backlink snippets in aggregated content', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const gathered = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: true,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
// Backlink content should be in the aggregated output
|
||||
if (gathered.notes.some(n => n.isBacklink)) {
|
||||
expect(gathered.aggregatedContent).toContain('# Backlink:');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatherContext - recent-activity mode', () => {
|
||||
it('throws when time window is missing', async () => {
|
||||
await expect(
|
||||
service.gatherContext({
|
||||
sourceMode: 'recent-activity',
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
})
|
||||
).rejects.toThrow('Time window required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('note metadata', () => {
|
||||
it('assigns correct depth to discovered notes', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 2,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: false,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
|
||||
expect(rootEntry?.depth).toBe(0);
|
||||
|
||||
const linkedA = result.notes.find(n => n.file.basename === 'Linked Note A');
|
||||
expect(linkedA?.depth).toBe(1);
|
||||
|
||||
const deepNote = result.notes.find(n => n.file.basename === 'Deep Note');
|
||||
if (deepNote) {
|
||||
expect(deepNote.depth).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes discoveredVia metadata', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
|
||||
const result = await service.gatherContext({
|
||||
sourceMode: 'linked-notes',
|
||||
rootNote: rootFile,
|
||||
linkDepth: 1,
|
||||
maxCharacters: 100000,
|
||||
excludeFolders: [],
|
||||
filterRecentSectionsOnly: false,
|
||||
includeBacklinks: false,
|
||||
backlinkContextLines: 0,
|
||||
});
|
||||
|
||||
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
|
||||
expect(rootEntry?.discoveredVia).toBe('root');
|
||||
|
||||
const linkedEntry = result.notes.find(n => n.file.basename === 'Linked Note A');
|
||||
expect(linkedEntry?.discoveredVia).toContain('linked from');
|
||||
});
|
||||
});
|
||||
});
|
||||
240
tests/distill-service.test.ts
Normal file
240
tests/distill-service.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DistillService } from '../src/services/distill-service';
|
||||
import { OpenAIService } from '../src/services/openai-service';
|
||||
import { DEFAULT_SETTINGS } from '../src/types/settings';
|
||||
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||
import * as path from 'path';
|
||||
|
||||
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||
|
||||
describe('DistillService', () => {
|
||||
let app: ReturnType<typeof createMockApp>;
|
||||
let openAIService: OpenAIService;
|
||||
let service: DistillService;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createMockApp(VAULT_DIR);
|
||||
openAIService = new OpenAIService('test-key', 'gpt-5');
|
||||
service = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
|
||||
});
|
||||
|
||||
describe('getLinkedNotes', () => {
|
||||
it('extracts forward links from a note', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
const linked = await service.getLinkedNotes(rootFile);
|
||||
|
||||
const basenames = linked.map(f => f.basename);
|
||||
expect(basenames).toContain('Linked Note A');
|
||||
expect(basenames).toContain('Linked Note B');
|
||||
});
|
||||
|
||||
it('does not include the root file itself', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
const linked = await service.getLinkedNotes(rootFile);
|
||||
|
||||
const paths = linked.map(f => f.path);
|
||||
expect(paths).not.toContain('Root Note.md');
|
||||
});
|
||||
|
||||
it('deduplicates linked files', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
const linked = await service.getLinkedNotes(rootFile);
|
||||
|
||||
const paths = linked.map(f => f.path);
|
||||
const uniquePaths = [...new Set(paths)];
|
||||
expect(paths.length).toBe(uniquePaths.length);
|
||||
});
|
||||
|
||||
it('extracts only checked items from collection notes', async () => {
|
||||
const collectionFile = createTestTFile(VAULT_DIR, 'Collection Note.md');
|
||||
const linked = await service.getLinkedNotes(collectionFile);
|
||||
|
||||
const basenames = linked.map(f => f.basename);
|
||||
// Only [x] items should be included
|
||||
expect(basenames).toContain('Linked Note A');
|
||||
expect(basenames).toContain('Journal Note');
|
||||
// Unchecked items should NOT be included
|
||||
expect(basenames).not.toContain('Linked Note B');
|
||||
expect(basenames).not.toContain('Backlink Source');
|
||||
});
|
||||
|
||||
it('handles notes with no links', async () => {
|
||||
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
|
||||
const linked = await service.getLinkedNotes(deepNote);
|
||||
expect(linked).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBacklinksForFile', () => {
|
||||
it('finds notes that link TO the target file', () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
const backlinks = service.getBacklinksForFile(rootFile);
|
||||
|
||||
const basenames = backlinks.map(f => f.basename);
|
||||
expect(basenames).toContain('Backlink Source');
|
||||
expect(basenames).toContain('Special Characters!');
|
||||
});
|
||||
|
||||
it('returns empty array for notes with no backlinks', () => {
|
||||
const excludedFile = createTestTFile(VAULT_DIR, 'Excluded Folder/Should Skip.md');
|
||||
const backlinks = service.getBacklinksForFile(excludedFile);
|
||||
expect(backlinks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBacklinkSnippets', () => {
|
||||
it('extracts context around backlink references', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
const backlinkSource = createTestTFile(VAULT_DIR, 'Backlink Source.md');
|
||||
|
||||
const snippets = await service.getBacklinkSnippets(rootFile, backlinkSource, 0);
|
||||
|
||||
expect(snippets.length).toBeGreaterThan(0);
|
||||
// Header section mode (0) should include the section header
|
||||
expect(snippets[0].snippet).toContain('Section About Root');
|
||||
expect(snippets[0].snippet).toContain('Root Note');
|
||||
});
|
||||
|
||||
it('returns empty for files with no links to target', async () => {
|
||||
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
|
||||
|
||||
const snippets = await service.getBacklinkSnippets(rootFile, deepNote, 0);
|
||||
expect(snippets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateContent', () => {
|
||||
it('combines content from multiple files', async () => {
|
||||
const files = [
|
||||
createTestTFile(VAULT_DIR, 'Linked Note A.md'),
|
||||
createTestTFile(VAULT_DIR, 'Linked Note B.md'),
|
||||
];
|
||||
|
||||
const result = await service.aggregateContent(files);
|
||||
|
||||
expect(result.content).toContain('# Note: Linked Note A');
|
||||
expect(result.content).toContain('# Note: Linked Note B');
|
||||
expect(result.content).toContain('atomic note-taking');
|
||||
expect(result.content).toContain('Zettelkasten');
|
||||
expect(result.sourceNotes).toContain('Linked Note A');
|
||||
expect(result.sourceNotes).toContain('Linked Note B');
|
||||
});
|
||||
|
||||
it('deduplicates files by path', async () => {
|
||||
const fileA = createTestTFile(VAULT_DIR, 'Linked Note A.md');
|
||||
const fileDuplicate = createTestTFile(VAULT_DIR, 'Linked Note A.md');
|
||||
|
||||
const result = await service.aggregateContent([fileA, fileDuplicate]);
|
||||
|
||||
// Should only appear once
|
||||
const occurrences = (result.content.match(/# Note: Linked Note A/g) || []).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it('strips dataview queries from output', async () => {
|
||||
const files = [createTestTFile(VAULT_DIR, 'Dataview Note.md')];
|
||||
const result = await service.aggregateContent(files);
|
||||
|
||||
expect(result.content).not.toContain('```dataview');
|
||||
expect(result.content).not.toContain('TABLE file.mtime');
|
||||
expect(result.content).toContain('Regular content after the dataview block');
|
||||
});
|
||||
});
|
||||
|
||||
describe('journal date filtering', () => {
|
||||
it('identifies journal-style notes with date headers', () => {
|
||||
// Access private method via any cast
|
||||
const content = '# Daily\n\n### 2026-02-25\nToday entry\n\n### 2026-02-20\nOlder entry';
|
||||
const result = (service as any).isJournalStyleNote(content);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-journal notes', () => {
|
||||
const content = '# Regular Note\n\nJust some content without date headers.';
|
||||
const result = (service as any).isJournalStyleNote(content);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts date sections correctly', () => {
|
||||
const content = '# Header\n\n### 2026-02-25\nRecent\n\n### 2026-01-15\nOlder';
|
||||
const sections = (service as any).getDateSections(content);
|
||||
|
||||
expect(sections.length).toBe(3); // header + 2 dated sections
|
||||
expect(sections[0].date).toBeNull(); // undated header
|
||||
expect(sections[1].date).toBeInstanceOf(Date);
|
||||
expect(sections[2].date).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('filters content by date range', () => {
|
||||
const content = '# Daily Journal\n\n### 2026-02-25\nRecent entry\n\n### 2025-01-01\nVery old entry';
|
||||
const filtered = (service as any).extractContentByDateRange(content, 30);
|
||||
|
||||
expect(filtered).toContain('Recent entry');
|
||||
expect(filtered).not.toContain('Very old entry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractDateFromFilename', () => {
|
||||
it('extracts date from YYYY-MM-DD prefix', () => {
|
||||
const date = (service as any).extractDateFromFilename('2026-02-25 My Note');
|
||||
expect(date).toBeInstanceOf(Date);
|
||||
expect(date.getFullYear()).toBe(2026);
|
||||
expect(date.getMonth()).toBe(1); // 0-indexed
|
||||
expect(date.getDate()).toBe(25);
|
||||
});
|
||||
|
||||
it('returns null for non-date filenames', () => {
|
||||
const date = (service as any).extractDateFromFilename('My Regular Note');
|
||||
expect(date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataview query handling', () => {
|
||||
it('detects dataview queries in content', () => {
|
||||
const content = 'Some text\n\n```dataview\nLIST FROM #tag\n```\n\nMore text';
|
||||
expect((service as any).containsDataviewQuery(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for content without dataview', () => {
|
||||
const content = 'Regular markdown content\n\n```javascript\nconsole.log("hi")\n```';
|
||||
expect((service as any).containsDataviewQuery(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts dataview query strings', () => {
|
||||
const content = 'Text\n\n```dataview\nLIST FROM #tag\n```\n\n```dataview\nTABLE file.name\n```';
|
||||
const queries = (service as any).extractDataviewQueries(content);
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[0]).toContain('LIST FROM #tag');
|
||||
expect(queries[1]).toContain('TABLE file.name');
|
||||
});
|
||||
|
||||
it('strips dataview queries from content', () => {
|
||||
const content = 'Before\n\n```dataview\nLIST\n```\nAfter';
|
||||
const stripped = (service as any).stripDataviewQueries(content);
|
||||
expect(stripped).not.toContain('```dataview');
|
||||
expect(stripped).toContain('Before');
|
||||
expect(stripped).toContain('After');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag stripping', () => {
|
||||
it('strips simple tags', () => {
|
||||
const result = (service as any).stripTags('Some text #tag more text');
|
||||
expect(result).not.toContain('#tag');
|
||||
expect(result).toContain('Some text');
|
||||
expect(result).toContain('more text');
|
||||
});
|
||||
|
||||
it('strips nested tags', () => {
|
||||
const result = (service as any).stripTags('Content #nested/tag here');
|
||||
expect(result).not.toContain('#nested/tag');
|
||||
});
|
||||
|
||||
it('preserves markdown headers', () => {
|
||||
const result = (service as any).stripTags('## Header\n\nContent #tag');
|
||||
expect(result).toContain('## Header');
|
||||
expect(result).not.toContain('#tag');
|
||||
});
|
||||
});
|
||||
});
|
||||
217
tests/file-service.test.ts
Normal file
217
tests/file-service.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { FileService } from '../src/services/file-service';
|
||||
import { MockApp } from './mocks/obsidian-mock';
|
||||
import { TranscriptResponse, DistillResponse } from '../src/types/transcript';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||
let testCounter = 0;
|
||||
|
||||
describe('FileService', () => {
|
||||
let app: MockApp;
|
||||
let service: FileService;
|
||||
let outputDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use unique subdirectory per test to avoid parallel cleanup races
|
||||
testCounter++;
|
||||
outputDir = path.join(BASE_OUTPUT_DIR, `run-${testCounter}-${Date.now()}`);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
app = new MockApp(outputDir);
|
||||
service = new FileService(
|
||||
app as any,
|
||||
'OpenAugi/Summaries',
|
||||
'OpenAugi/Notes',
|
||||
'OpenAugi/Published'
|
||||
);
|
||||
});
|
||||
|
||||
describe('ensureDirectoriesExist', () => {
|
||||
it('creates all output directories', async () => {
|
||||
await service.ensureDirectoriesExist();
|
||||
|
||||
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Notes'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Published'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent (safe to call multiple times)', async () => {
|
||||
await service.ensureDirectoriesExist();
|
||||
await service.ensureDirectoriesExist();
|
||||
|
||||
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeTranscriptFiles', () => {
|
||||
const mockTranscriptData: TranscriptResponse = {
|
||||
summary: 'This transcript discusses [[Atomic Notes]] and [[Knowledge Management]].',
|
||||
notes: [
|
||||
{ title: 'Atomic Notes', content: 'Each note should contain one idea. See [[Knowledge Management]].' },
|
||||
{ title: 'Knowledge Management', content: 'Systems for organizing knowledge. Related to [[Atomic Notes]].' },
|
||||
],
|
||||
tasks: [
|
||||
'- [ ] Review [[Atomic Notes]] methodology',
|
||||
'- [ ] Set up Zettelkasten system',
|
||||
],
|
||||
};
|
||||
|
||||
it('creates summary file with backlinks', async () => {
|
||||
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||
|
||||
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
|
||||
expect(fs.existsSync(summaryPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(summaryPath, 'utf-8');
|
||||
expect(content).toContain('Atomic Notes');
|
||||
expect(content).toContain('Knowledge Management');
|
||||
});
|
||||
|
||||
it('creates atomic note files in session folder', async () => {
|
||||
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||
|
||||
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||
const sessionFolders = fs.readdirSync(notesDir);
|
||||
expect(sessionFolders.length).toBe(1);
|
||||
expect(sessionFolders[0]).toMatch(/^Transcript \d{4}-\d{2}-\d{2}/);
|
||||
|
||||
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||
const noteFiles = fs.readdirSync(sessionPath);
|
||||
expect(noteFiles).toContain('Atomic Notes.md');
|
||||
expect(noteFiles).toContain('Knowledge Management.md');
|
||||
});
|
||||
|
||||
it('includes tasks section in summary', async () => {
|
||||
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||
|
||||
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
|
||||
const content = fs.readFileSync(summaryPath, 'utf-8');
|
||||
expect(content).toContain('## Tasks');
|
||||
expect(content).toContain('Review');
|
||||
});
|
||||
|
||||
it('processes backlinks in note content', async () => {
|
||||
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||
|
||||
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||
const sessionFolders = fs.readdirSync(notesDir);
|
||||
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||
|
||||
const atomicContent = fs.readFileSync(path.join(sessionPath, 'Atomic Notes.md'), 'utf-8');
|
||||
// Backlinks should reference sanitized filenames
|
||||
expect(atomicContent).toContain('[[Knowledge Management]]');
|
||||
});
|
||||
|
||||
it('sanitizes filenames with special characters', async () => {
|
||||
const dataWithSpecialChars: TranscriptResponse = {
|
||||
summary: 'Summary of [[Note: Special]]',
|
||||
notes: [
|
||||
{ title: 'Note: Special', content: 'Content with special title' },
|
||||
],
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
await service.writeTranscriptFiles('Test', dataWithSpecialChars);
|
||||
|
||||
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||
const sessionFolders = fs.readdirSync(notesDir);
|
||||
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||
const noteFiles = fs.readdirSync(sessionPath);
|
||||
|
||||
// Colon should be sanitized
|
||||
expect(noteFiles.some(f => f.includes(':'))).toBe(false);
|
||||
expect(noteFiles.some(f => f.includes('Note'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeDistilledFiles', () => {
|
||||
const mockDistillData: DistillResponse = {
|
||||
summary: 'Distilled insights about [[Topic A]] and [[Topic B]].',
|
||||
notes: [
|
||||
{ title: 'Topic A', content: 'Deep analysis of A' },
|
||||
{ title: 'Topic B', content: 'Overview of B, see also [[Topic A]]' },
|
||||
],
|
||||
tasks: ['- [ ] Follow up on Topic A'],
|
||||
sourceNotes: ['Root Note', 'Linked Note A'],
|
||||
};
|
||||
|
||||
it('creates distilled summary file', async () => {
|
||||
// Create a mock TFile for the root
|
||||
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||
// We need a file that exists in the output dir, so create it first
|
||||
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(outputDir, 'test/My Root.md'), 'root content');
|
||||
const rootFile = createTestTFile(outputDir, 'test/My Root.md');
|
||||
|
||||
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||
|
||||
expect(summaryPath).toContain('My Root - distilled');
|
||||
expect(fs.existsSync(path.join(outputDir, summaryPath))).toBe(true);
|
||||
});
|
||||
|
||||
it('creates atomic notes in session folder', async () => {
|
||||
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
|
||||
const rootFile = createTestTFile(outputDir, 'test/Root.md');
|
||||
|
||||
await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||
|
||||
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||
const sessionFolders = fs.readdirSync(notesDir);
|
||||
expect(sessionFolders.length).toBe(1);
|
||||
expect(sessionFolders[0]).toMatch(/^Distill Root/);
|
||||
|
||||
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||
const noteFiles = fs.readdirSync(sessionPath);
|
||||
expect(noteFiles).toContain('Topic A.md');
|
||||
expect(noteFiles).toContain('Topic B.md');
|
||||
});
|
||||
|
||||
it('includes source notes in summary', async () => {
|
||||
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
|
||||
const rootFile = createTestTFile(outputDir, 'test/Root.md');
|
||||
|
||||
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||
const content = fs.readFileSync(path.join(outputDir, summaryPath), 'utf-8');
|
||||
|
||||
expect(content).toContain('## Source Notes');
|
||||
expect(content).toContain('[[Root Note]]');
|
||||
expect(content).toContain('[[Linked Note A]]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePublishedPost', () => {
|
||||
it('creates published file with frontmatter', async () => {
|
||||
const content = '# My Great Post\n\nThis is the blog post content.';
|
||||
const filePath = await service.writePublishedPost(content, ['Source A', 'Source B'], 'default');
|
||||
|
||||
expect(filePath).toContain('Published');
|
||||
const fullContent = fs.readFileSync(path.join(outputDir, filePath), 'utf-8');
|
||||
|
||||
expect(fullContent).toContain('---');
|
||||
expect(fullContent).toContain('type: published-post');
|
||||
expect(fullContent).toContain('status: draft');
|
||||
expect(fullContent).toContain('[[Source A]]');
|
||||
expect(fullContent).toContain('# My Great Post');
|
||||
});
|
||||
|
||||
it('extracts title from first heading', async () => {
|
||||
const content = '# Test Title\n\nContent here.';
|
||||
const filePath = await service.writePublishedPost(content, ['Source'], 'default');
|
||||
|
||||
expect(filePath).toContain('Test Title');
|
||||
});
|
||||
|
||||
it('falls back to source note name when no heading', async () => {
|
||||
const content = 'No heading, just content.';
|
||||
const filePath = await service.writePublishedPost(content, ['My Source Note'], 'default');
|
||||
|
||||
expect(filePath).toContain('My Source Note');
|
||||
});
|
||||
});
|
||||
});
|
||||
125
tests/filename-utils.test.ts
Normal file
125
tests/filename-utils.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../src/utils/filename-utils';
|
||||
import { MockVault } from './mocks/obsidian-mock';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||
let collisionTestDir: string;
|
||||
let collisionTestCounter = 0;
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
it('passes through clean filenames unchanged', () => {
|
||||
expect(sanitizeFilename('My Note')).toBe('My Note');
|
||||
expect(sanitizeFilename('simple-name')).toBe('simple-name');
|
||||
});
|
||||
|
||||
it('replaces backslash', () => {
|
||||
expect(sanitizeFilename('path\\to\\file')).toBe('path - to - file');
|
||||
});
|
||||
|
||||
it('replaces forward slash', () => {
|
||||
expect(sanitizeFilename('path/to/file')).toBe('path - to - file');
|
||||
});
|
||||
|
||||
it('replaces colon', () => {
|
||||
expect(sanitizeFilename('Note: Important')).toBe('Note - Important');
|
||||
});
|
||||
|
||||
it('replaces asterisk', () => {
|
||||
expect(sanitizeFilename('Note*star')).toBe('Note - star');
|
||||
});
|
||||
|
||||
it('replaces question mark', () => {
|
||||
expect(sanitizeFilename('Is this a note?')).toBe('Is this a note - ');
|
||||
});
|
||||
|
||||
it('replaces double quotes', () => {
|
||||
expect(sanitizeFilename('He said "hello"')).toBe('He said - hello - ');
|
||||
});
|
||||
|
||||
it('replaces angle brackets', () => {
|
||||
expect(sanitizeFilename('<tag>')).toBe(' - tag - ');
|
||||
});
|
||||
|
||||
it('replaces pipe', () => {
|
||||
expect(sanitizeFilename('A | B')).toBe('A - B');
|
||||
});
|
||||
|
||||
it('handles multiple special characters', () => {
|
||||
const result = sanitizeFilename('Note: "Important" <test> | value');
|
||||
expect(result).not.toMatch(/[\\/:*?"<>|]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BacklinkMapper', () => {
|
||||
let mapper: BacklinkMapper;
|
||||
|
||||
beforeEach(() => {
|
||||
mapper = new BacklinkMapper();
|
||||
});
|
||||
|
||||
it('maps registered titles to sanitized filenames in backlinks', () => {
|
||||
mapper.registerTitle('My Important Note', 'My Important Note');
|
||||
mapper.registerTitle('Note: Special', 'Note - Special');
|
||||
|
||||
const result = mapper.processBacklinks('See [[Note: Special]] and [[My Important Note]]');
|
||||
expect(result).toBe('See [[Note - Special]] and [[My Important Note]]');
|
||||
});
|
||||
|
||||
it('falls back to sanitizeFilename for unregistered titles', () => {
|
||||
const result = mapper.processBacklinks('See [[Unregistered: Note]]');
|
||||
expect(result).toBe('See [[Unregistered - Note]]');
|
||||
});
|
||||
|
||||
it('handles content with no backlinks', () => {
|
||||
const result = mapper.processBacklinks('Plain text with no links');
|
||||
expect(result).toBe('Plain text with no links');
|
||||
});
|
||||
|
||||
it('handles multiple backlinks in same line', () => {
|
||||
mapper.registerTitle('A', 'a-sanitized');
|
||||
mapper.registerTitle('B', 'b-sanitized');
|
||||
|
||||
const result = mapper.processBacklinks('Links: [[A]] and [[B]]');
|
||||
expect(result).toBe('Links: [[a-sanitized]] and [[b-sanitized]]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFileWithCollisionHandling', () => {
|
||||
beforeEach(() => {
|
||||
collisionTestCounter++;
|
||||
collisionTestDir = path.join(BASE_OUTPUT_DIR, `collision-${collisionTestCounter}-${Date.now()}`);
|
||||
fs.mkdirSync(collisionTestDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('creates file at original path when no collision', async () => {
|
||||
const vault = new MockVault(collisionTestDir);
|
||||
const result = await createFileWithCollisionHandling(vault as any, 'test-note.md', 'Hello');
|
||||
expect(result).toBe('test-note.md');
|
||||
|
||||
const content = fs.readFileSync(path.join(collisionTestDir, 'test-note.md'), 'utf-8');
|
||||
expect(content).toBe('Hello');
|
||||
});
|
||||
|
||||
it('appends -1 when file already exists', async () => {
|
||||
const vault = new MockVault(collisionTestDir);
|
||||
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'existing');
|
||||
|
||||
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'New content');
|
||||
expect(result).toBe('note-1.md');
|
||||
|
||||
const content = fs.readFileSync(path.join(collisionTestDir, 'note-1.md'), 'utf-8');
|
||||
expect(content).toBe('New content');
|
||||
});
|
||||
|
||||
it('increments counter for multiple collisions', async () => {
|
||||
const vault = new MockVault(collisionTestDir);
|
||||
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'v1');
|
||||
fs.writeFileSync(path.join(collisionTestDir, 'note-1.md'), 'v2');
|
||||
fs.writeFileSync(path.join(collisionTestDir, 'note-2.md'), 'v3');
|
||||
|
||||
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'v4');
|
||||
expect(result).toBe('note-3.md');
|
||||
});
|
||||
});
|
||||
287
tests/mocks/obsidian-mock.ts
Normal file
287
tests/mocks/obsidian-mock.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* Filesystem-backed mock of the Obsidian API.
|
||||
* Reads/writes real markdown files from a test vault directory so we can
|
||||
* test DistillService, FileService, ContextGatheringService, etc. without
|
||||
* running Obsidian.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TFile } from './obsidian-module';
|
||||
|
||||
// ─── Link Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedLink {
|
||||
link: string; // The raw link text (e.g., "Note A" or "Note A|alias")
|
||||
original: string; // The full match including brackets
|
||||
position: { start: { line: number; col: number }; end: { line: number; col: number } };
|
||||
}
|
||||
|
||||
/** Parse [[wikilinks]] from markdown content */
|
||||
function parseWikilinks(content: string): ParsedLink[] {
|
||||
const links: ParsedLink[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||
const line = lines[lineNum];
|
||||
const regex = /\[\[(.*?)\]\]/g;
|
||||
let match;
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
const rawLink = match[1];
|
||||
// Strip alias: [[path|alias]] → path
|
||||
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
|
||||
links.push({
|
||||
link: linkPath,
|
||||
original: match[0],
|
||||
position: {
|
||||
start: { line: lineNum, col: match.index },
|
||||
end: { line: lineNum, col: match.index + match[0].length },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
// ─── MockTFile helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a TFile from a real filesystem path relative to vault root */
|
||||
function createMockTFile(vaultRoot: string, relativePath: string): TFile {
|
||||
const absPath = path.join(vaultRoot, relativePath);
|
||||
let mtime = Date.now();
|
||||
try {
|
||||
const stat = fs.statSync(absPath);
|
||||
mtime = stat.mtimeMs;
|
||||
} catch { /* file may not exist yet for output tests */ }
|
||||
const file = new TFile(relativePath, mtime);
|
||||
file.stat.size = (() => {
|
||||
try { return fs.statSync(absPath).size; } catch { return 0; }
|
||||
})();
|
||||
return file;
|
||||
}
|
||||
|
||||
// ─── MockVaultAdapter ────────────────────────────────────────────────────────
|
||||
|
||||
class MockVaultAdapter {
|
||||
constructor(private vaultRoot: string) {}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
return fs.existsSync(path.join(this.vaultRoot, filePath));
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<{ mtime: number; ctime: number; size: number } | null> {
|
||||
const absPath = path.join(this.vaultRoot, filePath);
|
||||
try {
|
||||
const stat = fs.statSync(absPath);
|
||||
return { mtime: stat.mtimeMs, ctime: stat.ctimeMs, size: stat.size };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MockVault ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class MockVault {
|
||||
adapter: MockVaultAdapter;
|
||||
private vaultRoot: string;
|
||||
|
||||
constructor(vaultRoot: string) {
|
||||
this.vaultRoot = vaultRoot;
|
||||
this.adapter = new MockVaultAdapter(vaultRoot);
|
||||
}
|
||||
|
||||
/** Read a file's content */
|
||||
async read(file: TFile): Promise<string> {
|
||||
const absPath = path.join(this.vaultRoot, file.path);
|
||||
return fs.readFileSync(absPath, 'utf-8');
|
||||
}
|
||||
|
||||
/** Create a file */
|
||||
async create(filePath: string, content: string): Promise<TFile> {
|
||||
const absPath = path.join(this.vaultRoot, filePath);
|
||||
const dir = path.dirname(absPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
return createMockTFile(this.vaultRoot, filePath);
|
||||
}
|
||||
|
||||
/** Create a folder */
|
||||
async createFolder(folderPath: string): Promise<void> {
|
||||
const absPath = path.join(this.vaultRoot, folderPath);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all markdown files in the vault */
|
||||
getMarkdownFiles(): TFile[] {
|
||||
const files: TFile[] = [];
|
||||
const walk = (dir: string, relative: string) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const relPath = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
walk(path.join(dir, entry.name), relPath);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
files.push(createMockTFile(this.vaultRoot, relPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(this.vaultRoot, '');
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Get a file by its exact path */
|
||||
getAbstractFileByPath(filePath: string): TFile | null {
|
||||
const absPath = path.join(this.vaultRoot, filePath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
return createMockTFile(this.vaultRoot, filePath);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MockMetadataCache ───────────────────────────────────────────────────────
|
||||
|
||||
export class MockMetadataCache {
|
||||
/** resolvedLinks[sourcePath][targetPath] = linkCount */
|
||||
resolvedLinks: Record<string, Record<string, number>> = {};
|
||||
private vaultRoot: string;
|
||||
private vault: MockVault;
|
||||
|
||||
constructor(vaultRoot: string, vault: MockVault) {
|
||||
this.vaultRoot = vaultRoot;
|
||||
this.vault = vault;
|
||||
this.buildLinkGraph();
|
||||
}
|
||||
|
||||
/** Build the resolved links graph by scanning all markdown files */
|
||||
private buildLinkGraph(): void {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
for (const file of files) {
|
||||
const absPath = path.join(this.vaultRoot, file.path);
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const links = parseWikilinks(content);
|
||||
|
||||
if (!this.resolvedLinks[file.path]) {
|
||||
this.resolvedLinks[file.path] = {};
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
const resolved = this.resolveLink(link.link, file.path);
|
||||
if (resolved) {
|
||||
this.resolvedLinks[file.path][resolved.path] =
|
||||
(this.resolvedLinks[file.path][resolved.path] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a link path to a TFile (mimics Obsidian's shortest-path resolution) */
|
||||
private resolveLink(linkPath: string, sourcePath: string): TFile | null {
|
||||
// Add .md extension if not present
|
||||
let searchPath = linkPath;
|
||||
if (!searchPath.endsWith('.md')) {
|
||||
searchPath += '.md';
|
||||
}
|
||||
|
||||
// Try exact path first
|
||||
const exactFile = this.vault.getAbstractFileByPath(searchPath);
|
||||
if (exactFile) return exactFile;
|
||||
|
||||
// Try relative to source file's directory
|
||||
const sourceDir = path.dirname(sourcePath);
|
||||
const relativePath = sourceDir === '.' ? searchPath : `${sourceDir}/${searchPath}`;
|
||||
const relativeFile = this.vault.getAbstractFileByPath(relativePath);
|
||||
if (relativeFile) return relativeFile;
|
||||
|
||||
// Obsidian-style: search all files for basename match
|
||||
const baseName = searchPath.split('/').pop();
|
||||
if (baseName) {
|
||||
const allFiles = this.vault.getMarkdownFiles();
|
||||
const match = allFiles.find(f => f.path.endsWith(baseName) || f.path.endsWith(`/${baseName}`));
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get cached metadata for a file (links extracted from content) */
|
||||
getFileCache(file: TFile): { links?: ParsedLink[]; embeds?: ParsedLink[] } | null {
|
||||
const absPath = path.join(this.vaultRoot, file.path);
|
||||
try {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const links = parseWikilinks(content);
|
||||
// For simplicity, embeds use the same format as links (Obsidian uses ![[embed]])
|
||||
const embedRegex = /!\[\[(.*?)\]\]/g;
|
||||
const embeds: ParsedLink[] = [];
|
||||
const lines = content.split('\n');
|
||||
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||
let match;
|
||||
while ((match = embedRegex.exec(lines[lineNum])) !== null) {
|
||||
const rawLink = match[1];
|
||||
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
|
||||
embeds.push({
|
||||
link: linkPath,
|
||||
original: match[0],
|
||||
position: {
|
||||
start: { line: lineNum, col: match.index },
|
||||
end: { line: lineNum, col: match.index + match[0].length },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { links, embeds: embeds.length > 0 ? embeds : undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a link path to a TFile (public API matching Obsidian's) */
|
||||
getFirstLinkpathDest(linkPath: string, sourcePath: string): TFile | null {
|
||||
return this.resolveLink(linkPath, sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MockApp ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class MockApp {
|
||||
vault: MockVault;
|
||||
metadataCache: MockMetadataCache;
|
||||
workspace: { onLayoutReady: (cb: () => void) => void; getActiveFile: () => null; getLeaf: () => any };
|
||||
plugins: { plugins: Record<string, any> };
|
||||
fileManager: { processFrontMatter: () => Promise<void> };
|
||||
|
||||
constructor(vaultRoot: string) {
|
||||
this.vault = new MockVault(vaultRoot);
|
||||
this.metadataCache = new MockMetadataCache(vaultRoot, this.vault);
|
||||
this.workspace = {
|
||||
onLayoutReady: (cb) => cb(),
|
||||
getActiveFile: () => null,
|
||||
getLeaf: () => ({ openFile: async () => {} }),
|
||||
};
|
||||
this.plugins = { plugins: {} };
|
||||
this.fileManager = { processFrontMatter: async () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helper to create a fresh test vault ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a MockApp pointed at a vault directory.
|
||||
* Use for tests that need the full Obsidian API mock.
|
||||
*/
|
||||
export function createMockApp(vaultRoot: string): MockApp {
|
||||
return new MockApp(vaultRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TFile from a vault-relative path (for use in test assertions).
|
||||
*/
|
||||
export function createTestTFile(vaultRoot: string, relativePath: string): TFile {
|
||||
return createMockTFile(vaultRoot, relativePath);
|
||||
}
|
||||
70
tests/mocks/obsidian-module.ts
Normal file
70
tests/mocks/obsidian-module.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Stub module that replaces `import { ... } from 'obsidian'` in tests.
|
||||
* Provides minimal class/type stubs so service code can import without error.
|
||||
* The real mock implementations (MockVault, MockApp, etc.) are in obsidian-mock.ts.
|
||||
*/
|
||||
|
||||
export class TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
stat: { mtime: number; ctime: number; size: number };
|
||||
|
||||
constructor(path: string, mtime?: number) {
|
||||
this.path = path;
|
||||
this.extension = path.split('.').pop() || '';
|
||||
this.basename = path.split('/').pop()?.replace(`.${this.extension}`, '') || '';
|
||||
this.stat = { mtime: mtime || Date.now(), ctime: mtime || Date.now(), size: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export class TAbstractFile {
|
||||
path: string = '';
|
||||
}
|
||||
|
||||
export class Vault {}
|
||||
|
||||
export class MetadataCache {}
|
||||
|
||||
export class App {}
|
||||
|
||||
export class Plugin {}
|
||||
|
||||
export class Component {}
|
||||
|
||||
export class Modal {
|
||||
app: any;
|
||||
constructor(app: any) { this.app = app; }
|
||||
open() {}
|
||||
close() {}
|
||||
onOpen() {}
|
||||
onClose() {}
|
||||
}
|
||||
|
||||
export class PluginSettingTab {
|
||||
app: any;
|
||||
plugin: any;
|
||||
constructor(app: any, plugin: any) { this.app = app; this.plugin = plugin; }
|
||||
display() {}
|
||||
hide() {}
|
||||
}
|
||||
|
||||
export class Setting {
|
||||
constructor(_el: any) {}
|
||||
setName(_n: string) { return this; }
|
||||
setDesc(_d: string) { return this; }
|
||||
addText(_cb: any) { return this; }
|
||||
addToggle(_cb: any) { return this; }
|
||||
addDropdown(_cb: any) { return this; }
|
||||
addButton(_cb: any) { return this; }
|
||||
}
|
||||
|
||||
export class Notice {
|
||||
constructor(message: string, _timeout?: number) {
|
||||
// Silent in tests — uncomment for debugging:
|
||||
// console.log('[Notice]', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkdownView {}
|
||||
export class WorkspaceLeaf {}
|
||||
235
tests/openai-service.test.ts
Normal file
235
tests/openai-service.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { OpenAIService } from '../src/services/openai-service';
|
||||
|
||||
describe('OpenAIService', () => {
|
||||
let service: OpenAIService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OpenAIService('test-api-key', 'gpt-5');
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('stores API key and model', () => {
|
||||
// Access private fields via any cast (testing internals)
|
||||
expect((service as any).apiKey).toBe('test-api-key');
|
||||
expect((service as any).model).toBe('gpt-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCustomContext (via prompt building)', () => {
|
||||
// We test this through the prompt generation methods since extractCustomContext is private
|
||||
|
||||
it('includes context: section in transcript prompt', () => {
|
||||
const content = 'Some transcript\n\ncontext:\nFocus on tech topics\n\nMore content';
|
||||
const prompt = (service as any).getPrompt(content);
|
||||
expect(prompt).toContain('USER CONTEXT');
|
||||
expect(prompt).toContain('Focus on tech topics');
|
||||
});
|
||||
|
||||
it('includes fenced context: section in transcript prompt', () => {
|
||||
const content = 'Some content\n\n```context:\nOnly extract action items\n```\n\nMore stuff';
|
||||
const prompt = (service as any).getPrompt(content);
|
||||
expect(prompt).toContain('USER CONTEXT');
|
||||
expect(prompt).toContain('Only extract action items');
|
||||
});
|
||||
|
||||
it('handles content without context section', () => {
|
||||
const content = 'Just a regular transcript with no special context';
|
||||
const prompt = (service as any).getPrompt(content);
|
||||
expect(prompt).not.toContain('USER CONTEXT');
|
||||
expect(prompt).toContain('Just a regular transcript');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrompt', () => {
|
||||
it('includes the transcript content', () => {
|
||||
const prompt = (service as any).getPrompt('My voice note about productivity');
|
||||
expect(prompt).toContain('Transcript:\nMy voice note about productivity');
|
||||
});
|
||||
|
||||
it('includes atomic notes instructions', () => {
|
||||
const prompt = (service as any).getPrompt('test');
|
||||
expect(prompt).toContain('Atomic Notes');
|
||||
expect(prompt).toContain('self-contained');
|
||||
});
|
||||
|
||||
it('includes task extraction instructions', () => {
|
||||
const prompt = (service as any).getPrompt('test');
|
||||
expect(prompt).toContain('Tasks');
|
||||
expect(prompt).toContain('actionable');
|
||||
});
|
||||
|
||||
it('includes AUGI command handling', () => {
|
||||
const prompt = (service as any).getPrompt('test');
|
||||
expect(prompt).toContain('AUGI');
|
||||
expect(prompt).toContain('auggie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDistillPrompt', () => {
|
||||
it('includes content to distill', () => {
|
||||
const prompt = (service as any).getDistillPrompt('Note content here');
|
||||
expect(prompt).toContain('Content to Distill:\nNote content here');
|
||||
});
|
||||
|
||||
it('uses custom prompt when provided', () => {
|
||||
const custom = 'You are a custom processor. Do something special.';
|
||||
const prompt = (service as any).getDistillPrompt('content', custom);
|
||||
expect(prompt).toContain(custom);
|
||||
// Should NOT contain the default distill instructions
|
||||
expect(prompt).not.toContain('expert knowledge curator');
|
||||
});
|
||||
|
||||
it('uses default prompt when no custom prompt', () => {
|
||||
const prompt = (service as any).getDistillPrompt('content');
|
||||
expect(prompt).toContain('expert knowledge curator');
|
||||
});
|
||||
|
||||
it('appends custom context from content', () => {
|
||||
const content = 'Notes here\n\ncontext:\nFocus on architecture\n\nMore notes';
|
||||
const prompt = (service as any).getDistillPrompt(content);
|
||||
expect(prompt).toContain('USER CONTEXT');
|
||||
expect(prompt).toContain('Focus on architecture');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublishPrompt', () => {
|
||||
it('includes content to transform', () => {
|
||||
const prompt = (service as any).getPublishPrompt('Blog content');
|
||||
expect(prompt).toContain('Content to Transform:\nBlog content');
|
||||
});
|
||||
|
||||
it('uses default publish prompt', () => {
|
||||
const prompt = (service as any).getPublishPrompt('content');
|
||||
expect(prompt).toContain('publishable blog post');
|
||||
expect(prompt).toContain('PRESERVE');
|
||||
});
|
||||
|
||||
it('uses custom prompt when provided', () => {
|
||||
const custom = 'Write a newsletter instead.';
|
||||
const prompt = (service as any).getPublishPrompt('content', custom);
|
||||
expect(prompt).toContain('Write a newsletter instead.');
|
||||
expect(prompt).not.toContain('publishable blog post');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscript', () => {
|
||||
it('throws when API key is not set', async () => {
|
||||
const noKeyService = new OpenAIService('', 'gpt-5');
|
||||
await expect(noKeyService.parseTranscript('content')).rejects.toThrow('OpenAI API key not set');
|
||||
});
|
||||
|
||||
it('calls OpenAI API with correct parameters', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
summary: 'Test summary',
|
||||
notes: [{ title: 'Note 1', content: 'Content 1' }],
|
||||
tasks: ['- [ ] Task 1']
|
||||
}),
|
||||
refusal: null
|
||||
}
|
||||
}]
|
||||
})
|
||||
};
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await service.parseTranscript('Test transcript');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, options] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||
expect((options as any).method).toBe('POST');
|
||||
expect(JSON.parse((options as any).body).model).toBe('gpt-5');
|
||||
|
||||
expect(result.summary).toBe('Test summary');
|
||||
expect(result.notes).toHaveLength(1);
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws on API error response', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: async () => ({ error: { message: 'Invalid API key' } })
|
||||
};
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||
|
||||
await expect(service.parseTranscript('content')).rejects.toThrow('OpenAI API error');
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('distillContent', () => {
|
||||
it('throws when API key is not set', async () => {
|
||||
const noKeyService = new OpenAIService('', 'gpt-5');
|
||||
await expect(noKeyService.distillContent('content')).rejects.toThrow('OpenAI API key not set');
|
||||
});
|
||||
|
||||
it('parses structured response correctly', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
summary: 'Distilled summary about [[Topic A]]',
|
||||
notes: [
|
||||
{ title: 'Topic A', content: 'Deep dive into A' },
|
||||
{ title: 'Topic B', content: 'Overview of B' },
|
||||
],
|
||||
tasks: []
|
||||
}),
|
||||
refusal: null
|
||||
}
|
||||
}]
|
||||
})
|
||||
};
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await service.distillContent('aggregated content');
|
||||
|
||||
expect(result.summary).toContain('Topic A');
|
||||
expect(result.notes).toHaveLength(2);
|
||||
expect(result.sourceNotes).toEqual([]); // Initialized as empty
|
||||
expect(result.tasks).toEqual([]);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishContent', () => {
|
||||
it('returns plain text content', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: '# My Blog Post\n\nThis is the published content.',
|
||||
refusal: null
|
||||
}
|
||||
}]
|
||||
})
|
||||
};
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await service.publishContent('raw notes');
|
||||
|
||||
expect(result).toContain('# My Blog Post');
|
||||
expect(result).toContain('published content');
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
10
tests/vault/Backlink Source.md
Normal file
10
tests/vault/Backlink Source.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Backlink Source
|
||||
|
||||
## Section About Root
|
||||
|
||||
This section references the [[Root Note]] because it is important for testing backlink discovery.
|
||||
|
||||
## Unrelated Section
|
||||
|
||||
This section has nothing to do with the root note.
|
||||
It discusses other topics entirely.
|
||||
8
tests/vault/Collection Note.md
Normal file
8
tests/vault/Collection Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Collection of Notes
|
||||
|
||||
This is a collection note with checkboxes.
|
||||
|
||||
- [x] [[Linked Note A]]
|
||||
- [ ] [[Linked Note B]]
|
||||
- [x] [[Journal Note]]
|
||||
- [ ] [[Backlink Source]]
|
||||
11
tests/vault/Context Note.md
Normal file
11
tests/vault/Context Note.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Note with Custom Context
|
||||
|
||||
This note has custom context instructions for AI processing.
|
||||
|
||||
Some content about software architecture patterns.
|
||||
|
||||
```context:
|
||||
Only extract notes about design patterns. Ignore implementation details.
|
||||
```
|
||||
|
||||
More content about specific patterns like Observer, Strategy, and Factory.
|
||||
14
tests/vault/Dataview Note.md
Normal file
14
tests/vault/Dataview Note.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Notes with Dataview
|
||||
|
||||
This note contains a dataview query that should be stripped from output.
|
||||
|
||||
```dataview
|
||||
TABLE file.mtime as "Modified"
|
||||
FROM "/"
|
||||
SORT file.mtime DESC
|
||||
LIMIT 10
|
||||
```
|
||||
|
||||
Regular content after the dataview block.
|
||||
|
||||
Also links to [[Linked Note A]].
|
||||
8
tests/vault/Deeply Linked/Deep Note.md
Normal file
8
tests/vault/Deeply Linked/Deep Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Deep Note
|
||||
|
||||
This note is at depth 2 from the root:
|
||||
Root Note → Linked Note A → Deep Note
|
||||
|
||||
It contains insights about deep work and focused concentration.
|
||||
|
||||
No further links from here (leaf node).
|
||||
4
tests/vault/Excluded Folder/Should Skip.md
Normal file
4
tests/vault/Excluded Folder/Should Skip.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Should Be Excluded
|
||||
|
||||
This note lives in a folder that should be excluded from discovery.
|
||||
It should never appear in gathered context.
|
||||
17
tests/vault/Journal Note.md
Normal file
17
tests/vault/Journal Note.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Daily Journal
|
||||
|
||||
### 2026-02-25
|
||||
Worked on setting up automated tests for the Obsidian plugin.
|
||||
Made good progress on the mock layer.
|
||||
|
||||
### 2026-02-20
|
||||
Started thinking about test architecture.
|
||||
Reviewed several approaches for testing Electron apps.
|
||||
|
||||
### 2026-01-15
|
||||
This is an old entry that should be filtered out by time window.
|
||||
Did some unrelated work on another project.
|
||||
|
||||
### 2025-12-01
|
||||
Very old entry. Should definitely be filtered out.
|
||||
Working on something completely different.
|
||||
7
tests/vault/Linked Note A.md
Normal file
7
tests/vault/Linked Note A.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Linked Note A
|
||||
|
||||
This note contains ideas about atomic note-taking.
|
||||
|
||||
Key insight: each note should contain exactly one idea.
|
||||
|
||||
See also [[Deeply Linked/Deep Note]] for more depth.
|
||||
10
tests/vault/Linked Note B.md
Normal file
10
tests/vault/Linked Note B.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Linked Note B
|
||||
|
||||
This note discusses knowledge management systems.
|
||||
|
||||
The Zettelkasten method emphasizes:
|
||||
- Atomic notes
|
||||
- Linking between ideas
|
||||
- Building a network of knowledge
|
||||
|
||||
Related: [[Linked Note A]]
|
||||
8
tests/vault/Root Note.md
Normal file
8
tests/vault/Root Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
This is the root note for testing linked note discovery.
|
||||
|
||||
It links to [[Linked Note A]] and [[Linked Note B]].
|
||||
|
||||
It also has an embed: ![[Linked Note A]]
|
||||
|
||||
context:
|
||||
Focus on extracting key concepts about testing
|
||||
6
tests/vault/Special Characters!.md
Normal file
6
tests/vault/Special Characters!.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Note with Special: Characters* in "Title"
|
||||
|
||||
This note tests filename sanitization.
|
||||
It contains characters that are invalid in filenames: \ / : * ? " < > |
|
||||
|
||||
Links to [[Root Note]].
|
||||
|
|
@ -21,5 +21,9 @@
|
|||
"include": [
|
||||
"**/*.ts",
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"tests/**/*.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// Mock the obsidian module globally so imports don't fail
|
||||
'obsidian': path.resolve(__dirname, 'tests/mocks/obsidian-module.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue