From d48babc9623c33e5c9e9320301580fbf39d0d0f7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 13:55:29 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=8E=92=E7=A8=8B):=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=E6=8B=93=E6=92=B2=E9=A0=86=E5=BA=8F=E3=80=81=E5=A4=9A=E9=87=8D?= =?UTF-8?q?=E5=85=88=E6=B1=BA=E8=88=87=E6=88=90=E7=92=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 除了逐例比對日期,另有一條測試直接驗「所有先決關係都滿足」這個不變式, 而不是只比對硬編的日期字串——不變式壞掉時它會指出是哪一對。 Co-Authored-By: Claude Opus 5 (1M context) --- test/schedule.test.js | 186 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 test/schedule.test.js diff --git a/test/schedule.test.js b/test/schedule.test.js new file mode 100644 index 0000000..6277f01 --- /dev/null +++ b/test/schedule.test.js @@ -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); +});