test(gsd): add tests for v3 reopen tools, unit ownership, and projection regression

37 new tests across 4 files covering v3 features that had no test
coverage, plus regression tests for the projection bug fixes:

- reopen-task.test.ts (8): success path (reset to pending, no side
  effects on other tasks) + 6 failure paths (empty ID, missing
  milestone/slice/task, closed parents, already pending)
- reopen-slice.test.ts (7): success path (reset slice + all tasks,
  single task variant) + 5 failure paths (empty ID, missing entities,
  closed milestone, already in_progress)
- unit-ownership.test.ts (14): key builders, claim/get/release CRUD,
  overwrite semantics, multi-unit independence, checkOwnership
  (opt-in when no actorName, null when unclaimed, pass when owner
  matches, error when mismatch)
- projection-regression.test.ts (8): renderPlanContent checkbox for
  "complete"/"done"/"pending" status + mixed, parsePlan-compatible
  bold format, renderRoadmapContent status icons

All 37 tests pass. Zero regressions.
This commit is contained in:
Jeremy McSpadden 2026-03-25 00:51:55 -05:00 committed by Lex Christopherson
parent a1592c984b
commit 6ed5b01507
4 changed files with 668 additions and 0 deletions

View file

@ -0,0 +1,173 @@
// GSD — projection renderer regression tests
// Verifies that "done" vs "complete" status mismatch doesn't recur.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderPlanContent, renderRoadmapContent } from '../workflow-projections.ts';
import type { SliceRow, TaskRow } from '../gsd-db.ts';
// ─── Helpers ─────────────────────────────────────────────────────────────
function makeSliceRow(overrides?: Partial<SliceRow>): SliceRow {
return {
milestone_id: 'M001',
id: 'S01',
title: 'Test Slice',
status: 'pending',
risk: 'medium',
depends: [],
demo: 'Demo.',
created_at: '2026-01-01T00:00:00Z',
completed_at: null,
full_summary_md: '',
full_uat_md: '',
goal: 'Test goal',
success_criteria: '',
proof_level: '',
integration_closure: '',
observability_impact: '',
sequence: 0,
replan_triggered_at: null,
...overrides,
};
}
function makeTaskRow(overrides?: Partial<TaskRow>): TaskRow {
return {
milestone_id: 'M001',
slice_id: 'S01',
id: 'T01',
title: 'Test Task',
status: 'pending',
one_liner: '',
narrative: '',
verification_result: '',
duration: '',
completed_at: null,
blocker_discovered: false,
deviations: '',
known_issues: '',
key_files: [],
key_decisions: [],
full_summary_md: '',
description: 'Test description',
estimate: '30m',
files: ['src/test.ts'],
verify: 'npm test',
inputs: [],
expected_output: [],
observability_impact: '',
sequence: 0,
...overrides,
};
}
function makeMilestoneRow() {
return {
id: 'M001',
title: 'Test Milestone',
status: 'active',
depends_on: [],
created_at: '2026-01-01T00:00:00Z',
completed_at: null,
vision: 'Test vision',
success_criteria: [],
key_risks: [],
proof_strategy: [],
verification_contract: '',
verification_integration: '',
verification_operational: '',
verification_uat: '',
definition_of_done: [],
requirement_coverage: '',
boundary_map_markdown: '',
};
}
// ─── renderPlanContent: checkbox regression ──────────────────────────────
test('renderPlanContent: task with status "complete" renders [x] checkbox', () => {
const slice = makeSliceRow();
const tasks = [makeTaskRow({ id: 'T01', status: 'complete', title: 'Completed Task' })];
const content = renderPlanContent(slice, tasks);
assert.match(content, /\[x\]\s+\*\*T01:/, 'complete task should have [x] checkbox');
});
test('renderPlanContent: task with status "done" renders [x] checkbox', () => {
const slice = makeSliceRow();
const tasks = [makeTaskRow({ id: 'T01', status: 'done', title: 'Done Task' })];
const content = renderPlanContent(slice, tasks);
assert.match(content, /\[x\]\s+\*\*T01:/, 'done task should have [x] checkbox');
});
test('renderPlanContent: task with status "pending" renders [ ] checkbox', () => {
const slice = makeSliceRow();
const tasks = [makeTaskRow({ id: 'T01', status: 'pending', title: 'Pending Task' })];
const content = renderPlanContent(slice, tasks);
assert.match(content, /\[ \]\s+\*\*T01:/, 'pending task should have [ ] checkbox');
});
test('renderPlanContent: mixed statuses render correct checkboxes', () => {
const slice = makeSliceRow();
const tasks = [
makeTaskRow({ id: 'T01', status: 'complete', title: 'Done One' }),
makeTaskRow({ id: 'T02', status: 'pending', title: 'Pending One' }),
makeTaskRow({ id: 'T03', status: 'done', title: 'Done Two' }),
];
const content = renderPlanContent(slice, tasks);
assert.match(content, /\[x\]\s+\*\*T01:/, 'T01 (complete) should be checked');
assert.match(content, /\[ \]\s+\*\*T02:/, 'T02 (pending) should be unchecked');
assert.match(content, /\[x\]\s+\*\*T03:/, 'T03 (done) should be checked');
});
// ─── renderPlanContent: format regression (parsePlan compatibility) ──────
test('renderPlanContent: format matches parsePlan regex **ID: title**', () => {
const slice = makeSliceRow();
const tasks = [makeTaskRow({ id: 'T01', status: 'pending', title: 'My Task' })];
const content = renderPlanContent(slice, tasks);
// parsePlan expects: **T01: My Task** (both ID and title inside bold)
// NOT: **T01:** My Task (only ID in bold)
assert.match(content, /\*\*T01: My Task\*\*/, 'ID and title should both be inside bold markers');
});
// ─── renderRoadmapContent: status regression ─────────────────────────────
test('renderRoadmapContent: slice with status "complete" shows ✅', () => {
const milestone = makeMilestoneRow();
const slices = [makeSliceRow({ id: 'S01', status: 'complete' })];
const content = renderRoadmapContent(milestone, slices);
assert.ok(content.includes('✅'), 'complete slice should show ✅');
});
test('renderRoadmapContent: slice with status "done" shows ✅', () => {
const milestone = makeMilestoneRow();
const slices = [makeSliceRow({ id: 'S01', status: 'done' })];
const content = renderRoadmapContent(milestone, slices);
assert.ok(content.includes('✅'), 'done slice should show ✅');
});
test('renderRoadmapContent: slice with status "pending" shows ⬜', () => {
const milestone = makeMilestoneRow();
const slices = [makeSliceRow({ id: 'S01', status: 'pending' })];
const content = renderRoadmapContent(milestone, slices);
assert.ok(content.includes('⬜'), 'pending slice should show ⬜');
});

