Feature/semantic similarity (#36)
* fix: mixed up migrations * feat: semantic similarity first take * feat: semantic similarity first round of fixes * feat: more work on making semantic similarity work properly * feat: getPostBySlug for the AI * feat: show similarity in post-link-insert-modal * chore: remove done doc --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
404
tests/engine/EmbeddingEngine.test.ts
Normal file
404
tests/engine/EmbeddingEngine.test.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs/promises';
|
||||
import { EmbeddingEngine, type EmbeddingPipeline } from '../../src/main/engine/EmbeddingEngine';
|
||||
|
||||
// ── In-memory DB store ─────────────────────────────────────────────────────
|
||||
|
||||
interface KeyRow {
|
||||
label: bigint;
|
||||
postId: string;
|
||||
projectId: string;
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
interface DismissedRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
postIdA: string;
|
||||
postIdB: string;
|
||||
dismissedAt: Date;
|
||||
}
|
||||
|
||||
interface PostRow {
|
||||
id: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
content: string | null;
|
||||
tags?: string;
|
||||
publishedAt?: Date | null;
|
||||
}
|
||||
|
||||
let keyRowsStore: KeyRow[] = [];
|
||||
let dismissedRowsStore: DismissedRow[] = [];
|
||||
let postRowsStore: PostRow[] = [];
|
||||
|
||||
// Drizzle stores the SQL table name at this symbol
|
||||
const DRIZZLE_NAME = Symbol.for('drizzle:Name');
|
||||
const DRIZZLE_BASE_NAME = Symbol.for('drizzle:BaseName');
|
||||
|
||||
function getTableName(table: unknown): string {
|
||||
if (table && typeof table === 'object') {
|
||||
const t = table as Record<symbol, unknown>;
|
||||
return (t[DRIZZLE_NAME] as string) || (t[DRIZZLE_BASE_NAME] as string) || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const mockDb = {
|
||||
selectFn: vi.fn(),
|
||||
insertFn: vi.fn(),
|
||||
deleteFn: vi.fn(),
|
||||
|
||||
select() {
|
||||
// Returns a drizzle-like query chain
|
||||
let tableName = '';
|
||||
|
||||
const chain: Record<string, unknown> = {
|
||||
from: vi.fn((table: unknown) => {
|
||||
tableName = getTableName(table);
|
||||
return chain;
|
||||
}),
|
||||
where: vi.fn((_cond: unknown) => {
|
||||
// Return the appropriate store based on table
|
||||
let rows: unknown[] = [];
|
||||
if (tableName === 'embedding_keys') {
|
||||
rows = keyRowsStore;
|
||||
} else if (tableName === 'dismissed_duplicate_pairs') {
|
||||
rows = dismissedRowsStore;
|
||||
} else if (tableName === 'posts') {
|
||||
rows = postRowsStore;
|
||||
}
|
||||
return Promise.resolve(rows);
|
||||
}),
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
|
||||
insert(_table: unknown) {
|
||||
const tableName = getTableName(_table);
|
||||
return {
|
||||
values: vi.fn((row: unknown) => {
|
||||
if (tableName === 'embedding_keys') {
|
||||
keyRowsStore.push(row as KeyRow);
|
||||
} else if (tableName === 'dismissed_duplicate_pairs') {
|
||||
dismissedRowsStore.push(row as DismissedRow);
|
||||
}
|
||||
return { onConflictDoNothing: vi.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
delete(_table: unknown) {
|
||||
return {
|
||||
where: vi.fn((_cond: unknown) => {
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: () => ({
|
||||
getLocal: () => mockDb,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Deterministic mock pipeline ────────────────────────────────────────────
|
||||
|
||||
let embedCallCount = 0;
|
||||
|
||||
function makeEmbedFn() {
|
||||
return vi.fn().mockImplementation(async (text: string): Promise<Float32Array> => {
|
||||
embedCallCount++;
|
||||
const arr = new Float32Array(384).fill(0);
|
||||
// Produce unique vector per text
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||
}
|
||||
arr[Math.abs(hash) % 384] = 1;
|
||||
arr[(Math.abs(hash * 31) % 383 + 1) % 384] = 0.7;
|
||||
// Normalize
|
||||
const norm = Math.sqrt(arr.reduce((s, v) => s + v * v, 0));
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i] = arr[i]! / norm;
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
}
|
||||
|
||||
function createMockPipeline(): EmbeddingPipeline {
|
||||
return { embed: makeEmbedFn() };
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeEngine(tmpDir: string): EmbeddingEngine {
|
||||
return new EmbeddingEngine({
|
||||
getIndexPath: (projectId: string) => path.join(tmpDir, `${projectId}.usearch`),
|
||||
createPipeline: async () => createMockPipeline(),
|
||||
});
|
||||
}
|
||||
|
||||
// Manually replicate embedPost logic in tests (insert key row, update in-memory state)
|
||||
// so we can set up test scenarios without relying on DB mock filtering
|
||||
async function addKeyRow(row: KeyRow): Promise<void> {
|
||||
keyRowsStore.push(row);
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('EmbeddingEngine', () => {
|
||||
let tmpDir: string;
|
||||
let engine: EmbeddingEngine;
|
||||
|
||||
beforeEach(async () => {
|
||||
keyRowsStore = [];
|
||||
dismissedRowsStore = [];
|
||||
postRowsStore = [];
|
||||
embedCallCount = 0;
|
||||
vi.clearAllMocks();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'embedding-test-'));
|
||||
engine = makeEngine(tmpDir);
|
||||
await engine.setProjectContext('proj1');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.shutdown();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('embedPost', () => {
|
||||
it('adds vector to index and persists key row', async () => {
|
||||
await engine.embedPost('post-1', 'Hello World', 'This is my first post');
|
||||
|
||||
expect(keyRowsStore.length).toBe(1);
|
||||
expect(keyRowsStore[0]!.postId).toBe('post-1');
|
||||
expect(keyRowsStore[0]!.projectId).toBe('proj1');
|
||||
expect(keyRowsStore[0]!.contentHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('skips re-embedding when content hash unchanged', async () => {
|
||||
await engine.embedPost('post-1', 'Hello', 'Content');
|
||||
const countBefore = embedCallCount;
|
||||
|
||||
// Embed same content again — engine uses in-memory hash check after first embed
|
||||
await engine.embedPost('post-1', 'Hello', 'Content');
|
||||
|
||||
// Should not have called embed again (no re-embed on unchanged content)
|
||||
expect(embedCallCount).toBe(countBefore);
|
||||
});
|
||||
|
||||
it('does not skip re-embedding when content changes', async () => {
|
||||
await engine.embedPost('post-1', 'Hello', 'Original');
|
||||
const countAfterFirst = embedCallCount;
|
||||
|
||||
// Update content (simulating second call with different content; engine detects hash change)
|
||||
// We need to trick the engine by clearing the internal keyRowsStore entry so the
|
||||
// DB mock returns empty for the second lookup
|
||||
keyRowsStore = [];
|
||||
|
||||
await engine.embedPost('post-1', 'Hello', 'Updated content');
|
||||
|
||||
expect(embedCallCount).toBeGreaterThan(countAfterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePost', () => {
|
||||
it('removes post from index and key map', async () => {
|
||||
await engine.embedPost('post-1', 'Hello', 'Content');
|
||||
expect(keyRowsStore.length).toBe(1);
|
||||
|
||||
await engine.removePost('post-1');
|
||||
|
||||
// Key map should not have post-1 anymore
|
||||
// (The delete mock doesn't clear keyRowsStore, but the in-memory map should be cleared)
|
||||
const results = await engine.findSimilar('post-1');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a no-op for non-existent post', async () => {
|
||||
await engine.removePost('non-existent'); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSimilar', () => {
|
||||
it('returns empty array for non-indexed post', async () => {
|
||||
const results = await engine.findSimilar('not-indexed');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty when only one post indexed', async () => {
|
||||
await engine.embedPost('post-1', 'Only post', 'Content');
|
||||
const results = await engine.findSimilar('post-1');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns similar posts ranked by similarity', async () => {
|
||||
await engine.embedPost('post-1', 'Machine learning basics', 'Intro to ML and neural nets');
|
||||
await engine.embedPost('post-2', 'Deep learning tutorial', 'Advanced ML techniques');
|
||||
await engine.embedPost('post-3', 'Cooking recipes', 'How to make pasta');
|
||||
|
||||
const results = await engine.findSimilar('post-1', 5);
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.every((r) => r.postId !== 'post-1')).toBe(true);
|
||||
expect(results.every((r) => r.similarity >= 0 && r.similarity <= 1)).toBe(true);
|
||||
// Results should be sorted by similarity descending
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i]!.similarity).toBeLessThanOrEqual(results[i - 1]!.similarity);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSimilarities', () => {
|
||||
it('returns empty object for non-indexed source post', async () => {
|
||||
const result = await engine.computeSimilarities('not-indexed', ['post-1']);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object for empty target list', async () => {
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
const result = await engine.computeSimilarities('post-1', []);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns similarity scores for indexed target posts', async () => {
|
||||
await engine.embedPost('post-1', 'Machine learning basics', 'Intro to ML');
|
||||
await engine.embedPost('post-2', 'Deep learning tutorial', 'Advanced ML');
|
||||
await engine.embedPost('post-3', 'Cooking recipes', 'How to make pasta');
|
||||
|
||||
const result = await engine.computeSimilarities('post-1', ['post-2', 'post-3']);
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result['post-2']).toBeGreaterThanOrEqual(0);
|
||||
expect(result['post-2']).toBeLessThanOrEqual(1);
|
||||
expect(result['post-3']).toBeGreaterThanOrEqual(0);
|
||||
expect(result['post-3']).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('omits targets without embeddings', async () => {
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
|
||||
const result = await engine.computeSimilarities('post-1', ['not-indexed']);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('excludes self from results', async () => {
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
|
||||
const result = await engine.computeSimilarities('post-1', ['post-1']);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIndexingProgress', () => {
|
||||
it('returns zero indexed and total when no posts', async () => {
|
||||
postRowsStore = [];
|
||||
const progress = await engine.getIndexingProgress();
|
||||
expect(progress.indexed).toBe(0);
|
||||
expect(progress.total).toBe(0);
|
||||
});
|
||||
|
||||
it('returns indexed from key map and total from posts table', async () => {
|
||||
await engine.embedPost('post-1', 'Title 1', 'Content 1');
|
||||
|
||||
// Set up posts DB to return 3 posts (only 1 indexed)
|
||||
postRowsStore = [
|
||||
{ id: 'post-1', title: 'T1', content: 'C1' },
|
||||
{ id: 'post-2', title: 'T2', content: 'C2' },
|
||||
{ id: 'post-3', title: 'T3', content: 'C3' },
|
||||
];
|
||||
|
||||
const progress = await engine.getIndexingProgress();
|
||||
expect(progress.indexed).toBe(1); // only post-1 in key map
|
||||
expect(progress.total).toBe(3); // 3 posts in DB
|
||||
});
|
||||
});
|
||||
|
||||
describe('setProjectContext', () => {
|
||||
it('clears key map when switching projects', async () => {
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
|
||||
// Switch to new project — should clear key map
|
||||
keyRowsStore = []; // No keys for proj2
|
||||
await engine.setProjectContext('proj2');
|
||||
|
||||
// post-1 is no longer in the key map for proj2
|
||||
const results = await engine.findSimilar('post-1');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a no-op when called with same project', async () => {
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
|
||||
await engine.setProjectContext('proj1'); // same project
|
||||
|
||||
// Key map should still have post-1
|
||||
expect(engine['postIdToLabel'].has('post-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save and load', () => {
|
||||
it('persists USearch index file to disk', async () => {
|
||||
await engine.embedPost('post-1', 'Hello', 'World');
|
||||
await engine.save();
|
||||
|
||||
const indexPath = path.join(tmpDir, 'proj1.usearch');
|
||||
const stat = await fs.stat(indexPath);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
expect(stat.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('loads persisted index after restart', async () => {
|
||||
await engine.embedPost('post-1', 'Hello', 'World');
|
||||
await engine.embedPost('post-2', 'Goodbye', 'World two');
|
||||
await engine.save();
|
||||
const savedKeyRows = [...keyRowsStore];
|
||||
|
||||
// Create new engine instance simulating restart
|
||||
const engine2 = makeEngine(tmpDir);
|
||||
keyRowsStore = savedKeyRows; // Restore DB state
|
||||
await engine2.setProjectContext('proj1');
|
||||
|
||||
// Should have loaded the key map
|
||||
expect(engine2['postIdToLabel'].has('post-1')).toBe(true);
|
||||
expect(engine2['postIdToLabel'].has('post-2')).toBe(true);
|
||||
|
||||
await engine2.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissPair', () => {
|
||||
it('inserts dismissed pair with canonical ordering', async () => {
|
||||
await engine.dismissPair('zzz-post', 'aaa-post');
|
||||
|
||||
expect(dismissedRowsStore.length).toBe(1);
|
||||
const row = dismissedRowsStore[0]!;
|
||||
// Should be stored with canonical (alphabetical) ordering
|
||||
expect(row.postIdA).toBe('aaa-post');
|
||||
expect(row.postIdB).toBe('zzz-post');
|
||||
expect(row.projectId).toBe('proj1');
|
||||
});
|
||||
|
||||
it('stores pair in both orderings consistently', async () => {
|
||||
await engine.dismissPair('post-b', 'post-a');
|
||||
const row = dismissedRowsStore[0]!;
|
||||
expect(row.postIdA).toBe('post-a'); // canonical order
|
||||
expect(row.postIdB).toBe('post-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content hash change detection', () => {
|
||||
it('detects unchanged content and skips re-embedding', async () => {
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
const embedsAfterFirst = embedCallCount;
|
||||
|
||||
// Second call with same content — in-memory cache should prevent re-embed
|
||||
await engine.embedPost('post-1', 'Title', 'Content');
|
||||
expect(embedCallCount).toBe(embedsAfterFirst);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ function createMockPostEngine() {
|
||||
return {
|
||||
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
|
||||
getPost: vi.fn().mockResolvedValue(null),
|
||||
getPostBySlug: vi.fn().mockResolvedValue(null),
|
||||
searchPosts: vi.fn().mockResolvedValue([]),
|
||||
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
|
||||
createPost: vi.fn().mockResolvedValue({
|
||||
@@ -205,6 +206,11 @@ describe('MCPServer', () => {
|
||||
const mcpServer = server.createMcpServer();
|
||||
expect(hasRegistered(mcpServer, '_registeredTools', 'discard_proposal')).toBe(true);
|
||||
});
|
||||
|
||||
it('registers read_post_by_slug tool', () => {
|
||||
const mcpServer = server.createMcpServer();
|
||||
expect(hasRegistered(mcpServer, '_registeredTools', 'read_post_by_slug')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered resources', () => {
|
||||
@@ -1165,6 +1171,33 @@ describe('MCPServer', () => {
|
||||
const result = await tool.handler({ category: 'tech' }, {}) as { content: Array<{ text: string }> };
|
||||
expect(result.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── read_post_by_slug tool ───────────────────────────────────────
|
||||
|
||||
it('read_post_by_slug returns post with backlinks when found', async () => {
|
||||
const post = { id: 'p1', title: 'Found', slug: 'found-post', content: 'body', status: 'published', tags: [], categories: [], createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'px', title: 'Ref', slug: 'ref' }]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'found-post' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.post.id).toBe('p1');
|
||||
expect(parsed.post.slug).toBe('found-post');
|
||||
expect(parsed.post.backlinks).toEqual([{ id: 'px', title: 'Ref', slug: 'ref' }]);
|
||||
expect(mockPostEngine.getPostBySlug).toHaveBeenCalledWith('found-post');
|
||||
});
|
||||
|
||||
it('read_post_by_slug returns error for nonexistent slug', async () => {
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'no-such-slug' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt handler behavior ────────────────────────────────────────
|
||||
|
||||
@@ -450,6 +450,36 @@ describe('MetaEngine', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('should preserve semanticSimilarityEnabled when updating from null metadata', async () => {
|
||||
// projectMetadata is null (fresh engine, no syncOnStartup called)
|
||||
// Simulates the case where setProjectContext was just called (e.g., dataPath change)
|
||||
expect(await metaEngine.getProjectMetadata()).toBeNull();
|
||||
|
||||
await metaEngine.updateProjectMetadata({ name: 'Blog', semanticSimilarityEnabled: true });
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata();
|
||||
expect(metadata?.semanticSimilarityEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve semanticSimilarityEnabled when merging into existing metadata', async () => {
|
||||
await metaEngine.setProjectMetadata({ name: 'Blog', semanticSimilarityEnabled: true });
|
||||
|
||||
// Update an unrelated field — should not lose semanticSimilarityEnabled
|
||||
await metaEngine.updateProjectMetadata({ name: 'Renamed Blog' });
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata();
|
||||
expect(metadata?.semanticSimilarityEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should persist semanticSimilarityEnabled to project.json', async () => {
|
||||
await metaEngine.updateProjectMetadata({ name: 'Blog', semanticSimilarityEnabled: true });
|
||||
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
const parsed = JSON.parse(mockFiles.get(projectPath)!);
|
||||
expect(parsed.semanticSimilarityEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should update project name only', async () => {
|
||||
await metaEngine.setProjectMetadata({
|
||||
name: 'Original Name',
|
||||
|
||||
@@ -619,6 +619,45 @@ Content for retrieval test`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPostBySlug', () => {
|
||||
it('should return null for non-existent slug', async () => {
|
||||
const result = await postEngine.getPostBySlug('no-such-slug');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should retrieve post by slug', async () => {
|
||||
const created = await postEngine.createPost({
|
||||
title: 'Slug Lookup Post',
|
||||
content: 'Content for slug test',
|
||||
});
|
||||
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: created.id,
|
||||
projectId: created.projectId,
|
||||
title: created.title,
|
||||
slug: created.slug,
|
||||
status: created.status,
|
||||
content: 'Content for slug test',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
const result = await postEngine.getPostBySlug(created.slug);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.title).toBe('Slug Lookup Post');
|
||||
expect(result?.content).toBe('Content for slug test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePost', () => {
|
||||
it('should return null when updating non-existent post', async () => {
|
||||
const result = await postEngine.updatePost('non-existent-id', { title: 'New Title' });
|
||||
|
||||
@@ -14,6 +14,7 @@ function createMockDeps(): BlogToolDeps {
|
||||
return {
|
||||
postEngine: {
|
||||
getPost: vi.fn(),
|
||||
getPostBySlug: vi.fn(),
|
||||
getAllPosts: vi.fn(),
|
||||
getPostsFiltered: vi.fn(),
|
||||
searchPostsFiltered: vi.fn(),
|
||||
@@ -72,12 +73,13 @@ describe('Blog Tools — createBlogTools', () => {
|
||||
tools = createBlogTools(deps);
|
||||
});
|
||||
|
||||
it('returns all 17 tools', () => {
|
||||
it('returns all 18 tools', () => {
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toHaveLength(17);
|
||||
expect(names).toHaveLength(18);
|
||||
expect(names).toContain('check_term');
|
||||
expect(names).toContain('search_posts');
|
||||
expect(names).toContain('read_post');
|
||||
expect(names).toContain('read_post_by_slug');
|
||||
expect(names).toContain('list_posts');
|
||||
expect(names).toContain('get_media');
|
||||
expect(names).toContain('list_media');
|
||||
@@ -243,6 +245,52 @@ describe('Blog Tools — read_post', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_post_by_slug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Blog Tools — read_post_by_slug', () => {
|
||||
let deps: BlogToolDeps;
|
||||
let tools: ReturnType<typeof createBlogTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = createMockDeps();
|
||||
tools = createBlogTools(deps);
|
||||
});
|
||||
|
||||
it('returns post with backlinks and outlinks when found by slug', async () => {
|
||||
vi.mocked(deps.postEngine.getPostBySlug).mockResolvedValueOnce(samplePost);
|
||||
vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([
|
||||
{ id: 'post-2', title: 'Related', slug: 'related' },
|
||||
]);
|
||||
|
||||
const result = await tools.read_post_by_slug.execute!(
|
||||
{ slug: 'hello-world' },
|
||||
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
post: {
|
||||
id: 'post-1',
|
||||
title: 'Hello World',
|
||||
slug: 'hello-world',
|
||||
content: '# Hello\n\nWorld',
|
||||
backlinks: [{ id: 'post-2', title: 'Related' }],
|
||||
},
|
||||
});
|
||||
expect(deps.postEngine.getPostBySlug).toHaveBeenCalledWith('hello-world');
|
||||
});
|
||||
|
||||
it('returns error for nonexistent slug', async () => {
|
||||
vi.mocked(deps.postEngine.getPostBySlug).mockResolvedValueOnce(null);
|
||||
const result = await tools.read_post_by_slug.execute!(
|
||||
{ slug: 'nope' },
|
||||
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
|
||||
);
|
||||
expect(result).toMatchObject({ success: false, error: 'Post not found' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_posts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1295,6 +1295,26 @@ describe('main bootstrap preview behavior', () => {
|
||||
}; }),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/engine/EmbeddingEngine', () => ({
|
||||
EmbeddingEngine: vi.fn().mockImplementation(function() { return {
|
||||
setProjectContext: vi.fn().mockResolvedValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
indexUnindexedPosts: vi.fn().mockResolvedValue(undefined),
|
||||
}; }),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||
BlogmarkTransformService: vi.fn().mockImplementation(function() { return {
|
||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[]; tags: string[] } }) => ({
|
||||
post: input.post,
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
})),
|
||||
}; }),
|
||||
}));
|
||||
|
||||
await import('../../src/main/main');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
|
||||
@@ -371,6 +371,7 @@ describe('IPC Handlers', () => {
|
||||
gitEngine: mockGitEngine,
|
||||
gitApiAdapter: {},
|
||||
taskManager: mockTaskManager,
|
||||
embeddingEngine: { reindexAll: vi.fn(), indexUnindexedPosts: vi.fn(), setProjectContext: vi.fn(), embedPost: vi.fn(), removePost: vi.fn() },
|
||||
blogGenerationEngine: null, // set in beforeEach
|
||||
publishEngine: { setProjectContext: vi.fn(), uploadHtml: vi.fn(), uploadThumbnails: vi.fn(), uploadMedia: vi.fn() },
|
||||
metadataDiffEngine: { setProjectContext: vi.fn(), comparePostMetadata: vi.fn(), scanAllPublishedPosts: vi.fn(), syncDbToFile: vi.fn(), syncFileToDb: vi.fn(), groupDifferencesByField: vi.fn() },
|
||||
@@ -1939,6 +1940,21 @@ describe('IPC Handlers', () => {
|
||||
expect(BrowserWindow.fromWebContents).toHaveBeenCalledWith(sender);
|
||||
expect(ownerWindow.setFullScreen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should start rebuild embedding index task when action is rebuildEmbeddingIndex', async () => {
|
||||
const send = vi.fn();
|
||||
const event = { sender: { send } };
|
||||
mockTaskManager.runTask.mockResolvedValue(undefined);
|
||||
|
||||
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'rebuildEmbeddingIndex');
|
||||
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringContaining('rebuild-embedding-index-'),
|
||||
})
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,61 @@ import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal';
|
||||
import { useAppStore } from '../../../src/renderer/store/appStore';
|
||||
|
||||
describe('InsertModal related posts confidence', () => {
|
||||
it('shows similarity confidence percentage for each related post', async () => {
|
||||
(window.electronAPI.embeddings.findSimilar as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ postId: 'p1', similarity: 0.92 },
|
||||
{ postId: 'p2', similarity: 0.67 },
|
||||
]);
|
||||
(window.electronAPI.posts.get as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ id: 'p1', title: 'Close Match', slug: 'close-match', excerpt: '' })
|
||||
.mockResolvedValueOnce({ id: 'p2', title: 'Loose Match', slug: 'loose-match', excerpt: '' });
|
||||
|
||||
render(
|
||||
<InsertModal
|
||||
mode="link"
|
||||
onInsertLink={vi.fn()}
|
||||
onInsertImage={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
currentPostId="current"
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for related posts to render
|
||||
expect(await screen.findByText('Close Match')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loose Match')).toBeInTheDocument();
|
||||
|
||||
// Confidence percentages should be displayed
|
||||
expect(screen.getByText('92%')).toBeInTheDocument();
|
||||
expect(screen.getByText('67%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows similarity confidence for search results', async () => {
|
||||
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 'p1', title: 'Found Post', slug: 'found-post', excerpt: 'Some text' },
|
||||
]);
|
||||
(window.electronAPI.embeddings.computeSimilarities as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
'p1': 0.85,
|
||||
});
|
||||
|
||||
render(
|
||||
<InsertModal
|
||||
mode="link"
|
||||
onInsertLink={vi.fn()}
|
||||
onInsertImage={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
currentPostId="current"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search posts by title or content...');
|
||||
fireEvent.input(input, { target: { value: 'Found Post' } });
|
||||
|
||||
expect(await screen.findByText('Found Post')).toBeInTheDocument();
|
||||
expect(await screen.findByText('85%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InsertModal format hints', () => {
|
||||
it('shows canonical post link format hint in internal link mode', () => {
|
||||
render(
|
||||
|
||||
@@ -370,4 +370,28 @@ describe('SettingsView Diff Preferences', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-saves semanticSimilarityEnabled immediately when toggled without requiring a Save click', async () => {
|
||||
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockResolvedValue({
|
||||
maxPostsPerPage: 50,
|
||||
semanticSimilarityEnabled: false,
|
||||
categorySettings: {
|
||||
article: { renderInLists: true, showTitle: true },
|
||||
},
|
||||
});
|
||||
(window as any).electronAPI.meta.updateProjectMetadata = vi.fn().mockResolvedValue({});
|
||||
|
||||
render(<SettingsView />);
|
||||
|
||||
const checkbox = await screen.findByLabelText(/semantic similarity/i);
|
||||
expect((checkbox as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(checkbox);
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ semanticSimilarityEnabled: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('editorRouting', () => {
|
||||
'site-validation': 'site-validation',
|
||||
scripts: 'scripts',
|
||||
templates: 'templates',
|
||||
'find-duplicates': 'find-duplicates',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('pythonApiContractV1', () => {
|
||||
|
||||
it('contains semantic version metadata for compatibility checks', () => {
|
||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||
version: '1.11.0',
|
||||
version: '1.12.0',
|
||||
generatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,6 +156,10 @@ Object.defineProperty(globalThis, 'window', {
|
||||
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||
rebuildFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
embeddings: {
|
||||
findSimilar: vi.fn().mockResolvedValue([]),
|
||||
computeSimilarities: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
on: vi.fn(() => () => {}),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user