268 lines
8.1 KiB
JavaScript
268 lines
8.1 KiB
JavaScript
/**
|
|
* 排程計算:日期只落在工作日,並從同一份計畫產出 PERT 與 CPM。
|
|
* 腳本對外會抓政府日曆;測試用 data URL 固定資料,fallback 測試才改用失效網址。
|
|
*/
|
|
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';
|
|
|
|
/** 測試用的 2026 年日曆:12 月 25 日是已知假日,其他日期只由週末判斷。 */
|
|
const CALENDAR = `data:text/csv,${encodeURIComponent(
|
|
'西元日期,星期,是否放假,備註\n20261224,四,0,\n20261225,五,2,測試假日\n20261228,一,0,',
|
|
)}`;
|
|
|
|
/** 把一份排程計畫寫成暫存檔,回傳路徑 */
|
|
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 = [], 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 () => {
|
|
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-28' });
|
|
});
|
|
|
|
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-28' });
|
|
});
|
|
|
|
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-05', '要等最晚的乙做完才開始');
|
|
});
|
|
|
|
test('週末與年度假日都不會被算作工作日', async () => {
|
|
const { json } = await run({
|
|
startDate: '2026-12-24',
|
|
workPackages: [{ index: 1, title: '跨假日', days: 2 }],
|
|
});
|
|
|
|
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 () => {
|
|
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('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',
|
|
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',
|
|
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 run(
|
|
{ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] },
|
|
[],
|
|
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
|
|
);
|
|
|
|
assert.equal(code, 0);
|
|
});
|
|
|
|
test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => {
|
|
const { code, json } = await run({
|
|
startDate: '2026-09-21',
|
|
workPackages: [
|
|
{ index: 1, title: '第一份定義', days: 1 },
|
|
{ index: 1, title: '第二份定義', days: 100 },
|
|
{ index: 2, title: '乙', days: 1, depends: [1] },
|
|
],
|
|
});
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'BAD_PLAN');
|
|
assert.match(json.error.message, /不只一次/);
|
|
});
|