View file

@ -0,0 +1,155 @@
// GSD — reopen-slice handler tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
getSlice,
getSliceTasks,
} from '../gsd-db.ts';
import { handleReopenSlice } from '../tools/reopen-slice.ts';
function makeTmpBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-reopen-slice-'));
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { closeDatabase(); } catch { /* noop */ }
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
}
function seedCompleteSlice(): void {
insertMilestone({ id: 'M001', title: 'Test Milestone', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'complete' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task One', status: 'complete' });
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Task Two', status: 'complete' });
}
// ─── Success path ────────────────────────────────────────────────────────
test('handleReopenSlice: resets a complete slice to in_progress and all tasks to pending', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedCompleteSlice();
const result = await handleReopenSlice({
milestoneId: 'M001',
sliceId: 'S01',
reason: 'need to redo after requirements change',
}, base);
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
assert.equal(result.sliceId, 'S01');
assert.equal(result.tasksReset, 2, 'should report 2 tasks reset');
const slice = getSlice('M001', 'S01');
assert.ok(slice, 'slice should still exist');
assert.equal(slice!.status, 'in_progress', 'slice status should be in_progress');
const tasks = getSliceTasks('M001', 'S01');
assert.equal(tasks.length, 2, 'both tasks should still exist');
assert.ok(tasks.every(t => t.status === 'pending'), 'all tasks should be pending');
} finally {
cleanup(base);
}
});
test('handleReopenSlice: works with a single task', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' });
const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S01' }, base);
assert.ok(!('error' in result));
assert.equal(result.tasksReset, 1);
} finally {
cleanup(base);
}
});
// ─── Failure paths ───────────────────────────────────────────────────────
test('handleReopenSlice: rejects empty sliceId', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: '' }, base);
assert.ok('error' in result);
assert.match(result.error, /sliceId/);
} finally {
cleanup(base);
}
});
test('handleReopenSlice: rejects non-existent milestone', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
const result = await handleReopenSlice({ milestoneId: 'M999', sliceId: 'S01' }, base);
assert.ok('error' in result);
assert.match(result.error, /milestone not found/);
} finally {
cleanup(base);
}
});
test('handleReopenSlice: rejects slice in a closed milestone', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Done', status: 'complete' });
insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' });
const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S01' }, base);
assert.ok('error' in result);
assert.match(result.error, /closed milestone/);
} finally {
cleanup(base);
}
});
test('handleReopenSlice: rejects reopening a slice that is not complete', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Active', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', status: 'in_progress' });
const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S01' }, base);
assert.ok('error' in result);
assert.match(result.error, /not complete/);
} finally {
cleanup(base);
}
});
test('handleReopenSlice: rejects non-existent slice', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Active', status: 'active' });
const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S99' }, base);
assert.ok('error' in result);
assert.match(result.error, /slice not found/);
} finally {
cleanup(base);
}
});

