feat/sdlc-report-timesheet/main #34
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* 工時報表:期間切法、週次歸屬與估算落差。
|
||||
*
|
||||
* 這支腳本的難處不在取資料,而在「哪一筆工時算在哪一週、哪一週算在哪個月」。
|
||||
* 跨月、跨年、當月有五個週五三種邊界各自都會讓人算錯,所以逐一釘住。
|
||||
*
|
||||
* 時區在測試裡固定為 Asia/Taipei:週界是以人在的時區切的,不釘住時區就等於沒釘住答案。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const TZ = 'Asia/Taipei';
|
||||
|
||||
/** 一顆工作包議題;估算寫在「關聯」段落,那是 issue-update 唯一寫得進去的地方 */
|
||||
function issue(number, { title = `工作包 ${number}`, days = null, repo = REPO } = {}) {
|
||||
const 關聯 = days === null ? '需求議題:#1' : `需求議題:#1\n估算人天:${days}`;
|
||||
return {
|
||||
number,
|
||||
title,
|
||||
html_url: `https://gitea.example/${repo}/issues/${number}`,
|
||||
body: `## 這個工作包在做什麼\n\n做一件事\n\n## 關聯\n\n${關聯}\n`,
|
||||
repository: { full_name: repo },
|
||||
};
|
||||
}
|
||||
|
||||
/** 一筆工時。created 寫成不帶時區的本地時刻,讀起來就是「那天的幾點」 */
|
||||
let nextId = 1;
|
||||
function time(created, hours, issueObject) {
|
||||
return {
|
||||
id: nextId++,
|
||||
created: new Date(`${created}T10:00:00+08:00`).toISOString(),
|
||||
time: Math.round(hours * 3600),
|
||||
user_name: 'tester',
|
||||
issue: issueObject,
|
||||
};
|
||||
}
|
||||
|
||||
/** 啟一台假 Gitea,/user/times 回傳指定的工時清單 */
|
||||
async function withTimes(t, times, overrides = {}) {
|
||||
return withStubGitea(
|
||||
t,
|
||||
healthyRoutes(REPO, {
|
||||
'GET /api/v1/user/times': { status: 200, body: times },
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const run = (stub, args) =>
|
||||
runScript('report.js', ['--repo', REPO, ...args], { env: { ...stubEnv(stub), TZ } });
|
||||
|
||||
/** 依名稱取出分段小計的秒數 */
|
||||
const segmentSeconds = (json) =>
|
||||
Object.fromEntries(json.data.分段.map((s) => [s.名稱, s.實際秒]));
|
||||
|
||||
// ── 期間:本週 ─────────────────────────────────────────────────────
|
||||
|
||||
test('預設為本週:起於本週一、迄於今日', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { code, json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.期間.類型, 'week');
|
||||
assert.equal(json.data.期間.起, '2026-09-14');
|
||||
assert.equal(json.data.期間.迄, '2026-09-17');
|
||||
});
|
||||
|
||||
test('今天就是週一時,本週只有今天這一天', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-14']);
|
||||
|
||||
assert.equal(json.data.期間.起, '2026-09-14');
|
||||
assert.equal(json.data.期間.迄, '2026-09-14');
|
||||
});
|
||||
|
||||
test('今天是週日時仍屬同一週,不跳到下週一', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-20']);
|
||||
|
||||
assert.equal(json.data.期間.起, '2026-09-14');
|
||||
assert.equal(json.data.期間.迄, '2026-09-20');
|
||||
});
|
||||
|
||||
test('只計入期間內的工時,期間外的一秒都不算', async (t) => {
|
||||
const wp = issue(12);
|
||||
const stub = await withTimes(t, [
|
||||
time('2026-09-13', 8, wp), // 上週日
|
||||
time('2026-09-14', 2, wp), // 本週一
|
||||
time('2026-09-17', 1.5, wp), // 今天
|
||||
time('2026-09-18', 4, wp), // 今天之後
|
||||
]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.實際秒, 3.5 * 3600);
|
||||
});
|
||||
|
||||
test('週報沒有分段小計:一週之內沒有更小的段落', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.deepEqual(json.data.分段, []);
|
||||
});
|
||||
|
||||
// ── 期間:月報與 W1–W5 ────────────────────────────────────────────
|
||||
|
||||
test('月報依「該週週五所屬月份」歸屬:月初跨月的那一週算進本月', async (t) => {
|
||||
// 2026-01 的第一個週五是 01-02,那一週的週一落在 2025-12-29
|
||||
const stub = await withTimes(t, [time('2025-12-29', 3, issue(12))]);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026-01']);
|
||||
|
||||
assert.equal(json.data.期間.起, '2025-12-29');
|
||||
assert.equal(json.data.總計.實際秒, 3 * 3600);
|
||||
assert.equal(segmentSeconds(json).W1, 3 * 3600);
|
||||
});
|
||||
|
||||
test('月報依「該週週五所屬月份」歸屬:月末跨月的那一週算進下個月', async (t) => {
|
||||
// 2026-02-01 是週日,它那一週的週五是 01-30,所以歸 2026-01 而非 2026-02
|
||||
const stub = await withTimes(t, [time('2026-02-01', 5, issue(12))]);
|
||||
|
||||
const january = await run(stub, ['--month', '2026-01']);
|
||||
const february = await run(stub, ['--month', '2026-02']);
|
||||
|
||||
assert.equal(january.json.data.總計.實際秒, 5 * 3600);
|
||||
assert.equal(february.json.data.總計.實際秒, 0, '同一筆工時不得被兩個月重複計算');
|
||||
});
|
||||
|
||||
test('W 編號為該週五是當月第幾個週五,有五個週五的月份排到 W5', async (t) => {
|
||||
// 2026-01 的週五:02、09、16、23、30
|
||||
const wp = issue(12);
|
||||
const stub = await withTimes(t, [
|
||||
time('2026-01-02', 1, wp),
|
||||
time('2026-01-09', 2, wp),
|
||||
time('2026-01-16', 3, wp),
|
||||
time('2026-01-23', 4, wp),
|
||||
time('2026-01-30', 5, wp),
|
||||
]);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026-01']);
|
||||
|
||||
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4', 'W5']);
|
||||
assert.deepEqual(segmentSeconds(json), {
|
||||
W1: 1 * 3600,
|
||||
W2: 2 * 3600,
|
||||
W3: 3 * 3600,
|
||||
W4: 4 * 3600,
|
||||
W5: 5 * 3600,
|
||||
});
|
||||
});
|
||||
|
||||
test('只有四個週五的月份就只有 W1–W4,不硬湊出空的 W5', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026-02']);
|
||||
|
||||
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4']);
|
||||
});
|
||||
|
||||
test('沒有工時的週次仍然列出來,小計為零', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-02-06', 1, issue(12))]);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026-02']);
|
||||
|
||||
assert.deepEqual(segmentSeconds(json), { W1: 3600, W2: 0, W3: 0, W4: 0 });
|
||||
});
|
||||
|
||||
test('每個週次都標出自己的起迄,週一到週日', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026-01']);
|
||||
|
||||
assert.deepEqual(json.data.分段[0], {
|
||||
名稱: 'W1',
|
||||
起: '2025-12-29',
|
||||
迄: '2026-01-04',
|
||||
實際秒: 0,
|
||||
實際工時: '0h 00m',
|
||||
});
|
||||
});
|
||||
|
||||
// ── 期間:年報與跨年 ──────────────────────────────────────────────
|
||||
|
||||
test('年報以月份分段小計', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-03-04', 2, issue(12))]);
|
||||
|
||||
const { json } = await run(stub, ['--year', '2026']);
|
||||
|
||||
assert.equal(json.data.分段.length, 12);
|
||||
assert.deepEqual(json.data.分段.map((s) => s.名稱).slice(0, 3), ['2026-01', '2026-02', '2026-03']);
|
||||
assert.equal(segmentSeconds(json)['2026-03'], 2 * 3600);
|
||||
});
|
||||
|
||||
test('跨年的那一週依週五歸屬:12/29 的工時算進下一年', async (t) => {
|
||||
// 2025-12-29 是週一,它那一週的週五是 2026-01-02
|
||||
const stub = await withTimes(t, [time('2025-12-29', 6, issue(12))]);
|
||||
|
||||
const y2025 = await run(stub, ['--year', '2025']);
|
||||
const y2026 = await run(stub, ['--year', '2026']);
|
||||
|
||||
assert.equal(y2025.json.data.總計.實際秒, 0);
|
||||
assert.equal(y2026.json.data.總計.實際秒, 6 * 3600);
|
||||
assert.equal(segmentSeconds(y2026.json)['2026-01'], 6 * 3600);
|
||||
});
|
||||
|
||||
test('年報的起迄由第一個與最後一個週五所在的週決定', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--year', '2026']);
|
||||
|
||||
// 首個週五 2026-01-02 的週一是 2025-12-29;末個週五 2026-12-25 的週日是 2026-12-27
|
||||
assert.equal(json.data.期間.起, '2025-12-29');
|
||||
assert.equal(json.data.期間.迄, '2026-12-27');
|
||||
});
|
||||
|
||||
// ── 估算落差 ───────────────────────────────────────────────────────
|
||||
|
||||
test('估算取自議題「關聯」段落的估算人天,落差為實際減估算', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 20, issue(12, { days: 2 }))]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.估算人天, 2);
|
||||
assert.equal(json.data.總計.落差秒, (20 - 16) * 3600, '2 人天 × 8 小時 = 16 小時');
|
||||
assert.equal(json.data.總計.落差工時, '+4h 00m');
|
||||
});
|
||||
|
||||
test('實際少於估算時落差為負', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 6, issue(12, { days: 1 }))]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.落差秒, -2 * 3600);
|
||||
assert.equal(json.data.總計.落差工時, '-2h 00m');
|
||||
});
|
||||
|
||||
test('--day-hours 換掉一人天等於幾小時的假設', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 7, issue(12, { days: 1 }))]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17', '--day-hours', '7']);
|
||||
|
||||
assert.equal(json.data.每日工時, 7);
|
||||
assert.equal(json.data.總計.落差秒, 0);
|
||||
});
|
||||
|
||||
test('議題沒寫估算時落差為 null,不當成零', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 3, issue(12))]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.議題[0].估算人天, null);
|
||||
assert.equal(json.data.議題[0].落差秒, null);
|
||||
assert.equal(json.data.總計.估算人天, 0, '總計只加得起來有估算的那些');
|
||||
assert.equal(json.data.總計.落差秒, null, '一顆估算都沒有時,沒有東西可以比');
|
||||
});
|
||||
|
||||
test('總計的落差只拿有估算的議題來比,沒估算的工時不算成超出估算', async (t) => {
|
||||
const stub = await withTimes(t, [
|
||||
time('2026-09-15', 6, issue(12, { days: 1 })), // 估 8 小時、實際 6 小時
|
||||
time('2026-09-16', 30, issue(13)), // 沒估算,30 小時
|
||||
]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.實際秒, 36 * 3600, '實際總計仍然是全部');
|
||||
assert.equal(json.data.總計.已估實際秒, 6 * 3600, '落差的分母只有有估算的那顆');
|
||||
assert.equal(json.data.總計.落差秒, -2 * 3600);
|
||||
assert.notEqual(json.data.總計.落差秒, 28 * 3600, '拿全部實際去比部分估算會灌出假的超支');
|
||||
});
|
||||
|
||||
// ── 逐議題明細 ─────────────────────────────────────────────────────
|
||||
|
||||
test('依議題彙總,帶上標題與網址,工時多的排前面', async (t) => {
|
||||
const stub = await withTimes(t, [
|
||||
time('2026-09-14', 1, issue(12, { title: '建立抽取契約', days: 3 })),
|
||||
time('2026-09-15', 4, issue(13, { title: '補上前置檢查' })),
|
||||
time('2026-09-16', 2, issue(12, { title: '建立抽取契約', days: 3 })),
|
||||
]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.deepEqual(json.data.議題, [
|
||||
{
|
||||
index: 13,
|
||||
title: '補上前置檢查',
|
||||
url: 'https://gitea.example/plugins/tea-sdlc/issues/13',
|
||||
實際秒: 4 * 3600,
|
||||
實際工時: '4h 00m',
|
||||
估算人天: null,
|
||||
落差秒: null,
|
||||
落差工時: null,
|
||||
},
|
||||
{
|
||||
index: 12,
|
||||
title: '建立抽取契約',
|
||||
url: 'https://gitea.example/plugins/tea-sdlc/issues/12',
|
||||
實際秒: 3 * 3600,
|
||||
實際工時: '3h 00m',
|
||||
估算人天: 3,
|
||||
落差秒: -21 * 3600,
|
||||
落差工時: '-21h 00m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('工時以時分呈現,秒數不進位成假的精確', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 1.51, issue(12))]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.實際工時, '1h 30m');
|
||||
});
|
||||
|
||||
// ── 範圍:只算指定 repo 的工時 ────────────────────────────────────
|
||||
|
||||
test('別的 repo 的工時不算進來', async (t) => {
|
||||
const stub = await withTimes(t, [
|
||||
time('2026-09-15', 2, issue(12)),
|
||||
time('2026-09-15', 8, issue(4, { repo: 'plugins/other' })),
|
||||
]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.實際秒, 2 * 3600);
|
||||
assert.deepEqual(json.data.議題.map((i) => i.index), [12]);
|
||||
});
|
||||
|
||||
test('查不到議題資訊的工時不默默消失,回報則數', async (t) => {
|
||||
const stub = await withTimes(t, [
|
||||
time('2026-09-15', 2, issue(12)),
|
||||
{ id: 99, created: '2026-09-15T02:00:00Z', time: 3600, user_name: 'tester' },
|
||||
]);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.略過, 1, '無議題資訊時整份報表會憑空變空,數字要留在輸出裡');
|
||||
assert.equal(json.data.總計.實際秒, 2 * 3600);
|
||||
});
|
||||
|
||||
test('沒有任何工時時回空報表,不是錯誤', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { code, json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.總計.實際秒, 0);
|
||||
assert.deepEqual(json.data.議題, []);
|
||||
});
|
||||
|
||||
// ── 取資料的方式 ───────────────────────────────────────────────────
|
||||
|
||||
test('工時逐頁讀完,不是只讀第一頁', async (t) => {
|
||||
const wp = issue(12);
|
||||
const first = Array.from({ length: 50 }, () => time('2026-09-15', 0.1, wp));
|
||||
const second = [time('2026-09-16', 1, wp)];
|
||||
const stub = await withStubGitea(
|
||||
t,
|
||||
healthyRoutes(REPO, {
|
||||
'GET /api/v1/user/times': (req) => ({
|
||||
status: 200,
|
||||
body: req.query.page === '1' ? first : second,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.總計.實際秒, Math.round((50 * 0.1 + 1) * 3600));
|
||||
});
|
||||
|
||||
test('工時內嵌的議題沒帶 body 時,補查議題才讀得到估算', async (t) => {
|
||||
// /user/times 內嵌的議題不保證帶 body;少了它,估算會整欄靜靜變成 null
|
||||
const bodyless = { ...issue(12, { days: 2 }) };
|
||||
delete bodyless.body;
|
||||
const stub = await withTimes(
|
||||
t,
|
||||
[time('2026-09-15', 20, bodyless), time('2026-09-16', 1, bodyless)],
|
||||
{ [`GET /api/v1/repos/${REPO}/issues/12`]: { status: 200, body: issue(12, { days: 2 }) } },
|
||||
);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.equal(json.data.議題[0].估算人天, 2);
|
||||
assert.equal(json.data.總計.落差秒, (21 - 16) * 3600);
|
||||
const lookups = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues/12`);
|
||||
assert.equal(lookups.length, 1, '同一顆議題只補查一次,不是每筆工時各查一次');
|
||||
});
|
||||
|
||||
test('唯一的非 GET 是前置檢查的寫入權探針,報表本身不寫任何東西', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
|
||||
|
||||
await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
// 四層前置檢查會 PATCH 不存在的議題 0 來實測 issues 寫入權,那一筆不改動任何東西。
|
||||
// 除它以外整趟都該是 GET——報表只印在終端,不對任何管道張貼。
|
||||
assert.deepEqual(
|
||||
stub.requests.filter((r) => r.method !== 'GET').map((r) => `${r.method} ${r.path}`),
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`],
|
||||
);
|
||||
});
|
||||
|
||||
test('--dry-run 印出將發出的請求,且完全不碰 Gitea', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { code, json } = await run(stub, ['--today', '2026-09-17', '--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.requests, [{ method: 'GET', path: '/user/times' }]);
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
// ── 期間參數的把關 ─────────────────────────────────────────────────
|
||||
|
||||
test('三種期間彼此互斥', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { code, json } = await run(stub, ['--month', '2026-01', '--year', '2026']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PERIOD_CONFLICT');
|
||||
});
|
||||
|
||||
test('--month 需為 YYYY-MM', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026/01']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PERIOD');
|
||||
assert.match(json.error.message, /--month/);
|
||||
});
|
||||
|
||||
test('--month 的月份需在 01–12 之間', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--month', '2026-13']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PERIOD');
|
||||
});
|
||||
|
||||
test('--year 需為四位數', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--year', '26']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PERIOD');
|
||||
assert.match(json.error.message, /--year/);
|
||||
});
|
||||
|
||||
test('--today 需為真實存在的日期', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--today', '2026-02-30']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PERIOD');
|
||||
assert.match(json.error.message, /--today/);
|
||||
});
|
||||
|
||||
test('--day-hours 需為正數', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { json } = await run(stub, ['--day-hours', '0']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_DAY_HOURS');
|
||||
});
|
||||
|
||||
test('--week 明講出來時與預設同一段期間', async (t) => {
|
||||
const stub = await withTimes(t, [time('2026-09-15', 2, issue(12))]);
|
||||
|
||||
const explicit = await run(stub, ['--today', '2026-09-17', '--week']);
|
||||
const implicit = await run(stub, ['--today', '2026-09-17']);
|
||||
|
||||
assert.deepEqual(explicit.json, implicit.json);
|
||||
});
|
||||
|
||||
test('--today 搭到月報或年報時擋下,不默默忽略', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const month = await run(stub, ['--month', '2026-01', '--today', '2026-09-17']);
|
||||
const year = await run(stub, ['--year', '2026', '--today', '2026-09-17']);
|
||||
|
||||
assert.equal(month.json.error.code, 'PERIOD_CONFLICT');
|
||||
assert.equal(year.json.error.code, 'PERIOD_CONFLICT');
|
||||
});
|
||||
|
||||
test('期間標籤讓人一眼看出報表涵蓋什麼', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const week = await run(stub, ['--today', '2026-09-17']);
|
||||
const month = await run(stub, ['--month', '2026-01']);
|
||||
const year = await run(stub, ['--year', '2026']);
|
||||
|
||||
assert.equal(week.json.data.期間.標籤, '2026-09-14 ~ 2026-09-17');
|
||||
assert.equal(month.json.data.期間.標籤, '2026-01');
|
||||
assert.equal(year.json.data.期間.標籤, '2026');
|
||||
});
|
||||
|
||||
test('不帶 --today 時以系統日期為準,仍算得出本週', async (t) => {
|
||||
const stub = await withTimes(t, []);
|
||||
|
||||
const { code, json } = await run(stub, []);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(json.data.期間.起, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.ok(json.data.期間.起 <= json.data.期間.迄);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 工時報表的流程正本與輸出模板。
|
||||
*
|
||||
* 報表的難處是規則而不是程式:期間怎麼切、落差怎麼讀、印到哪裡為止。
|
||||
* 規則寫在正本裡,錯了不會有任何測試自己爆掉,所以在這裡釘住。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assertNeutralPrompt,
|
||||
assertTemplateSections,
|
||||
readPrompt,
|
||||
readTemplate,
|
||||
} from './helpers/prompt-doc.js';
|
||||
|
||||
const template = readTemplate('report');
|
||||
const prompt = readPrompt('sdlc-report');
|
||||
|
||||
/** 報表的四個段落,順序即印出來的順序 */
|
||||
const SECTIONS = ['總計', '分段小計', '逐議題', '附註'];
|
||||
|
||||
// ── 輸出模板 ───────────────────────────────────────────────────────
|
||||
|
||||
test('模板依序包含四個段落', () => {
|
||||
assertTemplateSections(template, SECTIONS);
|
||||
});
|
||||
|
||||
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
|
||||
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
|
||||
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
|
||||
for (const name of placeholders) {
|
||||
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
|
||||
}
|
||||
});
|
||||
|
||||
test('模板不含邏輯:沒有任何條件或迴圈語法', () => {
|
||||
assert.equal(/\{\{[#/^]/.test(template), false, '分段與逐議題由呼叫端展開,模板不做迴圈');
|
||||
});
|
||||
|
||||
test('總計把實際、估算與落差擺在同一列,落差不必自己算', () => {
|
||||
const totals = template.slice(template.indexOf('## 總計'), template.indexOf('## 分段小計'));
|
||||
for (const placeholder of ['{{實際工時}}', '{{估算人天}}', '{{已估實際}}', '{{落差}}']) {
|
||||
assert.ok(totals.includes(placeholder), `總計缺少 ${placeholder}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 流程正本 ───────────────────────────────────────────────────────
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-report');
|
||||
});
|
||||
|
||||
test('正本交代三種期間,並指明預設是本週', () => {
|
||||
assert.match(prompt, /--week/);
|
||||
assert.match(prompt, /--month YYYY-MM/);
|
||||
assert.match(prompt, /--year YYYY/);
|
||||
assert.match(prompt, /預設/);
|
||||
});
|
||||
|
||||
test('正本釘住期間定義的三句話', () => {
|
||||
assert.match(prompt, /一週為週一至週日/);
|
||||
assert.match(prompt, /該週週五所屬月份/);
|
||||
assert.match(prompt, /W1[–-]W5.*第幾個週五/s);
|
||||
});
|
||||
|
||||
test('正本以實例說明跨月與跨年那一週落在哪邊', () => {
|
||||
assert.match(prompt, /2025-12-29/, '跨年的例子要寫出具體日期,規則才驗得出來');
|
||||
assert.match(prompt, /2026-02-01/, '跨月的例子要寫出具體日期');
|
||||
});
|
||||
|
||||
test('正本指名由 report.js 取數字,並要求直接用算好的時分', () => {
|
||||
assert.match(prompt, /report\.js/);
|
||||
assert.match(prompt, /不要自己再乘|已經算好/);
|
||||
});
|
||||
|
||||
test('正本明令只印在終端,不張貼到任何管道', () => {
|
||||
assert.match(prompt, /只印在終端/);
|
||||
assert.match(prompt, /不(要)?張貼/);
|
||||
});
|
||||
|
||||
test('正本說明落差的正負方向,以及一人天等於幾小時', () => {
|
||||
assert.match(prompt, /正數.*超出估算/);
|
||||
assert.match(prompt, /8\s*小時/);
|
||||
assert.match(prompt, /--day-hours/);
|
||||
});
|
||||
|
||||
test('正本交代總計的落差只涵蓋有估算的議題', () => {
|
||||
assert.match(prompt, /總計的落差只涵蓋有估算的議題/);
|
||||
assert.match(prompt, /已估實際秒/, '要指名分子是哪一個欄位,否則會被讀成全部實際');
|
||||
});
|
||||
|
||||
test('正本交代沒寫估算時落差是空的,不是零', () => {
|
||||
assert.match(prompt, /不是零|非零|空的/);
|
||||
assert.match(prompt, /null/);
|
||||
});
|
||||
|
||||
test('正本交代略過的工時筆數要講出來', () => {
|
||||
assert.match(prompt, /略過/);
|
||||
});
|
||||
|
||||
test('正本的邊界寫明這是唯讀流程', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不寫入|只發 GET/);
|
||||
assert.match(boundary, /不補登|不動碼錶/);
|
||||
});
|
||||
|
||||
test('正本交代週報沒有分段小計時整段不印', () => {
|
||||
assert.match(prompt, /週報沒有分段/);
|
||||
});
|
||||
Reference in New Issue
Block a user