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:
parent
a1592c984b
commit
6ed5b01507
4 changed files with 668 additions and 0 deletions
173
src/resources/extensions/gsd/tests/projection-regression.test.ts
Normal file
173
src/resources/extensions/gsd/tests/projection-regression.test.ts
Normal 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 ⬜');
|
||||
});
|
||||
155
src/resources/extensions/gsd/tests/reopen-slice.test.ts
Normal file
155
src/resources/extensions/gsd/tests/reopen-slice.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
165
src/resources/extensions/gsd/tests/reopen-task.test.ts
Normal file
165
src/resources/extensions/gsd/tests/reopen-task.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
175
src/resources/extensions/gsd/tests/unit-ownership.test.ts
Normal file
175
src/resources/extensions/gsd/tests/unit-ownership.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue