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:
Georg Bauer
2026-03-05 22:05:32 +01:00
committed by GitHub
parent 8ac8305e01
commit 7e1e8981a3
64 changed files with 6429 additions and 499 deletions

View 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);
});
});
});

View File

@@ -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 ────────────────────────────────────────

View File

@@ -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',

View File

@@ -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' });

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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));

View File

@@ -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();
});
});
});

View File

@@ -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(

View File

@@ -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 })
);
});
});

View File

@@ -23,6 +23,7 @@ describe('editorRouting', () => {
'site-validation': 'site-validation',
scripts: 'scripts',
templates: 'templates',
'find-duplicates': 'find-duplicates',
});
});

View File

@@ -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),
});
});

View File

@@ -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(() => () => {}),
},
},