takt/src/__tests__/catalog.test.ts

383 lines
13 KiB
TypeScript

/**
* Tests for facet catalog scanning and display.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
extractDescription,
parseFacetType,
scanFacets,
displayFacets,
showCatalog,
type FacetEntry,
} from '../features/catalog/catalogFacets.js';
// Mock external dependencies to isolate unit tests
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: () => ({}),
}));
vi.mock('../infra/config/loadConfig.js', () => ({
loadConfig: () => ({
global: {
language: 'en',
enableBuiltinPieces: true,
},
project: {},
}),
}));
const mockLogError = vi.fn();
const mockInfo = vi.fn();
vi.mock('../shared/ui/index.js', () => ({
error: (...args: unknown[]) => mockLogError(...args),
info: (...args: unknown[]) => mockInfo(...args),
section: (title: string) => console.log(title),
}));
let mockBuiltinDir: string;
vi.mock('../infra/resources/index.js', () => ({
getLanguageResourcesDir: () => mockBuiltinDir,
}));
let mockGlobalDir: string;
vi.mock('../infra/config/paths.js', () => ({
getGlobalConfigDir: () => mockGlobalDir,
getProjectConfigDir: (cwd: string) => join(cwd, '.takt'),
}));
describe('parseFacetType', () => {
it('should return FacetType for valid inputs', () => {
expect(parseFacetType('personas')).toBe('personas');
expect(parseFacetType('policies')).toBe('policies');
expect(parseFacetType('knowledge')).toBe('knowledge');
expect(parseFacetType('instructions')).toBe('instructions');
expect(parseFacetType('output-contracts')).toBe('output-contracts');
});
it('should return null for invalid inputs', () => {
expect(parseFacetType('unknown')).toBeNull();
expect(parseFacetType('persona')).toBeNull();
expect(parseFacetType('')).toBeNull();
});
});
describe('extractDescription', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-catalog-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should extract first heading from markdown file', () => {
const filePath = join(tempDir, 'test.md');
writeFileSync(filePath, '# My Persona\n\nSome content here.');
expect(extractDescription(filePath)).toBe('My Persona');
});
it('should return first non-empty line when no heading exists', () => {
const filePath = join(tempDir, 'test.md');
writeFileSync(filePath, 'No heading in this file\nJust plain text.');
expect(extractDescription(filePath)).toBe('No heading in this file');
});
it('should return empty string when file is empty', () => {
const filePath = join(tempDir, 'test.md');
writeFileSync(filePath, '');
expect(extractDescription(filePath)).toBe('');
});
it('should skip blank lines and return first non-empty line', () => {
const filePath = join(tempDir, 'test.md');
writeFileSync(filePath, '\n\n \nActual content here\nMore text.');
expect(extractDescription(filePath)).toBe('Actual content here');
});
it('should extract from first heading, ignoring later headings', () => {
const filePath = join(tempDir, 'test.md');
writeFileSync(filePath, 'Preamble\n# First Heading\n# Second Heading');
expect(extractDescription(filePath)).toBe('First Heading');
});
it('should trim whitespace from heading text', () => {
const filePath = join(tempDir, 'test.md');
writeFileSync(filePath, '# Spaced Heading \n');
expect(extractDescription(filePath)).toBe('Spaced Heading');
});
});
describe('scanFacets', () => {
let tempDir: string;
let builtinDir: string;
let globalDir: string;
let projectDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-catalog-test-'));
builtinDir = join(tempDir, 'builtin-lang');
globalDir = join(tempDir, 'global');
projectDir = join(tempDir, 'project');
mockBuiltinDir = builtinDir;
mockGlobalDir = globalDir;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should collect facets from all three layers', () => {
// Given: facets in builtin, user, and project layers
const builtinPersonas = join(builtinDir, 'personas');
const globalPersonas = join(globalDir, 'personas');
const projectPersonas = join(projectDir, '.takt', 'personas');
mkdirSync(builtinPersonas, { recursive: true });
mkdirSync(globalPersonas, { recursive: true });
mkdirSync(projectPersonas, { recursive: true });
writeFileSync(join(builtinPersonas, 'coder.md'), '# Coder Agent');
writeFileSync(join(globalPersonas, 'my-reviewer.md'), '# My Reviewer');
writeFileSync(join(projectPersonas, 'project-coder.md'), '# Project Coder');
// When: scanning personas
const entries = scanFacets('personas', projectDir);
// Then: all three entries are collected
expect(entries).toHaveLength(3);
const coder = entries.find((e) => e.name === 'coder');
expect(coder).toBeDefined();
expect(coder!.source).toBe('builtin');
expect(coder!.description).toBe('Coder Agent');
const myReviewer = entries.find((e) => e.name === 'my-reviewer');
expect(myReviewer).toBeDefined();
expect(myReviewer!.source).toBe('user');
const projectCoder = entries.find((e) => e.name === 'project-coder');
expect(projectCoder).toBeDefined();
expect(projectCoder!.source).toBe('project');
});
it('should detect override when higher layer has same name', () => {
// Given: same facet name in builtin and user layers
const builtinPersonas = join(builtinDir, 'personas');
const globalPersonas = join(globalDir, 'personas');
mkdirSync(builtinPersonas, { recursive: true });
mkdirSync(globalPersonas, { recursive: true });
writeFileSync(join(builtinPersonas, 'coder.md'), '# Builtin Coder');
writeFileSync(join(globalPersonas, 'coder.md'), '# Custom Coder');
// When: scanning personas
const entries = scanFacets('personas', tempDir);
// Then: builtin entry is marked as overridden by user
const builtinCoder = entries.find((e) => e.name === 'coder' && e.source === 'builtin');
expect(builtinCoder).toBeDefined();
expect(builtinCoder!.overriddenBy).toBe('user');
const userCoder = entries.find((e) => e.name === 'coder' && e.source === 'user');
expect(userCoder).toBeDefined();
expect(userCoder!.overriddenBy).toBeUndefined();
});
it('should detect override through project layer', () => {
// Given: same facet name in builtin and project layers
const builtinPolicies = join(builtinDir, 'policies');
const projectPolicies = join(projectDir, '.takt', 'policies');
mkdirSync(builtinPolicies, { recursive: true });
mkdirSync(projectPolicies, { recursive: true });
writeFileSync(join(builtinPolicies, 'coding.md'), '# Builtin Coding');
writeFileSync(join(projectPolicies, 'coding.md'), '# Project Coding');
// When: scanning policies
const entries = scanFacets('policies', projectDir);
// Then: builtin entry is marked as overridden by project
const builtinCoding = entries.find((e) => e.name === 'coding' && e.source === 'builtin');
expect(builtinCoding).toBeDefined();
expect(builtinCoding!.overriddenBy).toBe('project');
});
it('should handle non-existent directories gracefully', () => {
// Given: no directories exist
// When: scanning a facet type
const entries = scanFacets('knowledge', projectDir);
// Then: returns empty array
expect(entries).toEqual([]);
});
it('should only include .md files', () => {
// Given: directory with mixed file types
const builtinKnowledge = join(builtinDir, 'knowledge');
mkdirSync(builtinKnowledge, { recursive: true });
writeFileSync(join(builtinKnowledge, 'valid.md'), '# Valid');
writeFileSync(join(builtinKnowledge, 'ignored.txt'), 'Not a markdown');
writeFileSync(join(builtinKnowledge, 'also-ignored.yaml'), 'name: yaml');
// When: scanning knowledge
const entries = scanFacets('knowledge', tempDir);
// Then: only .md file is included
expect(entries).toHaveLength(1);
expect(entries[0]!.name).toBe('valid');
});
it('should work with all facet types', () => {
// Given: one facet in each type directory
const types = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts'] as const;
for (const type of types) {
const dir = join(builtinDir, type);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'test.md'), `# Test ${type}`);
}
// When/Then: each type is scannable
for (const type of types) {
const entries = scanFacets(type, tempDir);
expect(entries).toHaveLength(1);
expect(entries[0]!.name).toBe('test');
}
});
});
describe('displayFacets', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('should display entries with name, description, and source', () => {
// Given: a list of facet entries
const entries: FacetEntry[] = [
{ name: 'coder', description: 'Coder Agent', source: 'builtin' },
{ name: 'my-reviewer', description: 'My Reviewer', source: 'user' },
];
// When: displaying facets
displayFacets('personas', entries);
// Then: output contains facet names
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('coder');
expect(output).toContain('my-reviewer');
expect(output).toContain('Personas');
});
it('should display (none) when entries are empty', () => {
// Given: empty entries
const entries: FacetEntry[] = [];
// When: displaying facets
displayFacets('policies', entries);
// Then: output shows (none)
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('(none)');
});
it('should display override information', () => {
// Given: an overridden entry
const entries: FacetEntry[] = [
{ name: 'coder', description: 'Builtin Coder', source: 'builtin', overriddenBy: 'user' },
];
// When: displaying facets
displayFacets('personas', entries);
// Then: output contains override info
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('overridden by user');
});
});
describe('showCatalog', () => {
let tempDir: string;
let builtinDir: string;
let globalDir: string;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-catalog-test-'));
builtinDir = join(tempDir, 'builtin-lang');
globalDir = join(tempDir, 'global');
mockBuiltinDir = builtinDir;
mockGlobalDir = globalDir;
mockLogError.mockClear();
mockInfo.mockClear();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
rmSync(tempDir, { recursive: true, force: true });
});
it('should display only the specified facet type when valid type is given', () => {
// Given: personas facet exists
const builtinPersonas = join(builtinDir, 'personas');
mkdirSync(builtinPersonas, { recursive: true });
writeFileSync(join(builtinPersonas, 'coder.md'), '# Coder Agent');
// When: showing catalog for personas only
showCatalog(tempDir, 'personas');
// Then: output contains the facet name and no error
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('coder');
expect(mockLogError).not.toHaveBeenCalled();
});
it('should show error when invalid facet type is given', () => {
// When: showing catalog for an invalid type
showCatalog(tempDir, 'invalid-type');
// Then: error is logged with the invalid type name
expect(mockLogError).toHaveBeenCalledWith(
expect.stringContaining('invalid-type'),
);
// Then: available types are shown via info
expect(mockInfo).toHaveBeenCalledWith(
expect.stringContaining('personas'),
);
});
it('should display all five facet types when no type is specified', () => {
// Given: no facets exist (empty directories)
// When: showing catalog without specifying a type
showCatalog(tempDir);
// Then: all 5 facet type headings are displayed
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('Personas');
expect(output).toContain('Policies');
expect(output).toContain('Knowledge');
expect(output).toContain('Instructions');
expect(output).toContain('Output-contracts');
});
});