test(01-02): add phase utility, string helper, and roadmap tests

- escapeRegex: special chars, empty string, plain string
- generateSlugInternal: kebab-case, special chars, null/empty
- normalizePhaseName: padding, letters, decimals, multi-level
- comparePhaseNum: numeric sort, letters, decimals, equality
- safeReadFile: existing file, missing file
- pathExistsInternal: existing, non-existing, absolute paths
- getMilestoneInfo: version extraction, defaults
- searchPhaseInDir: finding dirs, plans/summaries, incomplete,
  research/context detection, slug generation
- findPhaseInternal: current phases, archived milestones, null
- getRoadmapPhaseInternal: export exists (REG-02), goal extraction,
  colon-outside-bold format, missing roadmap, section text
This commit is contained in:
Ethan Hurst
2026-02-25 11:32:02 +10:00
parent abeacf0290
commit a5fd00278d

View File

@@ -247,3 +247,394 @@ describe('resolveModelInternal', () => {
});
});
});
// ─── escapeRegex ───────────────────────────────────────────────────────────────
describe('escapeRegex', () => {
test('escapes dots', () => {
assert.strictEqual(escapeRegex('file.txt'), 'file\\.txt');
});
test('escapes all special regex characters', () => {
const input = '1.0 (alpha) [test] {ok} $100 ^start end$ a+b a*b a?b pipe|or back\\slash';
const result = escapeRegex(input);
// Verify each special char is escaped
assert.ok(result.includes('\\.'));
assert.ok(result.includes('\\('));
assert.ok(result.includes('\\)'));
assert.ok(result.includes('\\['));
assert.ok(result.includes('\\]'));
assert.ok(result.includes('\\{'));
assert.ok(result.includes('\\}'));
assert.ok(result.includes('\\$'));
assert.ok(result.includes('\\^'));
assert.ok(result.includes('\\+'));
assert.ok(result.includes('\\*'));
assert.ok(result.includes('\\?'));
assert.ok(result.includes('\\|'));
assert.ok(result.includes('\\\\'));
});
test('handles empty string', () => {
assert.strictEqual(escapeRegex(''), '');
});
test('returns plain string unchanged', () => {
assert.strictEqual(escapeRegex('hello'), 'hello');
});
});
// ─── generateSlugInternal ──────────────────────────────────────────────────────
describe('generateSlugInternal', () => {
test('converts text to lowercase kebab-case', () => {
assert.strictEqual(generateSlugInternal('Hello World'), 'hello-world');
});
test('removes special characters', () => {
assert.strictEqual(generateSlugInternal('core.cjs Tests!'), 'core-cjs-tests');
});
test('trims leading and trailing hyphens', () => {
assert.strictEqual(generateSlugInternal('---hello---'), 'hello');
});
test('returns null for null input', () => {
assert.strictEqual(generateSlugInternal(null), null);
});
test('returns null for empty string', () => {
assert.strictEqual(generateSlugInternal(''), null);
});
});
// ─── normalizePhaseName ────────────────────────────────────────────────────────
describe('normalizePhaseName', () => {
test('pads single digit', () => {
assert.strictEqual(normalizePhaseName('1'), '01');
});
test('preserves double digit', () => {
assert.strictEqual(normalizePhaseName('12'), '12');
});
test('handles letter suffix', () => {
assert.strictEqual(normalizePhaseName('1A'), '01A');
});
test('handles decimal phases', () => {
assert.strictEqual(normalizePhaseName('2.1'), '02.1');
});
test('handles multi-level decimals', () => {
assert.strictEqual(normalizePhaseName('1.2.3'), '01.2.3');
});
test('returns non-matching input unchanged', () => {
assert.strictEqual(normalizePhaseName('abc'), 'abc');
});
});
// ─── comparePhaseNum ───────────────────────────────────────────────────────────
describe('comparePhaseNum', () => {
test('sorts integer phases numerically', () => {
assert.ok(comparePhaseNum('1', '2') < 0);
assert.ok(comparePhaseNum('10', '2') > 0);
});
test('sorts letter suffixes', () => {
assert.ok(comparePhaseNum('12', '12A') < 0);
assert.ok(comparePhaseNum('12A', '12B') < 0);
});
test('sorts decimal phases', () => {
assert.ok(comparePhaseNum('2', '2.1') < 0);
assert.ok(comparePhaseNum('2.1', '2.2') < 0);
});
test('handles multi-level decimals', () => {
assert.ok(comparePhaseNum('1.1', '1.1.2') < 0);
assert.ok(comparePhaseNum('1.1.2', '1.2') < 0);
});
test('returns 0 for equal phases', () => {
assert.strictEqual(comparePhaseNum('1', '1'), 0);
assert.strictEqual(comparePhaseNum('2.1', '2.1'), 0);
});
});
// ─── safeReadFile ──────────────────────────────────────────────────────────────
describe('safeReadFile', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('reads existing file', () => {
const filePath = path.join(tmpDir, 'test.txt');
fs.writeFileSync(filePath, 'hello world');
assert.strictEqual(safeReadFile(filePath), 'hello world');
});
test('returns null for missing file', () => {
assert.strictEqual(safeReadFile('/nonexistent/path/file.txt'), null);
});
});
// ─── pathExistsInternal ────────────────────────────────────────────────────────
describe('pathExistsInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('returns true for existing path', () => {
assert.strictEqual(pathExistsInternal(tmpDir, '.planning'), true);
});
test('returns false for non-existing path', () => {
assert.strictEqual(pathExistsInternal(tmpDir, 'nonexistent'), false);
});
test('handles absolute paths', () => {
assert.strictEqual(pathExistsInternal(tmpDir, tmpDir), true);
});
});
// ─── getMilestoneInfo ──────────────────────────────────────────────────────────
describe('getMilestoneInfo', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('extracts version and name from roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Roadmap v1.2: My Cool Project\n\nSome content'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.2');
assert.strictEqual(info.name, 'My Cool Project');
});
test('returns defaults when roadmap missing', () => {
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.0');
assert.strictEqual(info.name, 'milestone');
});
});
// ─── searchPhaseInDir ──────────────────────────────────────────────────────────
describe('searchPhaseInDir', () => {
let tmpDir;
let phasesDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
phasesDir = path.join(tmpDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('finds phase directory by normalized prefix', () => {
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
assert.strictEqual(result.phase_name, 'foundation');
});
test('returns plans and summaries', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.ok(result.plans.includes('01-01-PLAN.md'));
assert.ok(result.summaries.includes('01-01-SUMMARY.md'));
assert.strictEqual(result.incomplete_plans.length, 0);
});
test('identifies incomplete plans', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.incomplete_plans.length, 1);
assert.ok(result.incomplete_plans.includes('01-02-PLAN.md'));
});
test('detects research and context files', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-RESEARCH.md'), '# Research');
fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# Context');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.has_research, true);
assert.strictEqual(result.has_context, true);
});
test('returns null when phase not found', () => {
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '99');
assert.strictEqual(result, null);
});
test('generates phase_slug from directory name', () => {
fs.mkdirSync(path.join(phasesDir, '01-core-cjs-tests'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.phase_slug, 'core-cjs-tests');
});
});
// ─── findPhaseInternal ─────────────────────────────────────────────────────────
describe('findPhaseInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('finds phase in current phases directory', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'));
const result = findPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
});
test('returns null for non-existent phase', () => {
const result = findPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null for null phase', () => {
const result = findPhaseInternal(tmpDir, null);
assert.strictEqual(result, null);
});
test('searches archived milestones when not in current', () => {
// Create archived milestone structure (no current phase match)
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation');
fs.mkdirSync(archiveDir, { recursive: true });
const result = findPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.archived, 'v1.0');
});
});
// ─── getRoadmapPhaseInternal ───────────────────────────────────────────────────
describe('getRoadmapPhaseInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Bug: getRoadmapPhaseInternal was missing from module.exports
test('is exported from core.cjs (REG-02)', () => {
assert.strictEqual(typeof getRoadmapPhaseInternal, 'function');
// Also verify it works with a real roadmap (note: goal regex expects **Goal:** with colon inside bold)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal:** Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, 'Build the base');
});
test('extracts phase name and goal from roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 2: API Layer\n**Goal:** Create REST endpoints\n**Depends on**: Phase 1\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '2');
assert.strictEqual(result.phase_name, 'API Layer');
assert.strictEqual(result.goal, 'Create REST endpoints');
});
test('returns null goal when Goal uses colon-outside-bold format', () => {
// Actual ROADMAP.md uses **Goal**: (colon outside bold) which the regex does not match
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, null);
});
test('returns null when roadmap missing', () => {
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
test('returns null when phase not in roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null for null phase number', () => {
const result = getRoadmapPhaseInternal(tmpDir, null);
assert.strictEqual(result, null);
});
test('extracts full section text', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n**Requirements**: TEST-01\nSome details here\n\n### Phase 2: API\n**Goal**: REST\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.ok(result.section.includes('Phase 1: Foundation'));
assert.ok(result.section.includes('Some details here'));
// Should not include Phase 2 content
assert.ok(!result.section.includes('Phase 2: API'));
});
});