diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 368c1d801..3210ece34 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -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')); + }); +});