github-issue-304-builtin (#309)

* takt: github-issue-304-builtin

* ピース選択UIから「also in」表示を削除
This commit is contained in:
nrs 2026-02-19 17:14:07 +09:00 committed by GitHub
parent 99aa22d250
commit 80a79683ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 65 deletions

View File

@ -61,6 +61,7 @@ vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => {
}); });
const { const {
BUILTIN_CATEGORY_NAME,
getPieceCategories, getPieceCategories,
loadDefaultCategories, loadDefaultCategories,
buildCategorizedPieces, buildCategorizedPieces,
@ -129,6 +130,7 @@ piece_categories:
{ name: 'Quick Start', pieces: ['default'], children: [] }, { name: 'Quick Start', pieces: ['default'], children: [] },
]); ]);
expect(config!.userPieceCategories).toEqual([]); expect(config!.userPieceCategories).toEqual([]);
expect(config!.hasUserCategories).toBe(false);
}); });
it('should use builtin categories when user overlay file is missing', () => { it('should use builtin categories when user overlay file is missing', () => {
@ -147,11 +149,12 @@ others_category_name: Others
{ name: 'Main', pieces: ['default'], children: [] }, { name: 'Main', pieces: ['default'], children: [] },
]); ]);
expect(config!.userPieceCategories).toEqual([]); expect(config!.userPieceCategories).toEqual([]);
expect(config!.hasUserCategories).toBe(false);
expect(config!.showOthersCategory).toBe(true); expect(config!.showOthersCategory).toBe(true);
expect(config!.othersCategoryName).toBe('Others'); expect(config!.othersCategoryName).toBe('Others');
}); });
it('should merge user overlay categories with builtin categories', () => { it('should separate user categories from builtin categories with builtin wrapper', () => {
writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` writeYaml(join(resourcesDir, 'piece-categories.yaml'), `
piece_categories: piece_categories:
Main: Main:
@ -184,15 +187,22 @@ others_category_name: Unclassified
const config = getPieceCategories(testDir); const config = getPieceCategories(testDir);
expect(config).not.toBeNull(); expect(config).not.toBeNull();
expect(config!.pieceCategories).toEqual([ expect(config!.pieceCategories).toEqual([
{ name: 'Main', pieces: ['custom'], children: [] },
{ name: 'My Team', pieces: ['team-flow'], children: [] },
{ {
name: 'Main', name: BUILTIN_CATEGORY_NAME,
pieces: ['custom'], pieces: [],
children: [ children: [
{ name: 'Child', pieces: ['nested'], children: [] }, {
name: 'Main',
pieces: ['default', 'coding'],
children: [
{ name: 'Child', pieces: ['nested'], children: [] },
],
},
{ name: 'Review', pieces: ['review-only', 'e2e-test'], children: [] },
], ],
}, },
{ name: 'Review', pieces: ['review-only', 'e2e-test'], children: [] },
{ name: 'My Team', pieces: ['team-flow'], children: [] },
]); ]);
expect(config!.builtinPieceCategories).toEqual([ expect(config!.builtinPieceCategories).toEqual([
{ {
@ -208,6 +218,7 @@ others_category_name: Unclassified
{ name: 'Main', pieces: ['custom'], children: [] }, { name: 'Main', pieces: ['custom'], children: [] },
{ name: 'My Team', pieces: ['team-flow'], children: [] }, { name: 'My Team', pieces: ['team-flow'], children: [] },
]); ]);
expect(config!.hasUserCategories).toBe(true);
expect(config!.showOthersCategory).toBe(false); expect(config!.showOthersCategory).toBe(false);
expect(config!.othersCategoryName).toBe('Unclassified'); expect(config!.othersCategoryName).toBe('Unclassified');
}); });
@ -259,6 +270,7 @@ others_category_name: Unclassified
{ name: 'Review', pieces: ['review-only'], children: [] }, { name: 'Review', pieces: ['review-only'], children: [] },
]); ]);
expect(config!.userPieceCategories).toEqual([]); expect(config!.userPieceCategories).toEqual([]);
expect(config!.hasUserCategories).toBe(false);
expect(config!.showOthersCategory).toBe(false); expect(config!.showOthersCategory).toBe(false);
expect(config!.othersCategoryName).toBe('Unclassified'); expect(config!.othersCategoryName).toBe('Unclassified');
}); });
@ -290,6 +302,7 @@ describe('buildCategorizedPieces', () => {
userPieceCategories: [ userPieceCategories: [
{ name: 'My Team', pieces: ['missing-user-piece'], children: [] }, { name: 'My Team', pieces: ['missing-user-piece'], children: [] },
], ],
hasUserCategories: true,
showOthersCategory: true, showOthersCategory: true,
othersCategoryName: 'Others', othersCategoryName: 'Others',
}; };
@ -322,6 +335,7 @@ describe('buildCategorizedPieces', () => {
{ name: 'Main', pieces: ['default'], children: [] }, { name: 'Main', pieces: ['default'], children: [] },
], ],
userPieceCategories: [], userPieceCategories: [],
hasUserCategories: false,
showOthersCategory: true, showOthersCategory: true,
othersCategoryName: 'Others', othersCategoryName: 'Others',
}; };
@ -346,6 +360,7 @@ describe('buildCategorizedPieces', () => {
{ name: 'Main', pieces: ['default'], children: [] }, { name: 'Main', pieces: ['default'], children: [] },
], ],
userPieceCategories: [], userPieceCategories: [],
hasUserCategories: false,
showOthersCategory: false, showOthersCategory: false,
othersCategoryName: 'Others', othersCategoryName: 'Others',
}; };
@ -356,6 +371,52 @@ describe('buildCategorizedPieces', () => {
]); ]);
}); });
it('should categorize pieces through builtin wrapper node', () => {
const allPieces = createPieceMap([
{ name: 'custom', source: 'user' },
{ name: 'default', source: 'builtin' },
{ name: 'review-only', source: 'builtin' },
{ name: 'extra', source: 'builtin' },
]);
const config = {
pieceCategories: [
{ name: 'My Team', pieces: ['custom'], children: [] },
{
name: BUILTIN_CATEGORY_NAME,
pieces: [],
children: [
{ name: 'Quick Start', pieces: ['default'], children: [] },
{ name: 'Review', pieces: ['review-only'], children: [] },
],
},
],
builtinPieceCategories: [
{ name: 'Quick Start', pieces: ['default'], children: [] },
{ name: 'Review', pieces: ['review-only'], children: [] },
],
userPieceCategories: [
{ name: 'My Team', pieces: ['custom'], children: [] },
],
hasUserCategories: true,
showOthersCategory: true,
othersCategoryName: 'Others',
};
const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([
{ name: 'My Team', pieces: ['custom'], children: [] },
{
name: BUILTIN_CATEGORY_NAME,
pieces: [],
children: [
{ name: 'Quick Start', pieces: ['default'], children: [] },
{ name: 'Review', pieces: ['review-only'], children: [] },
],
},
{ name: 'Others', pieces: ['extra'], children: [] },
]);
});
it('should find categories containing a piece', () => { it('should find categories containing a piece', () => {
const categories = [ const categories = [
{ name: 'A', pieces: ['shared'], children: [] }, { name: 'A', pieces: ['shared'], children: [] },

View File

@ -39,8 +39,8 @@ const configMock = vi.hoisted(() => ({
loadAllPiecesWithSources: vi.fn(), loadAllPiecesWithSources: vi.fn(),
getPieceCategories: vi.fn(), getPieceCategories: vi.fn(),
buildCategorizedPieces: vi.fn(), buildCategorizedPieces: vi.fn(),
getCurrentPiece: vi.fn(),
resolveConfigValue: vi.fn(), resolveConfigValue: vi.fn(),
findPieceCategories: vi.fn(() => []),
})); }));
vi.mock('../infra/config/index.js', () => configMock); vi.mock('../infra/config/index.js', () => configMock);
@ -242,6 +242,65 @@ describe('selectPieceFromCategorizedPieces', () => {
// Should NOT contain the parent category again // Should NOT contain the parent category again
expect(labels.some((l) => l.includes('Dev'))).toBe(false); expect(labels.some((l) => l.includes('Dev'))).toBe(false);
}); });
it('should navigate into builtin wrapper category and select a piece', async () => {
const categorized: CategorizedPieces = {
categories: [
{ name: 'My Team', pieces: ['custom'], children: [] },
{
name: 'builtin',
pieces: [],
children: [
{ name: 'Quick Start', pieces: ['default'], children: [] },
],
},
],
allPieces: createPieceMap([
{ name: 'custom', source: 'user' },
{ name: 'default', source: 'builtin' },
]),
missingPieces: [],
};
// Select builtin category → Quick Start subcategory → piece
selectOptionMock
.mockResolvedValueOnce('__custom_category__:builtin')
.mockResolvedValueOnce('__category__:Quick Start')
.mockResolvedValueOnce('default');
const selected = await selectPieceFromCategorizedPieces(categorized, '');
expect(selected).toBe('default');
expect(selectOptionMock).toHaveBeenCalledTimes(3);
});
it('should show builtin wrapper as a folder in top-level options', async () => {
const categorized: CategorizedPieces = {
categories: [
{ name: 'My Team', pieces: ['custom'], children: [] },
{
name: 'builtin',
pieces: [],
children: [
{ name: 'Quick Start', pieces: ['default'], children: [] },
],
},
],
allPieces: createPieceMap([
{ name: 'custom', source: 'user' },
{ name: 'default', source: 'builtin' },
]),
missingPieces: [],
};
selectOptionMock.mockResolvedValueOnce(null);
await selectPieceFromCategorizedPieces(categorized, '');
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
const labels = firstCallOptions.map((o) => o.label);
expect(labels.some((l) => l.includes('My Team'))).toBe(true);
expect(labels.some((l) => l.includes('builtin'))).toBe(true);
});
}); });
describe('selectPiece', () => { describe('selectPiece', () => {

View File

@ -11,7 +11,6 @@ import {
removeBookmark, removeBookmark,
} from '../../infra/config/global/index.js'; } from '../../infra/config/global/index.js';
import { import {
findPieceCategories,
listPieces, listPieces,
listPieceEntries, listPieceEntries,
loadAllPiecesWithSources, loadAllPiecesWithSources,
@ -160,8 +159,6 @@ function buildCategoryLevelOptions(
categories: PieceCategoryNode[], categories: PieceCategoryNode[],
pieces: string[], pieces: string[],
currentPiece: string, currentPiece: string,
rootCategories: PieceCategoryNode[],
currentPathLabel: string,
): { ): {
options: SelectionOption[]; options: SelectionOption[];
categoryMap: Map<string, PieceCategoryNode>; categoryMap: Map<string, PieceCategoryNode>;
@ -181,19 +178,7 @@ function buildCategoryLevelOptions(
for (const pieceName of pieces) { for (const pieceName of pieces) {
const isCurrent = pieceName === currentPiece; const isCurrent = pieceName === currentPiece;
const alsoIn = findPieceCategories(pieceName, rootCategories) const label = isCurrent ? `🎼 ${pieceName} (current)` : `🎼 ${pieceName}`;
.filter((path) => path !== currentPathLabel);
const alsoInLabel = alsoIn.length > 0 ? `also in ${alsoIn.join(', ')}` : '';
let label = `🎼 ${pieceName}`;
if (isCurrent && alsoInLabel) {
label = `🎼 ${pieceName} (current, ${alsoInLabel})`;
} else if (isCurrent) {
label = `🎼 ${pieceName} (current)`;
} else if (alsoInLabel) {
label = `🎼 ${pieceName} (${alsoInLabel})`;
}
options.push({ label, value: pieceName }); options.push({ label, value: pieceName });
} }
@ -223,8 +208,6 @@ async function selectPieceFromCategoryTree(
currentCategories, currentCategories,
currentPieces, currentPieces,
currentPiece, currentPiece,
categories,
currentPathLabel,
); );
if (options.length === 0) { if (options.length === 0) {

View File

@ -20,6 +20,7 @@ export {
} from './pieceLoader.js'; } from './pieceLoader.js';
export { export {
BUILTIN_CATEGORY_NAME,
loadDefaultCategories, loadDefaultCategories,
getDefaultCategoriesPath, getDefaultCategoriesPath,
getPieceCategories, getPieceCategories,

View File

@ -22,6 +22,8 @@ const CategoryConfigSchema = z.object({
others_category_name: z.string().min(1).optional(), others_category_name: z.string().min(1).optional(),
}).passthrough(); }).passthrough();
export const BUILTIN_CATEGORY_NAME = 'builtin';
export interface PieceCategoryNode { export interface PieceCategoryNode {
name: string; name: string;
pieces: string[]; pieces: string[];
@ -32,6 +34,7 @@ export interface CategoryConfig {
pieceCategories: PieceCategoryNode[]; pieceCategories: PieceCategoryNode[];
builtinPieceCategories: PieceCategoryNode[]; builtinPieceCategories: PieceCategoryNode[];
userPieceCategories: PieceCategoryNode[]; userPieceCategories: PieceCategoryNode[];
hasUserCategories: boolean;
showOthersCategory: boolean; showOthersCategory: boolean;
othersCategoryName: string; othersCategoryName: string;
} }
@ -57,7 +60,6 @@ interface RawCategoryConfig {
interface ParsedCategoryNode { interface ParsedCategoryNode {
name: string; name: string;
pieces: string[]; pieces: string[];
hasPieces: boolean;
children: ParsedCategoryNode[]; children: ParsedCategoryNode[];
} }
@ -97,7 +99,6 @@ function parseCategoryNode(
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: ParsedCategoryNode[] = []; const children: ParsedCategoryNode[] = [];
@ -109,7 +110,7 @@ function parseCategoryNode(
children.push(parseCategoryNode(key, value, sourceLabel, [...path, key])); children.push(parseCategoryNode(key, value, sourceLabel, [...path, key]));
} }
return { name, pieces, hasPieces, children }; return { name, pieces, children };
} }
function parseCategoryTree(raw: unknown, sourceLabel: string): ParsedCategoryNode[] { function parseCategoryTree(raw: unknown, sourceLabel: string): ParsedCategoryNode[] {
@ -176,38 +177,6 @@ function convertParsedNodes(nodes: ParsedCategoryNode[]): PieceCategoryNode[] {
})); }));
} }
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 { function resolveShowOthersCategory(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): boolean {
if (userConfig?.showOthersCategory !== undefined) { if (userConfig?.showOthersCategory !== undefined) {
return userConfig.showOthersCategory; return userConfig.showOthersCategory;
@ -249,6 +218,7 @@ export function loadDefaultCategories(cwd: string): CategoryConfig | null {
pieceCategories: builtinPieceCategories, pieceCategories: builtinPieceCategories,
builtinPieceCategories, builtinPieceCategories,
userPieceCategories: [], userPieceCategories: [],
hasUserCategories: false,
showOthersCategory, showOthersCategory,
othersCategoryName, othersCategoryName,
}; };
@ -260,6 +230,18 @@ export function getDefaultCategoriesPath(cwd: string): string {
return join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); return join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
} }
function buildSeparatedCategories(
userCategories: PieceCategoryNode[],
builtinCategories: PieceCategoryNode[],
): PieceCategoryNode[] {
const builtinWrapper: PieceCategoryNode = {
name: BUILTIN_CATEGORY_NAME,
pieces: [],
children: builtinCategories,
};
return [...userCategories, builtinWrapper];
}
/** /**
* Get effective piece categories configuration. * Get effective piece categories configuration.
* Built from builtin categories and optional user overlay. * Built from builtin categories and optional user overlay.
@ -274,17 +256,19 @@ export function getPieceCategories(cwd: string): CategoryConfig | null {
const userPath = getPieceCategoriesPath(cwd); const userPath = getPieceCategoriesPath(cwd);
const userConfig = loadCategoryConfigFromPath(userPath, userPath); const userConfig = loadCategoryConfigFromPath(userPath, userPath);
const merged = userConfig?.pieceCategories
? mergeCategoryNodes(defaultConfig.pieceCategories, userConfig.pieceCategories)
: defaultConfig.pieceCategories;
const builtinPieceCategories = convertParsedNodes(defaultConfig.pieceCategories); const builtinPieceCategories = convertParsedNodes(defaultConfig.pieceCategories);
const userPieceCategories = convertParsedNodes(userConfig?.pieceCategories ?? []); const userPieceCategories = convertParsedNodes(userConfig?.pieceCategories ?? []);
const hasUserCategories = userPieceCategories.length > 0;
const pieceCategories = hasUserCategories
? buildSeparatedCategories(userPieceCategories, builtinPieceCategories)
: builtinPieceCategories;
return { return {
pieceCategories: convertParsedNodes(merged), pieceCategories,
builtinPieceCategories, builtinPieceCategories,
userPieceCategories, userPieceCategories,
hasUserCategories,
showOthersCategory: resolveShowOthersCategory(defaultConfig, userConfig), showOthersCategory: resolveShowOthersCategory(defaultConfig, userConfig),
othersCategoryName: resolveOthersCategoryName(defaultConfig, userConfig), othersCategoryName: resolveOthersCategoryName(defaultConfig, userConfig),
}; };