Files
tea-sdlc/test/report.test.js
T
jiantw83andClaude Opus 5 b56216e0f0 test(工時報表): 釘住週次歸屬的跨月、跨年與五個週五
時區在測試裡固定為 Asia/Taipei:週界是以人在的時區切的,不釘住時區就等於沒釘住答案。
--today 讓「本週」在 CLI 接縫上釘得住,否則預設期間會跟著系統時鐘漂走。

寫入請求那則斷言誠實列出唯一的非 GET——四層前置檢查的寫入權探針,
而不是先把它濾掉再宣稱整趟沒有寫入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:50:35 +00:00

515 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 工時報表:期間切法、週次歸屬與估算落差。
*
* 這支腳本的難處不在取資料,而在「哪一筆工時算在哪一週、哪一週算在哪個月」。
* 跨月、跨年、當月有五個週五三種邊界各自都會讓人算錯,所以逐一釘住。
*
* 時區在測試裡固定為 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.期間.迄);
});