Files
bDS/tests/engine/OpenCodeManagerTools.test.ts

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenCodeManager Tool Execution Tests
*
* Tests the executeTool method for post-related tools,
* specifically that backlinks and linksTo are included in results.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock dependencies before importing the class
vi.mock('../../src/main/engine/ChatEngine', () => ({
ChatEngine: class {
getSetting = vi.fn();
setSetting = vi.fn();
getSelectedModel = vi.fn();
getDefaultSystemPrompt = vi.fn();
},
}));
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({})),
}));
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({})),
}));
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({})),
}));
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
function createMockPostEngine() {
return {
getPost: vi.fn(),
searchPosts: vi.fn(),
searchPostsFiltered: vi.fn(),
getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(),
getDashboardStats: vi.fn().mockResolvedValue({ totalPosts: 0 }),
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getBlogStats: vi.fn().mockResolvedValue({}),
};
}
function createMockMediaEngine() {
return {
getAllMedia: vi.fn(),
getMedia: vi.fn(),
getThumbnailDataUrl: vi.fn(),
};
}
function createMockPostMediaEngine() {
return {
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
};
}
function createManager(postEngine: ReturnType<typeof createMockPostEngine>, mediaEngine?: ReturnType<typeof createMockMediaEngine>, postMediaEngine?: ReturnType<typeof createMockPostMediaEngine>) {
const manager = new OpenCodeManager(
{ getSetting: vi.fn(), setSetting: vi.fn() } as never,
postEngine as never,
(mediaEngine ?? createMockMediaEngine()) as never,
(postMediaEngine ?? createMockPostMediaEngine()) as never,
() => null,
);
return manager;
}
describe('OpenCodeManager tool execution backlinks & linksTo', () => {
let mockPostEngine: ReturnType<typeof createMockPostEngine>;
let manager: OpenCodeManager;
beforeEach(() => {
vi.clearAllMocks();
mockPostEngine = createMockPostEngine();
manager = createManager(mockPostEngine);
});
describe('read_post', () => {
it('includes backlinks and linksTo in the response', async () => {
const post = {
id: 'p1', title: 'Target Post', slug: 'target-post',
content: '# Hello', excerpt: 'Hello', status: 'published',
author: 'Test', categories: ['article'], tags: ['test'],
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
publishedAt: new Date('2025-01-01'),
};
mockPostEngine.getPost.mockResolvedValue(post);
mockPostEngine.getLinkedBy.mockResolvedValue([
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
]);
mockPostEngine.getLinksTo.mockResolvedValue([
{ id: 'p4', title: 'Linked Target', slug: 'linked-target' },
]);
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
expect(result.success).toBe(true);
expect(result.post.backlinks).toEqual([
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
]);
expect(result.post.linksTo).toEqual([
{ id: 'p4', title: 'Linked Target', slug: 'linked-target' },
]);
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1');
});
it('returns empty backlinks and linksTo arrays when none exist', async () => {
const post = {
id: 'p1', title: 'Lonely Post', slug: 'lonely-post',
content: '# Alone', excerpt: '', status: 'draft',
categories: [], tags: [],
createdAt: new Date(), updatedAt: new Date(),
};
mockPostEngine.getPost.mockResolvedValue(post);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
expect(result.success).toBe(true);
expect(result.post.backlinks).toEqual([]);
expect(result.post.linksTo).toEqual([]);
});
});
describe('search_posts', () => {
it('includes backlinks and linksTo for each post in search results', async () => {
const posts = [
{ id: 'p1', title: 'Post One', slug: 'post-one', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
{ id: 'p2', title: 'Post Two', slug: 'post-two', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
];
mockPostEngine.searchPostsFiltered.mockResolvedValue(posts);
mockPostEngine.getLinkedBy
.mockResolvedValueOnce([{ id: 'p3', title: 'Linker', slug: 'linker' }])
.mockResolvedValueOnce([]);
mockPostEngine.getLinksTo
.mockResolvedValueOnce([{ id: 'p4', title: 'Target', slug: 'target' }])
.mockResolvedValueOnce([{ id: 'p5', title: 'Other', slug: 'other' }]);
const result = await (manager as any).executeTool('search_posts', { query: 'test' });
expect(result.success).toBe(true);
expect(result.posts[0].backlinks).toEqual([{ id: 'p3', title: 'Linker', slug: 'linker' }]);
expect(result.posts[0].linksTo).toEqual([{ id: 'p4', title: 'Target', slug: 'target' }]);
expect(result.posts[1].backlinks).toEqual([]);
expect(result.posts[1].linksTo).toEqual([{ id: 'p5', title: 'Other', slug: 'other' }]);
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledTimes(2);
expect(mockPostEngine.getLinksTo).toHaveBeenCalledTimes(2);
});
});
describe('list_posts', () => {
it('includes backlinks and linksTo for each post in listed results', async () => {
const posts = [
{ id: 'p1', title: 'Post A', slug: 'post-a', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
];
mockPostEngine.getAllPosts.mockResolvedValue({ items: posts, total: 1 });
mockPostEngine.getLinkedBy.mockResolvedValue([
{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' },
]);
mockPostEngine.getLinksTo.mockResolvedValue([
{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' },
]);
const result = await (manager as any).executeTool('list_posts', {});
expect(result.success).toBe(true);
expect(result.posts[0].backlinks).toEqual([{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' }]);
expect(result.posts[0].linksTo).toEqual([{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' }]);
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1');
});
it('includes backlinks and linksTo for filtered list results', async () => {
const posts = [
{ id: 'p5', title: 'Tagged Post', slug: 'tagged', status: 'published', categories: [], tags: ['js'], createdAt: new Date(), updatedAt: new Date() },
];
mockPostEngine.getPostsFiltered.mockResolvedValue(posts);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const result = await (manager as any).executeTool('list_posts', { tags: ['js'] });
expect(result.success).toBe(true);
expect(result.posts[0].backlinks).toEqual([]);
expect(result.posts[0].linksTo).toEqual([]);
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p5');
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p5');
});
});
// ── check_term tool ──────────────────────────────────────────────
describe('check_term', () => {
it('returns category and tag info for a term that exists as both', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'wiki', count: 3 },
{ category: 'tech', count: 5 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 1 },
{ tag: 'python', count: 4 },
]);
const result = await (manager as any).executeTool('check_term', { term: 'wiki' });
expect(result.success).toBe(true);
expect(result.term).toBe('wiki');
expect(result.asCategory).toBe(true);
expect(result.categoryPostCount).toBe(3);
expect(result.asTag).toBe(true);
expect(result.tagPostCount).toBe(1);
});
it('returns false for a term that does not exist', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'tech', count: 5 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'python', count: 4 },
]);
const result = await (manager as any).executeTool('check_term', { term: 'nonexistent' });
expect(result.success).toBe(true);
expect(result.term).toBe('nonexistent');
expect(result.asCategory).toBe(false);
expect(result.categoryPostCount).toBe(0);
expect(result.asTag).toBe(false);
expect(result.tagPostCount).toBe(0);
});
it('is case-insensitive', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'Wiki', count: 3 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([]);
const result = await (manager as any).executeTool('check_term', { term: 'wiki' });
expect(result.success).toBe(true);
expect(result.asCategory).toBe(true);
expect(result.categoryPostCount).toBe(3);
});
});
// ── month validation ────────────────────────────────────────────────
describe('month validation', () => {
it('search_posts returns error when month is given without year', async () => {
const result = await (manager as any).executeTool('search_posts', { query: 'test', month: 3 });
expect(result.success).toBe(false);
expect(result.error).toContain('month');
expect(result.error).toContain('year');
});
it('list_posts returns error when month is given without year', async () => {
const result = await (manager as any).executeTool('list_posts', { month: 3 });
expect(result.success).toBe(false);
expect(result.error).toContain('month');
expect(result.error).toContain('year');
});
it('list_media returns error when month is given without year', async () => {
const result = await (manager as any).executeTool('list_media', { month: 3 });
expect(result.success).toBe(false);
expect(result.error).toContain('month');
expect(result.error).toContain('year');
});
it('search_posts accepts month when year is also given', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
const result = await (manager as any).executeTool('search_posts', { query: 'test', year: 2025, month: 3 });
expect(result.success).toBe(true);
});
it('list_posts accepts month when year is also given', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
const result = await (manager as any).executeTool('list_posts', { year: 2025, month: 3 });
expect(result.success).toBe(true);
});
});
// ── ambiguity hints ─────────────────────────────────────────────────
describe('ambiguity hints', () => {
it('search_posts includes hint when category also exists as tag', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', slug: 'post', excerpt: '', status: 'published', categories: ['wiki'], tags: [], createdAt: new Date(), updatedAt: new Date() },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 2 },
]);
const result = await (manager as any).executeTool('search_posts', { query: 'test', category: 'wiki' });
expect(result.success).toBe(true);
expect(result.hints).toBeDefined();
expect(result.hints.length).toBeGreaterThan(0);
expect(result.hints[0]).toContain('wiki');
expect(result.hints[0]).toContain('tag');
});
it('list_posts includes hint when category also exists as tag', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', slug: 'post', status: 'published', categories: ['wiki'], tags: [], createdAt: new Date(), updatedAt: new Date() },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 2 },
]);
const result = await (manager as any).executeTool('list_posts', { category: 'wiki' });
expect(result.success).toBe(true);
expect(result.hints).toBeDefined();
expect(result.hints[0]).toContain('wiki');
expect(result.hints[0]).toContain('tag');
});
it('list_posts includes hint when tags also exist as categories', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'wiki', count: 3 },
]);
const result = await (manager as any).executeTool('list_posts', { tags: ['wiki'] });
expect(result.success).toBe(true);
expect(result.hints).toBeDefined();
expect(result.hints[0]).toContain('wiki');
expect(result.hints[0]).toContain('category');
});
it('search_posts does not include hints when no ambiguity exists', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'python', count: 4 },
]);
const result = await (manager as any).executeTool('search_posts', { query: 'test', category: 'tech' });
expect(result.success).toBe(true);
expect(result.hints).toBeUndefined();
});
});
});
// ── check_term tool definition ──────────────────────────────────────
describe('OpenCodeManager tool definitions', () => {
let manager: OpenCodeManager;
beforeEach(() => {
vi.clearAllMocks();
manager = createManager(createMockPostEngine());
});
it('includes check_term in tool definitions', () => {
const tools = (manager as any).getToolDefinitions();
const checkTerm = tools.find((t: any) => t.name === 'check_term');
expect(checkTerm).toBeDefined();
expect(checkTerm.input_schema.required).toContain('term');
});
});
describe('OpenCodeManager getMaxOutputTokens (ModelCatalogEngine delegate)', () => {
let manager: OpenCodeManager;
beforeEach(() => {
vi.clearAllMocks();
manager = createManager(createMockPostEngine());
});
it('delegates to ModelCatalogEngine.getMaxOutputTokens', async () => {
const engine = (manager as any).modelCatalogEngine;
vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(64000);
const result = await (manager as any).getMaxOutputTokens('claude-sonnet-4-5');
expect(result).toBe(64000);
expect(engine.getMaxOutputTokens).toHaveBeenCalledWith('claude-sonnet-4-5');
});
it('returns default when ModelCatalogEngine has no data', async () => {
const engine = (manager as any).modelCatalogEngine;
vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(16384);
const result = await (manager as any).getMaxOutputTokens('unknown-model');
expect(result).toBe(16384);
});
it('exposes ModelCatalogEngine via getModelCatalogEngine()', () => {
const engine = manager.getModelCatalogEngine();
expect(engine).toBeDefined();
expect(engine).toBeInstanceOf(Object);
});
});