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