View file

@ -0,0 +1,165 @@
// GSD — reopen-task handler tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
getTask,
} from '../gsd-db.ts';
import { handleReopenTask } from '../tools/reopen-task.ts';
function makeTmpBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-reopen-task-'));
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { closeDatabase(); } catch { /* noop */ }
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
}
function seedCompleteTask(): void {
insertMilestone({ id: 'M001', title: 'Test Milestone', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'in_progress' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task One', status: 'complete' });
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Task Two', status: 'pending' });
}
// ─── Success path ────────────────────────────────────────────────────────
test('handleReopenTask: resets a complete task to pending', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedCompleteTask();
const result = await handleReopenTask({
milestoneId: 'M001',
sliceId: 'S01',
taskId: 'T01',
reason: 'verification failed after merge',
}, base);
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
assert.equal(result.taskId, 'T01');
const task = getTask('M001', 'S01', 'T01');
assert.ok(task, 'task should still exist');
assert.equal(task!.status, 'pending', 'task status should be reset to pending');
} finally {
cleanup(base);
}
});
test('handleReopenTask: does not affect other tasks in the slice', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedCompleteTask();
await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T01' }, base);
const t02 = getTask('M001', 'S01', 'T02');
assert.ok(t02, 'T02 should still exist');
assert.equal(t02!.status, 'pending', 'T02 status should be unchanged');
} finally {
cleanup(base);
}
});
// ─── Failure paths ───────────────────────────────────────────────────────
test('handleReopenTask: rejects empty taskId', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: '' }, base);
assert.ok('error' in result);
assert.match(result.error, /taskId/);
} finally {
cleanup(base);
}
});
test('handleReopenTask: rejects non-existent milestone', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
const result = await handleReopenTask({ milestoneId: 'M999', sliceId: 'S01', taskId: 'T01' }, base);
assert.ok('error' in result);
assert.match(result.error, /milestone not found/);
} finally {
cleanup(base);
}
});
test('handleReopenTask: rejects task in a closed milestone', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Done', status: 'complete' });
insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' });
const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T01' }, base);
assert.ok('error' in result);
assert.match(result.error, /closed milestone/);
} finally {
cleanup(base);
}
});
test('handleReopenTask: rejects task inside a closed slice', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Active', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' });
const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T01' }, base);
assert.ok('error' in result);
assert.match(result.error, /closed slice/);
} finally {
cleanup(base);
}
});
test('handleReopenTask: rejects reopening a task that is not complete', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedCompleteTask();
const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T02' }, base);
assert.ok('error' in result);
assert.match(result.error, /not complete/);
} finally {
cleanup(base);
}
});
test('handleReopenTask: rejects non-existent task', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Active', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', status: 'in_progress' });
const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T99' }, base);
assert.ok('error' in result);
assert.match(result.error, /task not found/);
} finally {
cleanup(base);
}
});

