From e98d1d0cbea4107008a8d63a0d961b73581c5fe4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:43:02 +0800 Subject: [PATCH] =?UTF-8?q?test(schedule):=20=E8=A3=9C=E4=B8=8A=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E6=8E=92=E7=A8=8B=E8=88=87=20CPM=20PERT=20?= =?UTF-8?q?=E8=A6=86=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆蓋週末、年度假日、日曆 fallback、三點估算、關鍵路徑與循環相依。 --- test/schedule.test.js | 125 ++++++++++++++++++++++++++++++++---------- 1 file changed, 95 insertions(+), 30 deletions(-) diff --git a/test/schedule.test.js b/test/schedule.test.js index fbbec8d..75e9388 100644 --- a/test/schedule.test.js +++ b/test/schedule.test.js @@ -1,6 +1,6 @@ /** - * 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。 - * 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。 + * 排程計算:日期只落在工作日,並從同一份計畫產出 PERT 與 CPM。 + * 腳本對外會抓政府日曆;測試用 data URL 固定資料,fallback 測試才改用失效網址。 */ import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,6 +8,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { runScript, tmpRoot } from './helpers/run-script.js'; +/** 測試用的 2026 年日曆:12 月 25 日是已知假日,其他日期只由週末判斷。 */ +const CALENDAR = `data:text/csv,${encodeURIComponent( + '西元日期,星期,是否放假,備註\n20261224,四,0,\n20261225,五,2,測試假日\n20261228,一,0,', +)}`; + /** 把一份排程計畫寫成暫存檔,回傳路徑 */ function writePlan(plan) { mkdirSync(tmpRoot, { recursive: true }); @@ -17,14 +22,17 @@ function writePlan(plan) { return path; } -const run = (plan, args = []) => - runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]); +const run = (plan, args = [], opts = {}) => + runScript('schedule.js', ['--plan-file', writePlan(plan), ...args], { + ...opts, + env: { TEA_SDLC_CALENDAR_URL: CALENDAR, ...opts.env }, + }); /** 依 index 取出算出來的截止日 */ const dueOf = (json) => Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate])); -test('沒有相依時,每顆各自從起始日加上自己的人天', async () => { +test('沒有相依時,每顆各自從起始日加上工作日人天', async () => { const { code, json } = await run({ startDate: '2026-09-21', workPackages: [ @@ -34,10 +42,10 @@ test('沒有相依時,每顆各自從起始日加上自己的人天', async () }); assert.equal(code, 0); - assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' }); + assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' }); }); -test('有先決時,截止日從先決的截止日往後算', async () => { +test('有先決時,截止日從先決的工作日截止日往後算', async () => { const { json } = await run({ startDate: '2026-09-21', workPackages: [ @@ -46,7 +54,7 @@ test('有先決時,截止日從先決的截止日往後算', async () => { ], }); - assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' }); + assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' }); }); test('多個先決時取最晚的那一個當起點', async () => { @@ -59,28 +67,31 @@ test('多個先決時取最晚的那一個當起點', async () => { ], }); - assert.equal(dueOf(json)[3], '2026-10-01', '要等最晚的乙做完才開始'); + assert.equal(dueOf(json)[3], '2026-10-05', '要等最晚的乙做完才開始'); }); -test('任一工作包的截止日都不早於它的先決', async () => { +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] }, - ], + startDate: '2026-12-24', + workPackages: [{ index: 1, title: '跨假日', days: 2 }], }); - 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}`); - } - } + assert.equal(dueOf(json)[1], '2026-12-29'); +}); + +test('日曆抓不到時只跳過週末並印出警告', async () => { + const { code, json, stderr } = await run( + { + startDate: '2026-12-24', + workPackages: [{ index: 1, title: 'fallback', days: 2 }], + }, + [], + { env: { TEA_SDLC_CALENDAR_URL: 'http://127.0.0.1:1/unavailable.csv' } }, + ); + + assert.equal(code, 0); + assert.equal(dueOf(json)[1], '2026-12-28'); + assert.match(stderr, /警告.*週末/); }); test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => { @@ -96,6 +107,54 @@ test('輸出的順序即拓撲順序,先決一定排在後續之前', async () assert.deepEqual(json.data.order, [1, 2, 3]); }); +test('CPM 會計算 ES、EF、LS、LF、浮時與關鍵路徑', async () => { + const { json } = await run({ + startDate: '2026-09-21', + workPackages: [ + { index: 1, title: '長支線', days: 2 }, + { index: 2, title: '短支線', days: 1 }, + { index: 3, title: '匯合', days: 1, depends: [1, 2] }, + ], + }); + + const byIndex = Object.fromEntries(json.data.schedule.map((item) => [item.index, item])); + assert.deepEqual( + { ES: byIndex[2].ES, EF: byIndex[2].EF, LS: byIndex[2].LS, LF: byIndex[2].LF, float: byIndex[2].float }, + { ES: '2026-09-21', EF: '2026-09-22', LS: '2026-09-22', LF: '2026-09-23', float: 1 }, + ); + assert.deepEqual(json.data.criticalPath, [1, 3]); + assert.equal(byIndex[1].critical, true); + assert.equal(byIndex[2].critical, false); +}); + +test('PERT 會計算工作包與專案的期望值、變異數和標準差', async () => { + const { json } = await run({ + startDate: '2026-09-21', + workPackages: [ + { + index: 1, + title: '三點估算', + optimistic: 2, + mostLikely: 3, + pessimistic: 4, + }, + ], + }); + + const item = json.data.schedule[0]; + assert.deepEqual(item.pert, { + optimistic: 2, + mostLikely: 3, + pessimistic: 4, + expected: 3, + variance: 1 / 9, + }); + assert.equal(item.expectedDays, 3); + assert.equal(json.data.project.expectedDuration, 3); + assert.equal(json.data.project.variance, 1 / 9); + assert.equal(json.data.project.standardDeviation, 1 / 3); +}); + test('相依成環時中止,並指出環上的成員', async () => { const { code, json } = await run({ startDate: '2026-09-21', @@ -161,7 +220,7 @@ test('計畫檔不是合法 JSON 時中止', async () => { assert.equal(json.error.code, 'BAD_PLAN'); }); -test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => { +test('輸出保留標題、人天與日期欄位', async () => { const { json } = await run({ startDate: '2026-09-21', workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }], @@ -172,13 +231,20 @@ test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async title: '建立抽取契約', days: 3, dueDate: '2026-09-24', + ES: '2026-09-21', + EF: '2026-09-24', + LS: '2026-09-21', + LF: '2026-09-24', + float: 0, + critical: true, + expectedDays: 3, }); }); test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => { - const { code } = await runScript( - 'schedule.js', - ['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })], + const { code } = await run( + { startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] }, + [], { env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } }, ); @@ -186,7 +252,6 @@ test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => }); test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => { - // 混出來的結果會是:相依看後者、標題與人天取前者,而且完全不報錯 const { code, json } = await run({ startDate: '2026-09-21', workPackages: [