276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
/**
|
||
* Unit tests for repertoire @scope resolution and facet resolution chain.
|
||
*
|
||
* Covers:
|
||
* A. @scope reference resolution (src/faceted-prompting/scope.ts)
|
||
* B. Facet resolution chain with package-local layer
|
||
* (src/infra/config/loaders/resource-resolver.ts)
|
||
*
|
||
* @scope resolution rules:
|
||
* "@{owner}/{repo}/{name}" in a facet field →
|
||
* {repertoireDir}/@{owner}/{repo}/facets/{type}/{name}.md
|
||
*
|
||
* Name constraints:
|
||
* owner: /^[a-z0-9][a-z0-9-]*$/ (lowercase only after normalization)
|
||
* repo: /^[a-z0-9][a-z0-9._-]*$/ (dot and underscore allowed)
|
||
* facet/piece name: /^[a-z0-9][a-z0-9-]*$/
|
||
*
|
||
* Facet resolution order (package piece):
|
||
* 1. package-local: {repertoireDir}/@{owner}/{repo}/facets/{type}/{facet}.md
|
||
* 2. project: .takt/facets/{type}/{facet}.md
|
||
* 3. user: ~/.takt/facets/{type}/{facet}.md
|
||
* 4. builtin: builtins/{lang}/facets/{type}/{facet}.md
|
||
*
|
||
* Facet resolution order (non-package piece):
|
||
* 1. project → 2. user → 3. builtin (package-local is NOT consulted)
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import {
|
||
isScopeRef,
|
||
parseScopeRef,
|
||
resolveScopeRef,
|
||
validateScopeOwner,
|
||
validateScopeRepo,
|
||
validateScopeFacetName,
|
||
} from '../faceted-prompting/scope.js';
|
||
import {
|
||
isPackagePiece,
|
||
buildCandidateDirsWithPackage,
|
||
resolveFacetPath,
|
||
} from '../infra/config/loaders/resource-resolver.js';
|
||
|
||
describe('@scope reference resolution', () => {
|
||
let tempDir: string;
|
||
|
||
beforeEach(() => {
|
||
tempDir = mkdtempSync(join(tmpdir(), 'takt-scope-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// U34: persona @scope 解決
|
||
// Input: "@nrslib/takt-ensemble-fixture/expert-coder" (personas field)
|
||
// Expect: resolves to {repertoireDir}/@nrslib/takt-ensemble-fixture/facets/personas/expert-coder.md
|
||
it('should resolve persona @scope reference to repertoire faceted path', () => {
|
||
const repertoireDir = tempDir;
|
||
const ref = '@nrslib/takt-ensemble-fixture/expert-coder';
|
||
const scopeRef = parseScopeRef(ref);
|
||
const resolved = resolveScopeRef(scopeRef, 'personas', repertoireDir);
|
||
|
||
const expected = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas', 'expert-coder.md');
|
||
expect(resolved).toBe(expected);
|
||
});
|
||
|
||
// U35: policy @scope 解決
|
||
// Input: "@nrslib/takt-ensemble-fixture/strict-coding" (policies field)
|
||
// Expect: resolves to {repertoireDir}/@nrslib/takt-ensemble-fixture/facets/policies/strict-coding.md
|
||
it('should resolve policy @scope reference to repertoire faceted path', () => {
|
||
const repertoireDir = tempDir;
|
||
const ref = '@nrslib/takt-ensemble-fixture/strict-coding';
|
||
const scopeRef = parseScopeRef(ref);
|
||
const resolved = resolveScopeRef(scopeRef, 'policies', repertoireDir);
|
||
|
||
const expected = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'policies', 'strict-coding.md');
|
||
expect(resolved).toBe(expected);
|
||
});
|
||
|
||
// U36: 大文字正規化
|
||
// Input: "@NrsLib/Takt-Ensemble-Fixture/expert-coder"
|
||
// Expect: owner and repo lowercase-normalized; name kept as-is (must already be lowercase per spec)
|
||
it('should normalize uppercase @scope references to lowercase before resolving', () => {
|
||
const repertoireDir = tempDir;
|
||
const ref = '@NrsLib/Takt-Ensemble-Fixture/expert-coder';
|
||
const scopeRef = parseScopeRef(ref);
|
||
|
||
// owner and repo are normalized to lowercase
|
||
expect(scopeRef.owner).toBe('nrslib');
|
||
expect(scopeRef.repo).toBe('takt-ensemble-fixture');
|
||
|
||
const resolved = resolveScopeRef(scopeRef, 'personas', repertoireDir);
|
||
const expected = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas', 'expert-coder.md');
|
||
expect(resolved).toBe(expected);
|
||
});
|
||
|
||
// U37: 存在しないスコープは解決失敗(ファイル不在のため undefined)
|
||
// Input: "@nonexistent/package/facet"
|
||
// Expect: resolveFacetPath returns undefined (file not found at resolved path)
|
||
it('should throw error when @scope reference points to non-existent package', () => {
|
||
const repertoireDir = tempDir;
|
||
const ref = '@nonexistent/package/facet';
|
||
|
||
// resolveFacetPath returns undefined when the @scope file does not exist
|
||
const result = resolveFacetPath(ref, 'personas', {
|
||
lang: 'en',
|
||
repertoireDir,
|
||
});
|
||
|
||
expect(result).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('@scope name constraints', () => {
|
||
// U38: owner 名前制約: 有効
|
||
// Input: "@nrslib"
|
||
// Expect: バリデーション通過
|
||
it('should accept valid owner name matching /^[a-z0-9][a-z0-9-]*$/', () => {
|
||
expect(() => validateScopeOwner('nrslib')).not.toThrow();
|
||
expect(() => validateScopeOwner('my-org')).not.toThrow();
|
||
expect(() => validateScopeOwner('org123')).not.toThrow();
|
||
});
|
||
|
||
// U39: owner 名前制約: 大文字は正規化後に有効
|
||
// Input: "@NrsLib" → normalized to "@nrslib"
|
||
// Expect: バリデーション通過(小文字正規化後)
|
||
it('should normalize uppercase owner to lowercase and pass validation', () => {
|
||
const ref = '@NrsLib/repo/facet';
|
||
const scopeRef = parseScopeRef(ref);
|
||
|
||
// parseScopeRef normalizes owner to lowercase
|
||
expect(scopeRef.owner).toBe('nrslib');
|
||
// lowercase owner passes validation
|
||
expect(() => validateScopeOwner(scopeRef.owner)).not.toThrow();
|
||
});
|
||
|
||
// U40: owner 名前制約: 無効(先頭ハイフン)
|
||
// Input: "@-invalid"
|
||
// Expect: バリデーションエラー
|
||
it('should reject owner name starting with a hyphen', () => {
|
||
expect(() => validateScopeOwner('-invalid')).toThrow();
|
||
});
|
||
|
||
// U41: repo 名前制約: ドット・アンダースコア許可
|
||
// Input: "@nrslib/my.repo_name"
|
||
// Expect: バリデーション通過
|
||
it('should accept repo name containing dots and underscores', () => {
|
||
expect(() => validateScopeRepo('my.repo_name')).not.toThrow();
|
||
expect(() => validateScopeRepo('repo.name')).not.toThrow();
|
||
expect(() => validateScopeRepo('repo_name')).not.toThrow();
|
||
});
|
||
|
||
// U42: facet 名前制約: 無効(ドット含む)
|
||
// Input: "@nrslib/repo/facet.name"
|
||
// Expect: バリデーションエラー
|
||
it('should reject facet name containing dots', () => {
|
||
expect(() => validateScopeFacetName('facet.name')).toThrow();
|
||
});
|
||
});
|
||
|
||
describe('facet resolution chain: package-local layer', () => {
|
||
let tempDir: string;
|
||
|
||
beforeEach(() => {
|
||
tempDir = mkdtempSync(join(tmpdir(), 'takt-facet-chain-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// U43: パッケージローカルが最優先
|
||
// Given: package-local, project, user, builtin の全層に同名ファセットが存在
|
||
// When: パッケージ内ピースからファセット解決
|
||
// Then: package-local 層のファセットが返る
|
||
it('should prefer package-local facet over project/user/builtin layers', () => {
|
||
const repertoireDir = join(tempDir, 'repertoire');
|
||
const packagePiecesDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'pieces');
|
||
const packageFacetDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas');
|
||
const projectFacetDir = join(tempDir, 'project', '.takt', 'facets', 'personas');
|
||
|
||
// Create both package-local and project facet files with the same name
|
||
mkdirSync(packageFacetDir, { recursive: true });
|
||
mkdirSync(packagePiecesDir, { recursive: true });
|
||
mkdirSync(projectFacetDir, { recursive: true });
|
||
writeFileSync(join(packageFacetDir, 'expert-coder.md'), '# Package-local expert');
|
||
writeFileSync(join(projectFacetDir, 'expert-coder.md'), '# Project expert');
|
||
|
||
const candidateDirs = buildCandidateDirsWithPackage('personas', {
|
||
lang: 'en',
|
||
pieceDir: packagePiecesDir,
|
||
repertoireDir,
|
||
projectDir: join(tempDir, 'project'),
|
||
});
|
||
|
||
// Package-local dir should come first
|
||
expect(candidateDirs[0]).toBe(packageFacetDir);
|
||
});
|
||
|
||
// U44: package-local にない場合は project に落ちる
|
||
// Given: package-local にファセットなし、project にあり
|
||
// When: ファセット解決
|
||
// Then: project 層のファセットが返る
|
||
it('should fall back to project facet when package-local does not have it', () => {
|
||
const repertoireDir = join(tempDir, 'repertoire');
|
||
const packagePiecesDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'pieces');
|
||
const projectFacetDir = join(tempDir, 'project', '.takt', 'facets', 'personas');
|
||
|
||
mkdirSync(packagePiecesDir, { recursive: true });
|
||
mkdirSync(projectFacetDir, { recursive: true });
|
||
// Only create project facet (no package-local facet)
|
||
const projectFacetFile = join(projectFacetDir, 'expert-coder.md');
|
||
writeFileSync(projectFacetFile, '# Project expert');
|
||
|
||
const resolved = resolveFacetPath('expert-coder', 'personas', {
|
||
lang: 'en',
|
||
pieceDir: packagePiecesDir,
|
||
repertoireDir,
|
||
projectDir: join(tempDir, 'project'),
|
||
});
|
||
|
||
expect(resolved).toBe(projectFacetFile);
|
||
});
|
||
|
||
// U45: 非パッケージピースは package-local を使わない
|
||
// Given: package-local にファセットあり、非パッケージピースから解決
|
||
// When: ファセット解決
|
||
// Then: package-local は無視。project → user → builtin の3層で解決
|
||
it('should not consult package-local layer for non-package pieces', () => {
|
||
const repertoireDir = join(tempDir, 'repertoire');
|
||
const packageFacetDir = join(repertoireDir, '@nrslib', 'takt-ensemble-fixture', 'facets', 'personas');
|
||
// Non-package pieceDir (not under repertoireDir)
|
||
const globalPiecesDir = join(tempDir, 'global-pieces');
|
||
|
||
mkdirSync(packageFacetDir, { recursive: true });
|
||
mkdirSync(globalPiecesDir, { recursive: true });
|
||
writeFileSync(join(packageFacetDir, 'expert-coder.md'), '# Package-local expert');
|
||
|
||
const candidateDirs = buildCandidateDirsWithPackage('personas', {
|
||
lang: 'en',
|
||
pieceDir: globalPiecesDir,
|
||
repertoireDir,
|
||
});
|
||
|
||
// Package-local dir should NOT be in candidates for non-package pieces
|
||
expect(candidateDirs.some((d) => d.includes('@nrslib'))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('package piece detection', () => {
|
||
// U46: パッケージ所属は pieceDir パスから判定
|
||
// Given: pieceDir が {repertoireDir}/@nrslib/repo/pieces/ 配下
|
||
// When: isPackagePiece(pieceDir) 呼び出し
|
||
// Then: true が返る
|
||
it('should return true for pieceDir under repertoire/@scope/repo/pieces/', () => {
|
||
const repertoireDir = '/home/user/.takt/repertoire';
|
||
const pieceDir = '/home/user/.takt/repertoire/@nrslib/takt-ensemble-fixture/pieces';
|
||
|
||
expect(isPackagePiece(pieceDir, repertoireDir)).toBe(true);
|
||
});
|
||
|
||
// U47: 非パッケージ pieceDir は false
|
||
// Given: pieceDir が ~/.takt/pieces/ 配下
|
||
// When: isPackagePiece(pieceDir) 呼び出し
|
||
// Then: false が返る
|
||
it('should return false for pieceDir under global pieces directory', () => {
|
||
const repertoireDir = '/home/user/.takt/repertoire';
|
||
const pieceDir = '/home/user/.takt/pieces';
|
||
|
||
expect(isPackagePiece(pieceDir, repertoireDir)).toBe(false);
|
||
});
|
||
});
|