takt: update-category-spec (#184)
This commit is contained in:
parent
f8bcc4ce7d
commit
c7305374d7
117
src/__tests__/global-pieceCategories.test.ts
Normal file
117
src/__tests__/global-pieceCategories.test.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Tests for global piece category path resolution.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const loadGlobalConfigMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../infra/config/paths.js', () => ({
|
||||
getGlobalConfigDir: () => '/tmp/.takt',
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: loadGlobalConfigMock,
|
||||
}));
|
||||
|
||||
const { getPieceCategoriesPath, resetPieceCategories } = await import(
|
||||
'../infra/config/global/pieceCategories.js'
|
||||
);
|
||||
|
||||
function createTempCategoriesPath(): string {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'takt-piece-categories-'));
|
||||
return join(tempRoot, 'preferences', 'piece-categories.yaml');
|
||||
}
|
||||
|
||||
describe('getPieceCategoriesPath', () => {
|
||||
beforeEach(() => {
|
||||
loadGlobalConfigMock.mockReset();
|
||||
});
|
||||
|
||||
it('should return configured path when pieceCategoriesFile is set', () => {
|
||||
// Given
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
pieceCategoriesFile: '/custom/piece-categories.yaml',
|
||||
});
|
||||
|
||||
// When
|
||||
const path = getPieceCategoriesPath();
|
||||
|
||||
// Then
|
||||
expect(path).toBe('/custom/piece-categories.yaml');
|
||||
});
|
||||
|
||||
it('should return default path when pieceCategoriesFile is not set', () => {
|
||||
// Given
|
||||
loadGlobalConfigMock.mockReturnValue({});
|
||||
|
||||
// When
|
||||
const path = getPieceCategoriesPath();
|
||||
|
||||
// Then
|
||||
expect(path).toBe('/tmp/.takt/preferences/piece-categories.yaml');
|
||||
});
|
||||
|
||||
it('should rethrow when global config loading fails', () => {
|
||||
// Given
|
||||
loadGlobalConfigMock.mockImplementation(() => {
|
||||
throw new Error('invalid global config');
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(() => getPieceCategoriesPath()).toThrow('invalid global config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPieceCategories', () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
loadGlobalConfigMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempRoot of tempRoots) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it('should create parent directory and initialize with empty user categories', () => {
|
||||
// Given
|
||||
const categoriesPath = createTempCategoriesPath();
|
||||
tempRoots.push(dirname(dirname(categoriesPath)));
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
pieceCategoriesFile: categoriesPath,
|
||||
});
|
||||
|
||||
// When
|
||||
resetPieceCategories();
|
||||
|
||||
// Then
|
||||
expect(existsSync(dirname(categoriesPath))).toBe(true);
|
||||
expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n');
|
||||
});
|
||||
|
||||
it('should overwrite existing file with empty user categories', () => {
|
||||
// Given
|
||||
const categoriesPath = createTempCategoriesPath();
|
||||
const categoriesDir = dirname(categoriesPath);
|
||||
const tempRoot = dirname(categoriesDir);
|
||||
tempRoots.push(tempRoot);
|
||||
loadGlobalConfigMock.mockReturnValue({
|
||||
pieceCategoriesFile: categoriesPath,
|
||||
});
|
||||
mkdirSync(categoriesDir, { recursive: true });
|
||||
writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8');
|
||||
|
||||
// When
|
||||
resetPieceCategories();
|
||||
|
||||
// Then
|
||||
expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n');
|
||||
});
|
||||
});
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@ -19,6 +19,8 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
getLanguage: () => 'en',
|
||||
getBuiltinPiecesEnabled: () => true,
|
||||
getDisabledBuiltins: () => [],
|
||||
};
|
||||
});
|
||||
|
||||
@ -30,9 +32,11 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../infra/config/global/pieceCategories.js', async () => {
|
||||
vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
ensureUserCategoriesFile: () => pathsState.userCategoriesPath,
|
||||
...original,
|
||||
getPieceCategoriesPath: () => pathsState.userCategoriesPath,
|
||||
};
|
||||
});
|
||||
|
||||
@ -74,72 +78,15 @@ describe('piece category config loading', () => {
|
||||
|
||||
mkdirSync(resourcesDir, { recursive: true });
|
||||
pathsState.resourcesDir = resourcesDir;
|
||||
pathsState.userCategoriesPath = join(testDir, 'user-piece-categories.yaml');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load categories from user file (auto-copied from default)', () => {
|
||||
const userPath = join(testDir, 'piece-categories.yaml');
|
||||
writeYaml(userPath, `
|
||||
piece_categories:
|
||||
Default:
|
||||
pieces:
|
||||
- simple
|
||||
show_others_category: true
|
||||
others_category_name: "Others"
|
||||
`);
|
||||
pathsState.userCategoriesPath = userPath;
|
||||
|
||||
it('should return null when builtin categories file is missing', () => {
|
||||
const config = getPieceCategories();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{ name: 'Default', pieces: ['simple'], children: [] },
|
||||
]);
|
||||
expect(config!.showOthersCategory).toBe(true);
|
||||
expect(config!.othersCategoryName).toBe('Others');
|
||||
});
|
||||
|
||||
it('should return null when user file has no piece_categories', () => {
|
||||
const userPath = join(testDir, 'piece-categories.yaml');
|
||||
writeYaml(userPath, `
|
||||
show_others_category: true
|
||||
`);
|
||||
pathsState.userCategoriesPath = userPath;
|
||||
|
||||
const config = getPieceCategories();
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse nested categories from user file', () => {
|
||||
const userPath = join(testDir, 'piece-categories.yaml');
|
||||
writeYaml(userPath, `
|
||||
piece_categories:
|
||||
Parent:
|
||||
pieces:
|
||||
- parent-piece
|
||||
Child:
|
||||
pieces:
|
||||
- child-piece
|
||||
`);
|
||||
pathsState.userCategoriesPath = userPath;
|
||||
|
||||
const config = getPieceCategories();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{
|
||||
name: 'Parent',
|
||||
pieces: ['parent-piece'],
|
||||
children: [
|
||||
{ name: 'Child', pieces: ['child-piece'], children: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null when default categories file is missing', () => {
|
||||
const config = loadDefaultCategories();
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
@ -156,19 +103,151 @@ piece_categories:
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{ name: 'Quick Start', pieces: ['default'], children: [] },
|
||||
]);
|
||||
expect(config!.builtinPieceCategories).toEqual([
|
||||
{ name: 'Quick Start', pieces: ['default'], children: [] },
|
||||
]);
|
||||
expect(config!.userPieceCategories).toEqual([]);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
]);
|
||||
expect(config!.userPieceCategories).toEqual([]);
|
||||
expect(config!.showOthersCategory).toBe(true);
|
||||
expect(config!.othersCategoryName).toBe('Others');
|
||||
});
|
||||
|
||||
it('should merge user overlay categories with builtin categories', () => {
|
||||
writeYaml(join(resourcesDir, 'piece-categories.yaml'), `
|
||||
piece_categories:
|
||||
Main:
|
||||
pieces:
|
||||
- default
|
||||
- coding
|
||||
Child:
|
||||
pieces:
|
||||
- nested
|
||||
Review:
|
||||
pieces:
|
||||
- review-only
|
||||
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();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{
|
||||
name: 'Main',
|
||||
pieces: ['custom'],
|
||||
children: [
|
||||
{ name: 'Child', pieces: ['nested'], children: [] },
|
||||
],
|
||||
},
|
||||
{ name: 'Review', pieces: ['review-only'], children: [] },
|
||||
{ name: 'My Team', pieces: ['team-flow'], children: [] },
|
||||
]);
|
||||
expect(config!.builtinPieceCategories).toEqual([
|
||||
{
|
||||
name: 'Main',
|
||||
pieces: ['default', 'coding'],
|
||||
children: [
|
||||
{ name: 'Child', pieces: ['nested'], children: [] },
|
||||
],
|
||||
},
|
||||
{ name: 'Review', pieces: ['review-only'], children: [] },
|
||||
]);
|
||||
expect(config!.userPieceCategories).toEqual([
|
||||
{ name: 'Main', pieces: ['custom'], children: [] },
|
||||
{ name: 'My Team', pieces: ['team-flow'], children: [] },
|
||||
]);
|
||||
expect(config!.showOthersCategory).toBe(false);
|
||||
expect(config!.othersCategoryName).toBe('Unclassified');
|
||||
});
|
||||
|
||||
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-only
|
||||
show_others_category: true
|
||||
others_category_name: Others
|
||||
`);
|
||||
|
||||
writeYaml(pathsState.userCategoriesPath, `
|
||||
show_others_category: false
|
||||
others_category_name: Unclassified
|
||||
`);
|
||||
|
||||
const config = getPieceCategories();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
{ name: 'Review', pieces: ['review-only'], children: [] },
|
||||
]);
|
||||
expect(config!.builtinPieceCategories).toEqual([
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
{ name: 'Review', pieces: ['review-only'], children: [] },
|
||||
]);
|
||||
expect(config!.userPieceCategories).toEqual([]);
|
||||
expect(config!.showOthersCategory).toBe(false);
|
||||
expect(config!.othersCategoryName).toBe('Unclassified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCategorizedPieces', () => {
|
||||
it('should place all pieces (user and builtin) into a unified category tree', () => {
|
||||
it('should collect missing pieces with source information', () => {
|
||||
const allPieces = createPieceMap([
|
||||
{ name: 'a', source: 'user' },
|
||||
{ name: 'b', source: 'user' },
|
||||
{ name: 'c', source: 'builtin' },
|
||||
{ name: 'custom', source: 'user' },
|
||||
{ name: 'nested', source: 'builtin' },
|
||||
{ name: 'team-flow', source: 'user' },
|
||||
]);
|
||||
const config = {
|
||||
pieceCategories: [
|
||||
{ name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] },
|
||||
{
|
||||
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: [] },
|
||||
],
|
||||
showOthersCategory: true,
|
||||
othersCategoryName: 'Others',
|
||||
@ -176,30 +255,19 @@ describe('buildCategorizedPieces', () => {
|
||||
|
||||
const categorized = buildCategorizedPieces(allPieces, config);
|
||||
expect(categorized.categories).toEqual([
|
||||
{ name: 'Cat', pieces: ['a', 'c'], children: [] },
|
||||
{ name: 'Others', pieces: ['b'], children: [] },
|
||||
{
|
||||
name: 'Main',
|
||||
pieces: ['custom'],
|
||||
children: [{ name: 'Child', pieces: ['nested'], children: [] }],
|
||||
},
|
||||
{ name: 'My Team', pieces: ['team-flow'], children: [] },
|
||||
]);
|
||||
expect(categorized.missingPieces).toEqual([
|
||||
{ categoryPath: ['Cat'], pieceName: 'missing' },
|
||||
{ categoryPath: ['Main'], pieceName: 'default', source: 'builtin' },
|
||||
{ categoryPath: ['My Team'], pieceName: 'missing-user-piece', source: 'user' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip empty categories', () => {
|
||||
const allPieces = createPieceMap([
|
||||
{ name: 'a', source: 'user' },
|
||||
]);
|
||||
const config = {
|
||||
pieceCategories: [
|
||||
{ name: 'Empty', pieces: [], children: [] },
|
||||
],
|
||||
showOthersCategory: false,
|
||||
othersCategoryName: 'Others',
|
||||
};
|
||||
|
||||
const categorized = buildCategorizedPieces(allPieces, config);
|
||||
expect(categorized.categories).toEqual([]);
|
||||
});
|
||||
|
||||
it('should append Others category for uncategorized pieces', () => {
|
||||
const allPieces = createPieceMap([
|
||||
{ name: 'default', source: 'builtin' },
|
||||
@ -209,28 +277,10 @@ describe('buildCategorizedPieces', () => {
|
||||
pieceCategories: [
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
],
|
||||
showOthersCategory: true,
|
||||
othersCategoryName: 'Others',
|
||||
};
|
||||
|
||||
const categorized = buildCategorizedPieces(allPieces, config);
|
||||
expect(categorized.categories).toEqual([
|
||||
builtinPieceCategories: [
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
{ name: 'Others', pieces: ['extra'], children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge uncategorized pieces into existing Others category', () => {
|
||||
const allPieces = createPieceMap([
|
||||
{ name: 'default', source: 'builtin' },
|
||||
{ name: 'extra', source: 'builtin' },
|
||||
{ name: 'user-piece', source: 'user' },
|
||||
]);
|
||||
const config = {
|
||||
pieceCategories: [
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
{ name: 'Others', pieces: ['extra'], children: [] },
|
||||
],
|
||||
userPieceCategories: [],
|
||||
showOthersCategory: true,
|
||||
othersCategoryName: 'Others',
|
||||
};
|
||||
@ -238,7 +288,7 @@ describe('buildCategorizedPieces', () => {
|
||||
const categorized = buildCategorizedPieces(allPieces, config);
|
||||
expect(categorized.categories).toEqual([
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
{ name: 'Others', pieces: ['extra', 'user-piece'], children: [] },
|
||||
{ name: 'Others', pieces: ['extra'], children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
@ -251,6 +301,10 @@ describe('buildCategorizedPieces', () => {
|
||||
pieceCategories: [
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
],
|
||||
builtinPieceCategories: [
|
||||
{ name: 'Main', pieces: ['default'], children: [] },
|
||||
],
|
||||
userPieceCategories: [],
|
||||
showOthersCategory: false,
|
||||
othersCategoryName: 'Others',
|
||||
};
|
||||
@ -286,25 +340,3 @@ describe('buildCategorizedPieces', () => {
|
||||
expect(paths).toEqual(['Parent / Child']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureUserCategoriesFile (integration)', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should copy default categories to user path when missing', async () => {
|
||||
// Use real ensureUserCategoriesFile (not mocked)
|
||||
const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js');
|
||||
|
||||
// This test depends on the mock still being active — just verify the mock returns our path
|
||||
const result = ensureUserCategoriesFile('/tmp/default.yaml');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
44
src/__tests__/resetCategories.test.ts
Normal file
44
src/__tests__/resetCategories.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tests for reset categories command behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../infra/config/global/pieceCategories.js', () => ({
|
||||
resetPieceCategories: vi.fn(),
|
||||
getPieceCategoriesPath: vi.fn(() => '/tmp/user-piece-categories.yaml'),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
header: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resetPieceCategories } from '../infra/config/global/pieceCategories.js';
|
||||
import { header, success, info } from '../shared/ui/index.js';
|
||||
import { resetCategoriesToDefault } from '../features/config/resetCategories.js';
|
||||
|
||||
const mockResetPieceCategories = vi.mocked(resetPieceCategories);
|
||||
const mockHeader = vi.mocked(header);
|
||||
const mockSuccess = vi.mocked(success);
|
||||
const mockInfo = vi.mocked(info);
|
||||
|
||||
describe('resetCategoriesToDefault', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should reset user category overlay and show updated message', async () => {
|
||||
// Given
|
||||
|
||||
// When
|
||||
await resetCategoriesToDefault();
|
||||
|
||||
// Then
|
||||
expect(mockHeader).toHaveBeenCalledWith('Reset Categories');
|
||||
expect(mockResetPieceCategories).toHaveBeenCalledTimes(1);
|
||||
expect(mockSuccess).toHaveBeenCalledWith('User category overlay reset.');
|
||||
expect(mockInfo).toHaveBeenCalledWith(' /tmp/user-piece-categories.yaml');
|
||||
});
|
||||
});
|
||||
@ -58,13 +58,26 @@ vi.mock('../features/pieceSelection/index.js', () => ({
|
||||
}));
|
||||
|
||||
import { confirm } from '../shared/prompt/index.js';
|
||||
import {
|
||||
getCurrentPiece,
|
||||
loadAllPiecesWithSources,
|
||||
getPieceCategories,
|
||||
buildCategorizedPieces,
|
||||
} from '../infra/config/index.js';
|
||||
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js';
|
||||
import { selectAndExecuteTask } from '../features/tasks/execute/selectAndExecute.js';
|
||||
import { warnMissingPieces, selectPieceFromCategorizedPieces } from '../features/pieceSelection/index.js';
|
||||
import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js';
|
||||
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockGetCurrentPiece = vi.mocked(getCurrentPiece);
|
||||
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources);
|
||||
const mockGetPieceCategories = vi.mocked(getPieceCategories);
|
||||
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces);
|
||||
const mockCreateSharedClone = vi.mocked(createSharedClone);
|
||||
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
|
||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||
const mockWarnMissingPieces = vi.mocked(warnMissingPieces);
|
||||
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -102,4 +115,45 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
|
||||
expect(autoPrCall).toBeDefined();
|
||||
expect(autoPrCall![1]).toBe(true);
|
||||
});
|
||||
|
||||
it('should warn only user-origin missing pieces during interactive selection', async () => {
|
||||
// Given: category selection is enabled and both builtin/user missing pieces exist
|
||||
mockGetCurrentPiece.mockReturnValue('default');
|
||||
mockLoadAllPiecesWithSources.mockReturnValue(new Map([
|
||||
['default', {
|
||||
source: 'builtin',
|
||||
config: {
|
||||
name: 'default',
|
||||
movements: [],
|
||||
initialMovement: 'start',
|
||||
maxIterations: 1,
|
||||
},
|
||||
}],
|
||||
]));
|
||||
mockGetPieceCategories.mockReturnValue({
|
||||
pieceCategories: [],
|
||||
builtinPieceCategories: [],
|
||||
userPieceCategories: [],
|
||||
showOthersCategory: true,
|
||||
othersCategoryName: 'Others',
|
||||
});
|
||||
mockBuildCategorizedPieces.mockReturnValue({
|
||||
categories: [],
|
||||
allPieces: new Map(),
|
||||
missingPieces: [
|
||||
{ categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' },
|
||||
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
|
||||
],
|
||||
});
|
||||
mockSelectPieceFromCategorizedPieces.mockResolvedValue('default');
|
||||
|
||||
// When
|
||||
const selected = await determinePiece('/project');
|
||||
|
||||
// Then
|
||||
expect(selected).toBe('default');
|
||||
expect(mockWarnMissingPieces).toHaveBeenCalledWith([
|
||||
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
90
src/__tests__/switchPiece.test.ts
Normal file
90
src/__tests__/switchPiece.test.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Tests for switchPiece behavior.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
listPieceEntries: vi.fn(() => []),
|
||||
loadAllPiecesWithSources: vi.fn(() => new Map()),
|
||||
getPieceCategories: vi.fn(() => null),
|
||||
buildCategorizedPieces: vi.fn(),
|
||||
loadPiece: vi.fn(() => null),
|
||||
getCurrentPiece: vi.fn(() => 'default'),
|
||||
setCurrentPiece: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../features/pieceSelection/index.js', () => ({
|
||||
warnMissingPieces: vi.fn(),
|
||||
selectPieceFromCategorizedPieces: vi.fn(),
|
||||
selectPieceFromEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
loadAllPiecesWithSources,
|
||||
getPieceCategories,
|
||||
buildCategorizedPieces,
|
||||
} from '../infra/config/index.js';
|
||||
import {
|
||||
warnMissingPieces,
|
||||
selectPieceFromCategorizedPieces,
|
||||
} from '../features/pieceSelection/index.js';
|
||||
import { switchPiece } from '../features/config/switchPiece.js';
|
||||
|
||||
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources);
|
||||
const mockGetPieceCategories = vi.mocked(getPieceCategories);
|
||||
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces);
|
||||
const mockWarnMissingPieces = vi.mocked(warnMissingPieces);
|
||||
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
|
||||
|
||||
describe('switchPiece', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should warn only user-origin missing pieces during interactive switch', async () => {
|
||||
// Given
|
||||
mockLoadAllPiecesWithSources.mockReturnValue(new Map([
|
||||
['default', {
|
||||
source: 'builtin',
|
||||
config: {
|
||||
name: 'default',
|
||||
movements: [],
|
||||
initialMovement: 'start',
|
||||
maxIterations: 1,
|
||||
},
|
||||
}],
|
||||
]));
|
||||
mockGetPieceCategories.mockReturnValue({
|
||||
pieceCategories: [],
|
||||
builtinPieceCategories: [],
|
||||
userPieceCategories: [],
|
||||
showOthersCategory: true,
|
||||
othersCategoryName: 'Others',
|
||||
});
|
||||
mockBuildCategorizedPieces.mockReturnValue({
|
||||
categories: [],
|
||||
allPieces: new Map(),
|
||||
missingPieces: [
|
||||
{ categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' },
|
||||
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
|
||||
],
|
||||
});
|
||||
mockSelectPieceFromCategorizedPieces.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const switched = await switchPiece('/project');
|
||||
|
||||
// Then
|
||||
expect(switched).toBe(false);
|
||||
expect(mockWarnMissingPieces).toHaveBeenCalledWith([
|
||||
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,18 +1,16 @@
|
||||
/**
|
||||
* Reset piece categories to builtin defaults.
|
||||
* Reset user piece categories overlay.
|
||||
*/
|
||||
|
||||
import { getDefaultCategoriesPath } from '../../infra/config/loaders/pieceCategories.js';
|
||||
import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js';
|
||||
import { header, success, info } from '../../shared/ui/index.js';
|
||||
|
||||
export async function resetCategoriesToDefault(): Promise<void> {
|
||||
header('Reset Categories');
|
||||
|
||||
const defaultPath = getDefaultCategoriesPath();
|
||||
resetPieceCategories(defaultPath);
|
||||
resetPieceCategories();
|
||||
|
||||
const userPath = getPieceCategoriesPath();
|
||||
success('Categories reset to builtin defaults.');
|
||||
success('User category overlay reset.');
|
||||
info(` ${userPath}`);
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise<bool
|
||||
selected = null;
|
||||
} else {
|
||||
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
|
||||
warnMissingPieces(categorized.missingPieces);
|
||||
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
|
||||
selected = await selectPieceFromCategorizedPieces(categorized, current);
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -68,7 +68,7 @@ async function selectPiece(cwd: string): Promise<string | null> {
|
||||
return DEFAULT_PIECE_NAME;
|
||||
}
|
||||
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
|
||||
warnMissingPieces(categorized.missingPieces);
|
||||
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
|
||||
return selectPieceFromCategorizedPieces(categorized, current);
|
||||
}
|
||||
return selectPieceWithDirectoryCategories(cwd);
|
||||
|
||||
@ -27,7 +27,6 @@ export {
|
||||
|
||||
export {
|
||||
getPieceCategoriesPath,
|
||||
ensureUserCategoriesFile,
|
||||
resetPieceCategories,
|
||||
} from './pieceCategories.js';
|
||||
|
||||
|
||||
@ -1,67 +1,38 @@
|
||||
/**
|
||||
* Piece categories file management.
|
||||
*
|
||||
* The categories file (~/.takt/preferences/piece-categories.yaml) uses the same
|
||||
* format as the builtin piece-categories.yaml (piece_categories key).
|
||||
* If the file doesn't exist, it's auto-copied from builtin defaults.
|
||||
* User category file is treated as overlay on top of builtin categories.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { getGlobalConfigDir } from '../paths.js';
|
||||
import { loadGlobalConfig } from './globalConfig.js';
|
||||
|
||||
const INITIAL_USER_CATEGORIES_CONTENT = 'piece_categories: {}\n';
|
||||
|
||||
function getDefaultPieceCategoriesPath(): string {
|
||||
return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml');
|
||||
}
|
||||
|
||||
/** Get the path to the user's piece categories file. */
|
||||
export function getPieceCategoriesPath(): string {
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
if (config.pieceCategoriesFile) {
|
||||
return config.pieceCategoriesFile;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, use default
|
||||
}
|
||||
return getDefaultPieceCategoriesPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user categories file exists by copying from builtin defaults.
|
||||
* Returns the path to the user categories file.
|
||||
* Reset user categories overlay file to initial content.
|
||||
*/
|
||||
export function ensureUserCategoriesFile(defaultCategoriesPath: string): string {
|
||||
const userPath = getPieceCategoriesPath();
|
||||
if (existsSync(userPath)) {
|
||||
return userPath;
|
||||
}
|
||||
|
||||
if (!existsSync(defaultCategoriesPath)) {
|
||||
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
|
||||
}
|
||||
|
||||
const dir = dirname(userPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
copyFileSync(defaultCategoriesPath, userPath);
|
||||
return userPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user categories file by overwriting with builtin defaults.
|
||||
*/
|
||||
export function resetPieceCategories(defaultCategoriesPath: string): void {
|
||||
if (!existsSync(defaultCategoriesPath)) {
|
||||
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
|
||||
}
|
||||
|
||||
export function resetPieceCategories(): void {
|
||||
const userPath = getPieceCategoriesPath();
|
||||
const dir = dirname(userPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
copyFileSync(defaultCategoriesPath, userPath);
|
||||
|
||||
writeFileSync(userPath, INITIAL_USER_CATEGORIES_CONTENT, 'utf-8');
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Piece category configuration loader and helpers.
|
||||
*
|
||||
* Categories are loaded from a single source: the user's piece-categories.yaml file.
|
||||
* If the file doesn't exist, it's auto-copied from builtin defaults.
|
||||
* Categories are built from 2 layers:
|
||||
* - builtin base categories (read-only)
|
||||
* - user overlay categories (optional)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
@ -10,7 +11,7 @@ import { join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { z } from 'zod/v4';
|
||||
import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
|
||||
import { ensureUserCategoriesFile } from '../global/pieceCategories.js';
|
||||
import { getPieceCategoriesPath } from '../global/pieceCategories.js';
|
||||
import { getLanguageResourcesDir } from '../../resources/index.js';
|
||||
import { listBuiltinPieceNames } from './pieceResolver.js';
|
||||
import type { PieceWithSource } from './pieceResolver.js';
|
||||
@ -29,6 +30,8 @@ export interface PieceCategoryNode {
|
||||
|
||||
export interface CategoryConfig {
|
||||
pieceCategories: PieceCategoryNode[];
|
||||
builtinPieceCategories: PieceCategoryNode[];
|
||||
userPieceCategories: PieceCategoryNode[];
|
||||
showOthersCategory: boolean;
|
||||
othersCategoryName: string;
|
||||
}
|
||||
@ -42,6 +45,7 @@ export interface CategorizedPieces {
|
||||
export interface MissingPiece {
|
||||
categoryPath: string[];
|
||||
pieceName: string;
|
||||
source: 'builtin' | 'user';
|
||||
}
|
||||
|
||||
interface RawCategoryConfig {
|
||||
@ -50,6 +54,19 @@ interface RawCategoryConfig {
|
||||
others_category_name?: string;
|
||||
}
|
||||
|
||||
interface ParsedCategoryNode {
|
||||
name: string;
|
||||
pieces: string[];
|
||||
hasPieces: boolean;
|
||||
children: ParsedCategoryNode[];
|
||||
}
|
||||
|
||||
interface ParsedCategoryConfig {
|
||||
pieceCategories?: ParsedCategoryNode[];
|
||||
showOthersCategory?: boolean;
|
||||
othersCategoryName?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@ -59,6 +76,7 @@ function parsePieces(raw: unknown, sourceLabel: string, path: string[]): string[
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error(`pieces must be an array in ${sourceLabel} at ${path.join(' > ')}`);
|
||||
}
|
||||
|
||||
const pieces: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'string' || item.trim().length === 0) {
|
||||
@ -74,13 +92,14 @@ function parseCategoryNode(
|
||||
raw: unknown,
|
||||
sourceLabel: string,
|
||||
path: string[],
|
||||
): PieceCategoryNode {
|
||||
): ParsedCategoryNode {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error(`category "${name}" must be an object in ${sourceLabel} at ${path.join(' > ')}`);
|
||||
}
|
||||
|
||||
const hasPieces = Object.prototype.hasOwnProperty.call(raw, 'pieces');
|
||||
const pieces = parsePieces(raw.pieces, sourceLabel, path);
|
||||
const children: PieceCategoryNode[] = [];
|
||||
const children: ParsedCategoryNode[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === 'pieces') continue;
|
||||
@ -90,59 +109,125 @@ function parseCategoryNode(
|
||||
children.push(parseCategoryNode(key, value, sourceLabel, [...path, key]));
|
||||
}
|
||||
|
||||
return { name, pieces, children };
|
||||
return { name, pieces, hasPieces, children };
|
||||
}
|
||||
|
||||
function parseCategoryTree(raw: unknown, sourceLabel: string): PieceCategoryNode[] {
|
||||
function parseCategoryTree(raw: unknown, sourceLabel: string): ParsedCategoryNode[] {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error(`piece_categories must be an object in ${sourceLabel}`);
|
||||
}
|
||||
const categories: PieceCategoryNode[] = [];
|
||||
|
||||
const categories: ParsedCategoryNode[] = [];
|
||||
for (const [name, value] of Object.entries(raw)) {
|
||||
categories.push(parseCategoryNode(name, value, sourceLabel, [name]));
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig | null {
|
||||
function parseCategoryConfig(raw: unknown, sourceLabel: string): ParsedCategoryConfig | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPieceCategories = Object.prototype.hasOwnProperty.call(raw, 'piece_categories');
|
||||
if (!hasPieceCategories) {
|
||||
const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig;
|
||||
const hasPieceCategories = Object.prototype.hasOwnProperty.call(parsed, 'piece_categories');
|
||||
|
||||
const result: ParsedCategoryConfig = {};
|
||||
if (hasPieceCategories) {
|
||||
if (!parsed.piece_categories) {
|
||||
throw new Error(`piece_categories must be an object in ${sourceLabel}`);
|
||||
}
|
||||
result.pieceCategories = parseCategoryTree(parsed.piece_categories, sourceLabel);
|
||||
}
|
||||
|
||||
if (parsed.show_others_category !== undefined) {
|
||||
result.showOthersCategory = parsed.show_others_category;
|
||||
}
|
||||
if (parsed.others_category_name !== undefined) {
|
||||
result.othersCategoryName = parsed.others_category_name;
|
||||
}
|
||||
|
||||
if (
|
||||
result.pieceCategories === undefined
|
||||
&& result.showOthersCategory === undefined
|
||||
&& result.othersCategoryName === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig;
|
||||
if (!parsed.piece_categories) {
|
||||
throw new Error(`piece_categories is required in ${sourceLabel}`);
|
||||
}
|
||||
|
||||
const showOthersCategory = parsed.show_others_category === undefined
|
||||
? true
|
||||
: parsed.show_others_category;
|
||||
|
||||
const othersCategoryName = parsed.others_category_name === undefined
|
||||
? 'Others'
|
||||
: parsed.others_category_name;
|
||||
|
||||
return {
|
||||
pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel),
|
||||
showOthersCategory,
|
||||
othersCategoryName,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadCategoryConfigFromPath(path: string, sourceLabel: string): CategoryConfig | null {
|
||||
function loadCategoryConfigFromPath(path: string, sourceLabel: string): ParsedCategoryConfig | null {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
const raw = parseYaml(content);
|
||||
return parseCategoryConfig(raw, sourceLabel);
|
||||
}
|
||||
|
||||
function convertParsedNodes(nodes: ParsedCategoryNode[]): PieceCategoryNode[] {
|
||||
return nodes.map((node) => ({
|
||||
name: node.name,
|
||||
pieces: node.pieces,
|
||||
children: convertParsedNodes(node.children),
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeCategoryNodes(baseNodes: ParsedCategoryNode[], overlayNodes: ParsedCategoryNode[]): ParsedCategoryNode[] {
|
||||
const overlayByName = new Map<string, ParsedCategoryNode>();
|
||||
for (const overlayNode of overlayNodes) {
|
||||
overlayByName.set(overlayNode.name, overlayNode);
|
||||
}
|
||||
|
||||
const merged: ParsedCategoryNode[] = [];
|
||||
for (const baseNode of baseNodes) {
|
||||
const overlayNode = overlayByName.get(baseNode.name);
|
||||
if (!overlayNode) {
|
||||
merged.push(baseNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
overlayByName.delete(baseNode.name);
|
||||
|
||||
const mergedNode: ParsedCategoryNode = {
|
||||
name: baseNode.name,
|
||||
pieces: overlayNode.hasPieces ? overlayNode.pieces : baseNode.pieces,
|
||||
hasPieces: baseNode.hasPieces || overlayNode.hasPieces,
|
||||
children: mergeCategoryNodes(baseNode.children, overlayNode.children),
|
||||
};
|
||||
merged.push(mergedNode);
|
||||
}
|
||||
|
||||
for (const overlayNode of overlayByName.values()) {
|
||||
merged.push(overlayNode);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveShowOthersCategory(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): boolean {
|
||||
if (userConfig?.showOthersCategory !== undefined) {
|
||||
return userConfig.showOthersCategory;
|
||||
}
|
||||
if (defaultConfig.showOthersCategory !== undefined) {
|
||||
return defaultConfig.showOthersCategory;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveOthersCategoryName(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): string {
|
||||
if (userConfig?.othersCategoryName !== undefined) {
|
||||
return userConfig.othersCategoryName;
|
||||
}
|
||||
if (defaultConfig.othersCategoryName !== undefined) {
|
||||
return defaultConfig.othersCategoryName;
|
||||
}
|
||||
return 'Others';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load default categories from builtin resource file.
|
||||
* Returns null if file doesn't exist or has no piece_categories.
|
||||
@ -150,7 +235,23 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category
|
||||
export function loadDefaultCategories(): CategoryConfig | null {
|
||||
const lang = getLanguage();
|
||||
const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
|
||||
return loadCategoryConfigFromPath(filePath, filePath);
|
||||
const parsed = loadCategoryConfigFromPath(filePath, filePath);
|
||||
|
||||
if (!parsed?.pieceCategories) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builtinPieceCategories = convertParsedNodes(parsed.pieceCategories);
|
||||
const showOthersCategory = parsed.showOthersCategory ?? true;
|
||||
const othersCategoryName = parsed.othersCategoryName ?? 'Others';
|
||||
|
||||
return {
|
||||
pieceCategories: builtinPieceCategories,
|
||||
builtinPieceCategories,
|
||||
userPieceCategories: [],
|
||||
showOthersCategory,
|
||||
othersCategoryName,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the path to the builtin default categories file. */
|
||||
@ -161,28 +262,49 @@ export function getDefaultCategoriesPath(): string {
|
||||
|
||||
/**
|
||||
* Get effective piece categories configuration.
|
||||
* Reads from user file (~/.takt/preferences/piece-categories.yaml).
|
||||
* Auto-copies from builtin defaults if user file doesn't exist.
|
||||
* Built from builtin categories and optional user overlay.
|
||||
*/
|
||||
export function getPieceCategories(): CategoryConfig | null {
|
||||
const defaultPath = getDefaultCategoriesPath();
|
||||
const userPath = ensureUserCategoriesFile(defaultPath);
|
||||
return loadCategoryConfigFromPath(userPath, userPath);
|
||||
const defaultConfig = loadCategoryConfigFromPath(defaultPath, defaultPath);
|
||||
if (!defaultConfig?.pieceCategories) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userPath = getPieceCategoriesPath();
|
||||
const userConfig = loadCategoryConfigFromPath(userPath, userPath);
|
||||
|
||||
const merged = userConfig?.pieceCategories
|
||||
? mergeCategoryNodes(defaultConfig.pieceCategories, userConfig.pieceCategories)
|
||||
: defaultConfig.pieceCategories;
|
||||
|
||||
const builtinPieceCategories = convertParsedNodes(defaultConfig.pieceCategories);
|
||||
const userPieceCategories = convertParsedNodes(userConfig?.pieceCategories ?? []);
|
||||
|
||||
return {
|
||||
pieceCategories: convertParsedNodes(merged),
|
||||
builtinPieceCategories,
|
||||
userPieceCategories,
|
||||
showOthersCategory: resolveShowOthersCategory(defaultConfig, userConfig),
|
||||
othersCategoryName: resolveOthersCategoryName(defaultConfig, userConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function collectMissingPieces(
|
||||
categories: PieceCategoryNode[],
|
||||
allPieces: Map<string, PieceWithSource>,
|
||||
ignorePieces: Set<string>,
|
||||
source: 'builtin' | 'user',
|
||||
): MissingPiece[] {
|
||||
const missing: MissingPiece[] = [];
|
||||
|
||||
const visit = (nodes: PieceCategoryNode[], path: string[]): void => {
|
||||
for (const node of nodes) {
|
||||
const nextPath = [...path, node.name];
|
||||
for (const pieceName of node.pieces) {
|
||||
if (ignorePieces.has(pieceName)) continue;
|
||||
if (!allPieces.has(pieceName)) {
|
||||
missing.push({ categoryPath: nextPath, pieceName });
|
||||
missing.push({ categoryPath: nextPath, pieceName, source });
|
||||
}
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
@ -235,7 +357,6 @@ function appendOthersCategory(
|
||||
return categories;
|
||||
}
|
||||
|
||||
// If a category with the same name already exists, merge uncategorized pieces into it
|
||||
const existingIndex = categories.findIndex((node) => node.name === othersCategoryName);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = categories[existingIndex]!;
|
||||
@ -250,8 +371,7 @@ function appendOthersCategory(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build categorized pieces map from configuration.
|
||||
* All pieces (user and builtin) are placed in a single category tree.
|
||||
* Build categorized pieces map from effective configuration.
|
||||
*/
|
||||
export function buildCategorizedPieces(
|
||||
allPieces: Map<string, PieceWithSource>,
|
||||
@ -268,18 +388,13 @@ export function buildCategorizedPieces(
|
||||
}
|
||||
}
|
||||
|
||||
const missingPieces = collectMissingPieces(
|
||||
config.pieceCategories,
|
||||
allPieces,
|
||||
ignoreMissing,
|
||||
);
|
||||
const missingPieces = [
|
||||
...collectMissingPieces(config.builtinPieceCategories, allPieces, ignoreMissing, 'builtin'),
|
||||
...collectMissingPieces(config.userPieceCategories, allPieces, ignoreMissing, 'user'),
|
||||
];
|
||||
|
||||
const categorized = new Set<string>();
|
||||
const categories = buildCategoryTree(
|
||||
config.pieceCategories,
|
||||
allPieces,
|
||||
categorized,
|
||||
);
|
||||
const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized);
|
||||
|
||||
const finalCategories = config.showOthersCategory
|
||||
? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user