feat/wp-schedule-and-board/main #31

Merged
admin merged 17 commits from feat/wp-schedule-and-board/main into master 2026-09-17 06:09:42 +00:00
Showing only changes of commit be477a4391 - Show all commits
+186
View File
@@ -0,0 +1,186 @@
/**
* 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。
* 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { runScript, tmpRoot } from './helpers/run-script.js';
/** 把一份排程計畫寫成暫存檔,回傳路徑 */
function writePlan(plan) {
mkdirSync(tmpRoot, { recursive: true });
const dir = mkdtempSync(join(tmpRoot, 'plan-'));
const path = join(dir, 'plan.json');
writeFileSync(path, JSON.stringify(plan));
return path;
}
const run = (plan, args = []) =>
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]);
/** 依 index 取出算出來的截止日 */
const dueOf = (json) =>
Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate]));
test('沒有相依時,每顆各自從起始日加上自己的人天', async () => {
const { code, json } = await run({
startDate: '2026-09-21',
workPackages: [
{ index: 1, title: '甲', days: 2 },
{ index: 2, title: '乙', days: 5 },
],
});
assert.equal(code, 0);
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
});
test('有先決時,截止日從先決的截止日往後算', async () => {
const { json } = await run({
startDate: '2026-09-21',
workPackages: [
{ index: 1, title: '甲', days: 2 },
{ index: 2, title: '乙', days: 3, depends: [1] },
],
});
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
});
test('多個先決時取最晚的那一個當起點', async () => {
const { json } = await run({
startDate: '2026-09-21',
workPackages: [
{ index: 1, title: '甲', days: 2 },
{ index: 2, title: '乙', days: 9 },
{ index: 3, title: '丙', days: 1, depends: [1, 2] },
],
});
assert.equal(dueOf(json)[3], '2026-10-01', '要等最晚的乙做完才開始');
});
test('任一工作包的截止日都不早於它的先決', async () => {
const { json } = await run({
startDate: '2026-09-21',
workPackages: [
{ index: 1, title: '甲', days: 3 },
{ index: 2, title: '乙', days: 1, depends: [1] },
{ index: 3, title: '丙', days: 1, depends: [2] },
{ index: 4, title: '丁', days: 4, depends: [1] },
{ index: 5, title: '戊', days: 2, depends: [3, 4] },
],
});
const due = dueOf(json);
const depends = { 2: [1], 3: [2], 4: [1], 5: [3, 4] };
for (const [index, prerequisites] of Object.entries(depends)) {
for (const p of prerequisites) {
assert.ok(due[index] > due[p], `#${index} 的截止日不該早於先決 #${p}`);
}
}
});
test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => {
const { json } = await run({
startDate: '2026-09-21',
workPackages: [
{ index: 3, title: '丙', days: 1, depends: [2] },
{ index: 1, title: '甲', days: 1 },
{ index: 2, title: '乙', days: 1, depends: [1] },
],
});
assert.deepEqual(json.data.order, [1, 2, 3]);
});
test('相依成環時中止,並指出環上的成員', async () => {
const { code, json } = await run({
startDate: '2026-09-21',
workPackages: [
{ index: 1, title: '甲', days: 1, depends: [2] },
{ index: 2, title: '乙', days: 1, depends: [1] },
],
});
assert.equal(code, 1);
assert.equal(json.error.code, 'CYCLE_DETECTED');
assert.match(json.error.message, /1/);
assert.match(json.error.message, /2/);
});
test('先決指向不存在的工作包時中止', async () => {
const { code, json } = await run({
startDate: '2026-09-21',
workPackages: [{ index: 1, title: '甲', days: 1, depends: [99] }],
});
assert.equal(code, 1);
assert.equal(json.error.code, 'UNKNOWN_DEPENDENCY');
assert.match(json.error.message, /99/);
});
test('人天必須是正數', async () => {
const { json } = await run({
startDate: '2026-09-21',
workPackages: [{ index: 1, title: '甲', days: 0 }],
});
assert.equal(json.error.code, 'BAD_PLAN');
assert.match(json.error.message, /days/);
});
test('起始日格式不對時中止', async () => {
const { json } = await run({
startDate: '2026/09/21',
workPackages: [{ index: 1, title: '甲', days: 1 }],
});
assert.equal(json.error.code, 'BAD_PLAN');
assert.match(json.error.message, /startDate/);
});
test('計畫檔不存在時帶著路徑失敗', async () => {
const { code, json } = await runScript('schedule.js', ['--plan-file', '/nonexistent/plan.json']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PLAN_FILE_MISSING');
assert.match(json.error.message, /\/nonexistent\/plan\.json/);
});
test('計畫檔不是合法 JSON 時中止', async () => {
mkdirSync(tmpRoot, { recursive: true });
const dir = mkdtempSync(join(tmpRoot, 'plan-'));
const path = join(dir, 'plan.json');
writeFileSync(path, '{ 這不是 JSON');
const { json } = await runScript('schedule.js', ['--plan-file', path]);
assert.equal(json.error.code, 'BAD_PLAN');
});
test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => {
const { json } = await run({
startDate: '2026-09-21',
workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }],
});
assert.deepEqual(json.data.schedule[0], {
index: 7,
title: '建立抽取契約',
days: 3,
dueDate: '2026-09-24',
});
});
test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => {
const { code } = await runScript(
'schedule.js',
['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })],
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
);
assert.equal(code, 0);
});