View file

@ -0,0 +1,175 @@
// GSD — unit-ownership tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
claimUnit,
releaseUnit,
getOwner,
checkOwnership,
taskUnitKey,
sliceUnitKey,
} from '../unit-ownership.ts';
function makeTmpBase(): string {
return mkdtempSync(join(tmpdir(), 'gsd-ownership-'));
}
function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
}
// ─── Key builders ────────────────────────────────────────────────────────
test('taskUnitKey: builds correct key', () => {
assert.equal(taskUnitKey('M001', 'S01', 'T01'), 'M001/S01/T01');
});
test('sliceUnitKey: builds correct key', () => {
assert.equal(sliceUnitKey('M001', 'S01'), 'M001/S01');
});
// ─── Claim / get / release ───────────────────────────────────────────────
test('claimUnit: creates claim file and records agent', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'executor-01');
assert.ok(existsSync(join(base, '.gsd', 'unit-claims.json')), 'claim file should exist');
assert.equal(getOwner(base, 'M001/S01/T01'), 'executor-01');
} finally {
cleanup(base);
}
});
test('claimUnit: overwrites existing claim (last writer wins)', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'executor-01');
claimUnit(base, 'M001/S01/T01', 'executor-02');
assert.equal(getOwner(base, 'M001/S01/T01'), 'executor-02');
} finally {
cleanup(base);
}
});
test('claimUnit: multiple units can be claimed independently', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
claimUnit(base, 'M001/S01/T02', 'agent-b');
assert.equal(getOwner(base, 'M001/S01/T01'), 'agent-a');
assert.equal(getOwner(base, 'M001/S01/T02'), 'agent-b');
} finally {
cleanup(base);
}
});
test('getOwner: returns null when no claim file exists', () => {
const base = makeTmpBase();
try {
assert.equal(getOwner(base, 'M001/S01/T01'), null);
} finally {
cleanup(base);
}
});
test('getOwner: returns null for unclaimed unit', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
assert.equal(getOwner(base, 'M001/S01/T99'), null);
} finally {
cleanup(base);
}
});
test('releaseUnit: removes claim', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
releaseUnit(base, 'M001/S01/T01');
assert.equal(getOwner(base, 'M001/S01/T01'), null);
} finally {
cleanup(base);
}
});
test('releaseUnit: no-op for non-existent claim', () => {
const base = makeTmpBase();
try {
// Should not throw
releaseUnit(base, 'M001/S01/T01');
} finally {
cleanup(base);
}
});
// ─── checkOwnership ──────────────────────────────────────────────────────
test('checkOwnership: returns null when no actorName provided (opt-in)', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
// No actorName → ownership not enforced
assert.equal(checkOwnership(base, 'M001/S01/T01', undefined), null);
} finally {
cleanup(base);
}
});
test('checkOwnership: returns null when no claim file exists', () => {
const base = makeTmpBase();
try {
assert.equal(checkOwnership(base, 'M001/S01/T01', 'agent-a'), null);
} finally {
cleanup(base);
}
});
test('checkOwnership: returns null when unit is unclaimed', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
// Different unit, unclaimed
assert.equal(checkOwnership(base, 'M001/S01/T99', 'agent-b'), null);
} finally {
cleanup(base);
}
});
test('checkOwnership: returns null when actor matches owner', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
assert.equal(checkOwnership(base, 'M001/S01/T01', 'agent-a'), null);
} finally {
cleanup(base);
}
});
test('checkOwnership: returns error string when actor does not match owner', () => {
const base = makeTmpBase();
try {
claimUnit(base, 'M001/S01/T01', 'agent-a');
const err = checkOwnership(base, 'M001/S01/T01', 'agent-b');
assert.ok(err !== null, 'should return error');
assert.match(err!, /owned by agent-a/);
assert.match(err!, /not agent-b/);
} finally {
cleanup(base);
}
});