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 { 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 { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
@ -19,6 +19,8 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
getLanguage: () => 'en',
|
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 {
|
return {
|
||||||
ensureUserCategoriesFile: () => pathsState.userCategoriesPath,
|
...original,
|
||||||
|
getPieceCategoriesPath: () => pathsState.userCategoriesPath,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,72 +78,15 @@ describe('piece category config loading', () => {
|
|||||||
|
|
||||||
mkdirSync(resourcesDir, { recursive: true });
|
mkdirSync(resourcesDir, { recursive: true });
|
||||||
pathsState.resourcesDir = resourcesDir;
|
pathsState.resourcesDir = resourcesDir;
|
||||||
|
pathsState.userCategoriesPath = join(testDir, 'user-piece-categories.yaml');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load categories from user file (auto-copied from default)', () => {
|
it('should return null when builtin categories file is missing', () => {
|
||||||
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;
|
|
||||||
|
|
||||||
const config = getPieceCategories();
|
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();
|
expect(config).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -156,19 +103,151 @@ piece_categories:
|
|||||||
expect(config!.pieceCategories).toEqual([
|
expect(config!.pieceCategories).toEqual([
|
||||||
{ name: 'Quick Start', pieces: ['default'], children: [] },
|
{ 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', () => {
|
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([
|
const allPieces = createPieceMap([
|
||||||
{ name: 'a', source: 'user' },
|
{ name: 'custom', source: 'user' },
|
||||||
{ name: 'b', source: 'user' },
|
{ name: 'nested', source: 'builtin' },
|
||||||
{ name: 'c', source: 'builtin' },
|
{ name: 'team-flow', source: 'user' },
|
||||||
]);
|
]);
|
||||||
const config = {
|
const config = {
|
||||||
pieceCategories: [
|
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,
|
showOthersCategory: true,
|
||||||
othersCategoryName: 'Others',
|
othersCategoryName: 'Others',
|
||||||
@ -176,30 +255,19 @@ describe('buildCategorizedPieces', () => {
|
|||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
const categorized = buildCategorizedPieces(allPieces, config);
|
||||||
expect(categorized.categories).toEqual([
|
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([
|
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', () => {
|
it('should append Others category for uncategorized pieces', () => {
|
||||||
const allPieces = createPieceMap([
|
const allPieces = createPieceMap([
|
||||||
{ name: 'default', source: 'builtin' },
|
{ name: 'default', source: 'builtin' },
|
||||||
@ -209,6 +277,10 @@ describe('buildCategorizedPieces', () => {
|
|||||||
pieceCategories: [
|
pieceCategories: [
|
||||||
{ name: 'Main', pieces: ['default'], children: [] },
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
],
|
],
|
||||||
|
builtinPieceCategories: [
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
],
|
||||||
|
userPieceCategories: [],
|
||||||
showOthersCategory: true,
|
showOthersCategory: true,
|
||||||
othersCategoryName: 'Others',
|
othersCategoryName: 'Others',
|
||||||
};
|
};
|
||||||
@ -220,28 +292,6 @@ describe('buildCategorizedPieces', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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: [] },
|
|
||||||
],
|
|
||||||
showOthersCategory: true,
|
|
||||||
othersCategoryName: 'Others',
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
|
||||||
expect(categorized.categories).toEqual([
|
|
||||||
{ name: 'Main', pieces: ['default'], children: [] },
|
|
||||||
{ name: 'Others', pieces: ['extra', 'user-piece'], children: [] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not append Others when showOthersCategory is false', () => {
|
it('should not append Others when showOthersCategory is false', () => {
|
||||||
const allPieces = createPieceMap([
|
const allPieces = createPieceMap([
|
||||||
{ name: 'default', source: 'builtin' },
|
{ name: 'default', source: 'builtin' },
|
||||||
@ -251,6 +301,10 @@ describe('buildCategorizedPieces', () => {
|
|||||||
pieceCategories: [
|
pieceCategories: [
|
||||||
{ name: 'Main', pieces: ['default'], children: [] },
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
],
|
],
|
||||||
|
builtinPieceCategories: [
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
],
|
||||||
|
userPieceCategories: [],
|
||||||
showOthersCategory: false,
|
showOthersCategory: false,
|
||||||
othersCategoryName: 'Others',
|
othersCategoryName: 'Others',
|
||||||
};
|
};
|
||||||
@ -286,25 +340,3 @@ describe('buildCategorizedPieces', () => {
|
|||||||
expect(paths).toEqual(['Parent / Child']);
|
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 { 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 { 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 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 mockCreateSharedClone = vi.mocked(createSharedClone);
|
||||||
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
|
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
|
||||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||||
|
const mockWarnMissingPieces = vi.mocked(warnMissingPieces);
|
||||||
|
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -102,4 +115,45 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
|
|||||||
expect(autoPrCall).toBeDefined();
|
expect(autoPrCall).toBeDefined();
|
||||||
expect(autoPrCall![1]).toBe(true);
|
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 { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js';
|
||||||
import { header, success, info } from '../../shared/ui/index.js';
|
import { header, success, info } from '../../shared/ui/index.js';
|
||||||
|
|
||||||
export async function resetCategoriesToDefault(): Promise<void> {
|
export async function resetCategoriesToDefault(): Promise<void> {
|
||||||
header('Reset Categories');
|
header('Reset Categories');
|
||||||
|
|
||||||
const defaultPath = getDefaultCategoriesPath();
|
resetPieceCategories();
|
||||||
resetPieceCategories(defaultPath);
|
|
||||||
|
|
||||||
const userPath = getPieceCategoriesPath();
|
const userPath = getPieceCategoriesPath();
|
||||||
success('Categories reset to builtin defaults.');
|
success('User category overlay reset.');
|
||||||
info(` ${userPath}`);
|
info(` ${userPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise<bool
|
|||||||
selected = null;
|
selected = null;
|
||||||
} else {
|
} else {
|
||||||
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
|
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
|
||||||
warnMissingPieces(categorized.missingPieces);
|
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
|
||||||
selected = await selectPieceFromCategorizedPieces(categorized, current);
|
selected = await selectPieceFromCategorizedPieces(categorized, current);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -68,7 +68,7 @@ async function selectPiece(cwd: string): Promise<string | null> {
|
|||||||
return DEFAULT_PIECE_NAME;
|
return DEFAULT_PIECE_NAME;
|
||||||
}
|
}
|
||||||
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
|
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
|
||||||
warnMissingPieces(categorized.missingPieces);
|
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
|
||||||
return selectPieceFromCategorizedPieces(categorized, current);
|
return selectPieceFromCategorizedPieces(categorized, current);
|
||||||
}
|
}
|
||||||
return selectPieceWithDirectoryCategories(cwd);
|
return selectPieceWithDirectoryCategories(cwd);
|
||||||
|
|||||||
@ -27,7 +27,6 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
getPieceCategoriesPath,
|
getPieceCategoriesPath,
|
||||||
ensureUserCategoriesFile,
|
|
||||||
resetPieceCategories,
|
resetPieceCategories,
|
||||||
} from './pieceCategories.js';
|
} from './pieceCategories.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,67 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Piece categories file management.
|
* Piece categories file management.
|
||||||
*
|
*
|
||||||
* The categories file (~/.takt/preferences/piece-categories.yaml) uses the same
|
* User category file is treated as overlay on top of builtin categories.
|
||||||
* format as the builtin piece-categories.yaml (piece_categories key).
|
|
||||||
* If the file doesn't exist, it's auto-copied from builtin defaults.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { getGlobalConfigDir } from '../paths.js';
|
import { getGlobalConfigDir } from '../paths.js';
|
||||||
import { loadGlobalConfig } from './globalConfig.js';
|
import { loadGlobalConfig } from './globalConfig.js';
|
||||||
|
|
||||||
|
const INITIAL_USER_CATEGORIES_CONTENT = 'piece_categories: {}\n';
|
||||||
|
|
||||||
function getDefaultPieceCategoriesPath(): string {
|
function getDefaultPieceCategoriesPath(): string {
|
||||||
return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml');
|
return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the path to the user's piece categories file. */
|
/** Get the path to the user's piece categories file. */
|
||||||
export function getPieceCategoriesPath(): string {
|
export function getPieceCategoriesPath(): string {
|
||||||
try {
|
const config = loadGlobalConfig();
|
||||||
const config = loadGlobalConfig();
|
if (config.pieceCategoriesFile) {
|
||||||
if (config.pieceCategoriesFile) {
|
return config.pieceCategoriesFile;
|
||||||
return config.pieceCategoriesFile;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors, use default
|
|
||||||
}
|
}
|
||||||
return getDefaultPieceCategoriesPath();
|
return getDefaultPieceCategoriesPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure user categories file exists by copying from builtin defaults.
|
* Reset user categories overlay file to initial content.
|
||||||
* Returns the path to the user categories file.
|
|
||||||
*/
|
*/
|
||||||
export function ensureUserCategoriesFile(defaultCategoriesPath: string): string {
|
export function resetPieceCategories(): void {
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPath = getPieceCategoriesPath();
|
const userPath = getPieceCategoriesPath();
|
||||||
const dir = dirname(userPath);
|
const dir = dirname(userPath);
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true });
|
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.
|
* Piece category configuration loader and helpers.
|
||||||
*
|
*
|
||||||
* Categories are loaded from a single source: the user's piece-categories.yaml file.
|
* Categories are built from 2 layers:
|
||||||
* If the file doesn't exist, it's auto-copied from builtin defaults.
|
* - builtin base categories (read-only)
|
||||||
|
* - user overlay categories (optional)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
@ -10,7 +11,7 @@ import { join } from 'node:path';
|
|||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
|
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 { getLanguageResourcesDir } from '../../resources/index.js';
|
||||||
import { listBuiltinPieceNames } from './pieceResolver.js';
|
import { listBuiltinPieceNames } from './pieceResolver.js';
|
||||||
import type { PieceWithSource } from './pieceResolver.js';
|
import type { PieceWithSource } from './pieceResolver.js';
|
||||||
@ -29,6 +30,8 @@ export interface PieceCategoryNode {
|
|||||||
|
|
||||||
export interface CategoryConfig {
|
export interface CategoryConfig {
|
||||||
pieceCategories: PieceCategoryNode[];
|
pieceCategories: PieceCategoryNode[];
|
||||||
|
builtinPieceCategories: PieceCategoryNode[];
|
||||||
|
userPieceCategories: PieceCategoryNode[];
|
||||||
showOthersCategory: boolean;
|
showOthersCategory: boolean;
|
||||||
othersCategoryName: string;
|
othersCategoryName: string;
|
||||||
}
|
}
|
||||||
@ -42,6 +45,7 @@ export interface CategorizedPieces {
|
|||||||
export interface MissingPiece {
|
export interface MissingPiece {
|
||||||
categoryPath: string[];
|
categoryPath: string[];
|
||||||
pieceName: string;
|
pieceName: string;
|
||||||
|
source: 'builtin' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawCategoryConfig {
|
interface RawCategoryConfig {
|
||||||
@ -50,6 +54,19 @@ interface RawCategoryConfig {
|
|||||||
others_category_name?: string;
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
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)) {
|
if (!Array.isArray(raw)) {
|
||||||
throw new Error(`pieces must be an array in ${sourceLabel} at ${path.join(' > ')}`);
|
throw new Error(`pieces must be an array in ${sourceLabel} at ${path.join(' > ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieces: string[] = [];
|
const pieces: string[] = [];
|
||||||
for (const item of raw) {
|
for (const item of raw) {
|
||||||
if (typeof item !== 'string' || item.trim().length === 0) {
|
if (typeof item !== 'string' || item.trim().length === 0) {
|
||||||
@ -74,13 +92,14 @@ function parseCategoryNode(
|
|||||||
raw: unknown,
|
raw: unknown,
|
||||||
sourceLabel: string,
|
sourceLabel: string,
|
||||||
path: string[],
|
path: string[],
|
||||||
): PieceCategoryNode {
|
): ParsedCategoryNode {
|
||||||
if (!isRecord(raw)) {
|
if (!isRecord(raw)) {
|
||||||
throw new Error(`category "${name}" must be an object in ${sourceLabel} at ${path.join(' > ')}`);
|
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 pieces = parsePieces(raw.pieces, sourceLabel, path);
|
||||||
const children: PieceCategoryNode[] = [];
|
const children: ParsedCategoryNode[] = [];
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(raw)) {
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
if (key === 'pieces') continue;
|
if (key === 'pieces') continue;
|
||||||
@ -90,59 +109,125 @@ function parseCategoryNode(
|
|||||||
children.push(parseCategoryNode(key, value, sourceLabel, [...path, key]));
|
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)) {
|
if (!isRecord(raw)) {
|
||||||
throw new Error(`piece_categories must be an object in ${sourceLabel}`);
|
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)) {
|
for (const [name, value] of Object.entries(raw)) {
|
||||||
categories.push(parseCategoryNode(name, value, sourceLabel, [name]));
|
categories.push(parseCategoryNode(name, value, sourceLabel, [name]));
|
||||||
}
|
}
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig | null {
|
function parseCategoryConfig(raw: unknown, sourceLabel: string): ParsedCategoryConfig | null {
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!raw || typeof raw !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPieceCategories = Object.prototype.hasOwnProperty.call(raw, 'piece_categories');
|
const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig;
|
||||||
if (!hasPieceCategories) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig;
|
return result;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCategoryConfigFromPath(path: string, sourceLabel: string): CategoryConfig | null {
|
function loadCategoryConfigFromPath(path: string, sourceLabel: string): ParsedCategoryConfig | null {
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = readFileSync(path, 'utf-8');
|
const content = readFileSync(path, 'utf-8');
|
||||||
const raw = parseYaml(content);
|
const raw = parseYaml(content);
|
||||||
return parseCategoryConfig(raw, sourceLabel);
|
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.
|
* Load default categories from builtin resource file.
|
||||||
* Returns null if file doesn't exist or has no piece_categories.
|
* 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 {
|
export function loadDefaultCategories(): CategoryConfig | null {
|
||||||
const lang = getLanguage();
|
const lang = getLanguage();
|
||||||
const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
|
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. */
|
/** Get the path to the builtin default categories file. */
|
||||||
@ -161,28 +262,49 @@ export function getDefaultCategoriesPath(): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get effective piece categories configuration.
|
* Get effective piece categories configuration.
|
||||||
* Reads from user file (~/.takt/preferences/piece-categories.yaml).
|
* Built from builtin categories and optional user overlay.
|
||||||
* Auto-copies from builtin defaults if user file doesn't exist.
|
|
||||||
*/
|
*/
|
||||||
export function getPieceCategories(): CategoryConfig | null {
|
export function getPieceCategories(): CategoryConfig | null {
|
||||||
const defaultPath = getDefaultCategoriesPath();
|
const defaultPath = getDefaultCategoriesPath();
|
||||||
const userPath = ensureUserCategoriesFile(defaultPath);
|
const defaultConfig = loadCategoryConfigFromPath(defaultPath, defaultPath);
|
||||||
return loadCategoryConfigFromPath(userPath, userPath);
|
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(
|
function collectMissingPieces(
|
||||||
categories: PieceCategoryNode[],
|
categories: PieceCategoryNode[],
|
||||||
allPieces: Map<string, PieceWithSource>,
|
allPieces: Map<string, PieceWithSource>,
|
||||||
ignorePieces: Set<string>,
|
ignorePieces: Set<string>,
|
||||||
|
source: 'builtin' | 'user',
|
||||||
): MissingPiece[] {
|
): MissingPiece[] {
|
||||||
const missing: MissingPiece[] = [];
|
const missing: MissingPiece[] = [];
|
||||||
|
|
||||||
const visit = (nodes: PieceCategoryNode[], path: string[]): void => {
|
const visit = (nodes: PieceCategoryNode[], path: string[]): void => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const nextPath = [...path, node.name];
|
const nextPath = [...path, node.name];
|
||||||
for (const pieceName of node.pieces) {
|
for (const pieceName of node.pieces) {
|
||||||
if (ignorePieces.has(pieceName)) continue;
|
if (ignorePieces.has(pieceName)) continue;
|
||||||
if (!allPieces.has(pieceName)) {
|
if (!allPieces.has(pieceName)) {
|
||||||
missing.push({ categoryPath: nextPath, pieceName });
|
missing.push({ categoryPath: nextPath, pieceName, source });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node.children.length > 0) {
|
if (node.children.length > 0) {
|
||||||
@ -235,7 +357,6 @@ function appendOthersCategory(
|
|||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a category with the same name already exists, merge uncategorized pieces into it
|
|
||||||
const existingIndex = categories.findIndex((node) => node.name === othersCategoryName);
|
const existingIndex = categories.findIndex((node) => node.name === othersCategoryName);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
const existing = categories[existingIndex]!;
|
const existing = categories[existingIndex]!;
|
||||||
@ -250,8 +371,7 @@ function appendOthersCategory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build categorized pieces map from configuration.
|
* Build categorized pieces map from effective configuration.
|
||||||
* All pieces (user and builtin) are placed in a single category tree.
|
|
||||||
*/
|
*/
|
||||||
export function buildCategorizedPieces(
|
export function buildCategorizedPieces(
|
||||||
allPieces: Map<string, PieceWithSource>,
|
allPieces: Map<string, PieceWithSource>,
|
||||||
@ -268,18 +388,13 @@ export function buildCategorizedPieces(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingPieces = collectMissingPieces(
|
const missingPieces = [
|
||||||
config.pieceCategories,
|
...collectMissingPieces(config.builtinPieceCategories, allPieces, ignoreMissing, 'builtin'),
|
||||||
allPieces,
|
...collectMissingPieces(config.userPieceCategories, allPieces, ignoreMissing, 'user'),
|
||||||
ignoreMissing,
|
];
|
||||||
);
|
|
||||||
|
|
||||||
const categorized = new Set<string>();
|
const categorized = new Set<string>();
|
||||||
const categories = buildCategoryTree(
|
const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized);
|
||||||
config.pieceCategories,
|
|
||||||
allPieces,
|
|
||||||
categorized,
|
|
||||||
);
|
|
||||||
|
|
||||||
const finalCategories = config.showOthersCategory
|
const finalCategories = config.showOthersCategory
|
||||||
? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
|
? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user