/** * Tests for piece category configuration loading and building */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import type { PieceWithSource } from '../infra/config/index.js'; const languageState = vi.hoisted(() => ({ value: 'en' as 'en' | 'ja', })); const pathsState = vi.hoisted(() => ({ resourcesRoot: '', userCategoriesPath: '', })); vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, loadGlobalConfig: () => ({}), }; }); vi.mock('../infra/config/resolveConfigValue.js', () => ({ resolveConfigValue: (_cwd: string, key: string) => { if (key === 'language') return languageState.value; if (key === 'enableBuiltinPieces') return true; if (key === 'disabledBuiltins') return []; return undefined; }, resolveConfigValues: (_cwd: string, keys: readonly string[]) => { const result: Record = {}; for (const key of keys) { if (key === 'language') result[key] = languageState.value; if (key === 'enableBuiltinPieces') result[key] = true; if (key === 'disabledBuiltins') result[key] = []; } return result; }, })); vi.mock('../infra/resources/index.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, getLanguageResourcesDir: (lang: string) => join(pathsState.resourcesRoot, lang), }; }); vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, getPieceCategoriesPath: () => pathsState.userCategoriesPath, }; }); const { BUILTIN_CATEGORY_NAME, getPieceCategories, loadDefaultCategories, buildCategorizedPieces, findPieceCategories, } = await import('../infra/config/loaders/pieceCategories.js'); function writeYaml(path: string, content: string): void { writeFileSync(path, content.trim() + '\n', 'utf-8'); } function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'project' | 'repertoire' }[]): Map { const pieces = new Map(); for (const entry of entries) { pieces.set(entry.name, { source: entry.source, config: { name: entry.name, movements: [], initialMovement: 'start', maxMovements: 1, }, }); } return pieces; } describe('piece category config loading', () => { let testDir: string; let resourcesDir: string; beforeEach(() => { testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); resourcesDir = join(testDir, 'resources', 'en'); mkdirSync(resourcesDir, { recursive: true }); mkdirSync(join(testDir, 'resources', 'ja'), { recursive: true }); pathsState.resourcesRoot = join(testDir, 'resources'); languageState.value = 'en'; pathsState.userCategoriesPath = join(testDir, 'user-piece-categories.yaml'); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); it('should return null when builtin categories file is missing', () => { const config = getPieceCategories(testDir); expect(config).toBeNull(); }); it('should load default categories from resources', () => { writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` piece_categories: Quick Start: pieces: - default `); const config = loadDefaultCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Quick Start', pieces: ['default'], children: [] }, ]); expect(config!.builtinPieceCategories).toEqual([ { name: 'Quick Start', pieces: ['default'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([]); expect(config!.hasUserCategories).toBe(false); }); it('should use builtin categories when user overlay file is missing', () => { writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` piece_categories: Main: pieces: - default show_others_category: true others_category_name: Others `); const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([]); expect(config!.hasUserCategories).toBe(false); expect(config!.showOthersCategory).toBe(true); expect(config!.othersCategoryName).toBe('Others'); }); it('should separate user categories from builtin categories with builtin wrapper', () => { writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` piece_categories: Main: pieces: - default - coding Child: pieces: - nested Review: pieces: - review - e2e-test show_others_category: true others_category_name: Others `); writeYaml(pathsState.userCategoriesPath, ` piece_categories: Main: pieces: - custom My Team: pieces: - team-flow show_others_category: false others_category_name: Unclassified `); const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['custom'], children: [] }, { name: 'My Team', pieces: ['team-flow'], children: [] }, { name: BUILTIN_CATEGORY_NAME, pieces: [], children: [ { name: 'Main', pieces: ['default', 'coding'], children: [ { name: 'Child', pieces: ['nested'], children: [] }, ], }, { name: 'Review', pieces: ['review', 'e2e-test'], children: [] }, ], }, ]); expect(config!.builtinPieceCategories).toEqual([ { name: 'Main', pieces: ['default', 'coding'], children: [ { name: 'Child', pieces: ['nested'], children: [] }, ], }, { name: 'Review', pieces: ['review', 'e2e-test'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([ { name: 'Main', pieces: ['custom'], children: [] }, { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); expect(config!.hasUserCategories).toBe(true); expect(config!.showOthersCategory).toBe(false); expect(config!.othersCategoryName).toBe('Unclassified'); }); it('should load ja builtin categories and include e2e-test under レビュー', () => { languageState.value = 'ja'; writeYaml(join(testDir, 'resources', 'ja', 'piece-categories.yaml'), ` piece_categories: レビュー: pieces: - review - e2e-test `); const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'レビュー', pieces: ['review', 'e2e-test'], children: [] }, ]); }); it('should override others settings without replacing categories when user overlay has no piece_categories', () => { writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` piece_categories: Main: pieces: - default Review: pieces: - review show_others_category: true others_category_name: Others `); writeYaml(pathsState.userCategoriesPath, ` show_others_category: false others_category_name: Unclassified `); const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, { name: 'Review', pieces: ['review'], children: [] }, ]); expect(config!.builtinPieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, { name: 'Review', pieces: ['review'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([]); expect(config!.hasUserCategories).toBe(false); expect(config!.showOthersCategory).toBe(false); expect(config!.othersCategoryName).toBe('Unclassified'); }); }); describe('buildCategorizedPieces', () => { it('should collect missing pieces with source information', () => { const allPieces = createPieceMap([ { name: 'custom', source: 'user' }, { name: 'nested', source: 'builtin' }, { name: 'team-flow', source: 'user' }, ]); const config = { pieceCategories: [ { name: 'Main', pieces: ['custom'], children: [{ name: 'Child', pieces: ['nested'], children: [] }], }, { name: 'My Team', pieces: ['team-flow'], children: [] }, ], builtinPieceCategories: [ { name: 'Main', pieces: ['default'], children: [{ name: 'Child', pieces: ['nested'], children: [] }], }, ], userPieceCategories: [ { name: 'My Team', pieces: ['missing-user-piece'], children: [] }, ], hasUserCategories: true, showOthersCategory: true, othersCategoryName: 'Others', }; const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', pieces: ['custom'], children: [{ name: 'Child', pieces: ['nested'], children: [] }], }, { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); expect(categorized.missingPieces).toEqual([ { categoryPath: ['Main'], pieceName: 'default', source: 'builtin' }, { categoryPath: ['My Team'], pieceName: 'missing-user-piece', source: 'user' }, ]); }); it('should append Others category for uncategorized pieces', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, { name: 'extra', source: 'builtin' }, ]); const config = { pieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], builtinPieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], userPieceCategories: [], hasUserCategories: false, showOthersCategory: true, othersCategoryName: 'Others', }; const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, { name: 'Others', pieces: ['extra'], children: [] }, ]); }); it('should not append Others when showOthersCategory is false', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, { name: 'extra', source: 'builtin' }, ]); const config = { pieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], builtinPieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], userPieceCategories: [], hasUserCategories: false, showOthersCategory: false, othersCategoryName: 'Others', }; const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, ]); }); it('should categorize pieces through builtin wrapper node', () => { const allPieces = createPieceMap([ { name: 'custom', source: 'user' }, { name: 'default', source: 'builtin' }, { name: 'review', source: 'builtin' }, { name: 'extra', source: 'builtin' }, ]); const config = { pieceCategories: [ { name: 'My Team', pieces: ['custom'], children: [] }, { name: BUILTIN_CATEGORY_NAME, pieces: [], children: [ { name: 'Quick Start', pieces: ['default'], children: [] }, { name: 'Review', pieces: ['review'], children: [] }, ], }, ], builtinPieceCategories: [ { name: 'Quick Start', pieces: ['default'], children: [] }, { name: 'Review', pieces: ['review'], children: [] }, ], userPieceCategories: [ { name: 'My Team', pieces: ['custom'], children: [] }, ], hasUserCategories: true, showOthersCategory: true, othersCategoryName: 'Others', }; const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'My Team', pieces: ['custom'], children: [] }, { name: BUILTIN_CATEGORY_NAME, pieces: [], children: [ { name: 'Quick Start', pieces: ['default'], children: [] }, { name: 'Review', pieces: ['review'], children: [] }, ], }, { name: 'Others', pieces: ['extra'], children: [] }, ]); }); it('should find categories containing a piece', () => { const categories = [ { name: 'A', pieces: ['shared'], children: [] }, { name: 'B', pieces: ['shared'], children: [] }, ]; const paths = findPieceCategories('shared', categories).sort(); expect(paths).toEqual(['A', 'B']); }); it('should handle nested category paths', () => { const categories = [ { name: 'Parent', pieces: [], children: [ { name: 'Child', pieces: ['nested'], children: [] }, ], }, ]; const paths = findPieceCategories('nested', categories); expect(paths).toEqual(['Parent / Child']); }); it('should append repertoire category for @scope pieces', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, { name: '@nrslib/takt-ensemble/expert', source: 'repertoire' }, { name: '@nrslib/takt-ensemble/reviewer', source: 'repertoire' }, ]); const config = { pieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], builtinPieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], userPieceCategories: [], hasUserCategories: false, showOthersCategory: true, othersCategoryName: 'Others', }; const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); // repertoire category is appended const repertoireCat = categorized.categories.find((c) => c.name === 'repertoire'); expect(repertoireCat).toBeDefined(); expect(repertoireCat!.children).toHaveLength(1); expect(repertoireCat!.children[0]!.name).toBe('@nrslib/takt-ensemble'); expect(repertoireCat!.children[0]!.pieces).toEqual( expect.arrayContaining(['@nrslib/takt-ensemble/expert', '@nrslib/takt-ensemble/reviewer']), ); // @scope pieces must not appear in Others const othersCat = categorized.categories.find((c) => c.name === 'Others'); expect(othersCat?.pieces ?? []).not.toContain('@nrslib/takt-ensemble/expert'); }); it('should not append repertoire category when no @scope pieces exist', () => { const allPieces = createPieceMap([{ name: 'default', source: 'builtin' }]); const config = { pieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], builtinPieceCategories: [{ name: 'Main', pieces: ['default'], children: [] }], userPieceCategories: [], hasUserCategories: false, showOthersCategory: true, othersCategoryName: 'Others', }; const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); const repertoireCat = categorized.categories.find((c) => c.name === 'repertoire'); expect(repertoireCat).toBeUndefined(); }); });