/** * 補登工時。 * * 規劃階段最耗時的那一段發生在議題建立之前——那時候沒有標的可起錶,時間只能事後補登。 * 這一支的價值全在「補多少」與「什麼時候不該補」:長度由議題的建立時間減掉指令開始 * 時間算出來,不由 agent 自己做減法;已經補過的不再補一次,否則重跑會把同一段時間 * 記兩遍,而報表看不出來那是重複的。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; const INDEX = 42; /** 議題建立於此刻;每支測試的 --since 都相對它往前推 */ const CREATED = '2026-09-17T10:30:00Z'; /** CREATED 往前推 n 秒的 ISO 時間 */ const 早於建立 = (seconds) => new Date(Date.parse(CREATED) - seconds * 1000).toISOString(); /** 議題上跑著的碼錶長什麼樣 */ const stopwatchOn = (index, repo = REPO) => ({ issue_index: index, repo_owner_name: repo.split('/')[0], repo_name: repo.split('/')[1], }); function routes(overrides = {}, { stopwatches = [], times = [] } = {}) { return healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 200, body: { number: INDEX, title: '為規劃與分析階段計時並補登規劃時間', created_at: CREATED, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`, }, }, 'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches }, [`GET /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 200, body: times }, [`POST /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 200, body: { id: 1 } }, ...overrides, }); } const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); const run = (since, args, stub) => runScript('time-log.js', ['--repo', REPO, '--index', String(INDEX), '--since', since, ...args], { env: envFor(stub), }); /** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */ const writes = (stub) => stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0')); test('補登時長等於指令開始到議題建立的差值', async (t) => { const stub = await withStub(t); const { code, json } = await run(早於建立(25 * 60), [], stub); assert.equal(code, 0, json.error?.message); assert.equal(json.data.補登, true); assert.equal(json.data.秒數, 25 * 60); const [write] = writes(stub); assert.equal(write.path, `/api/v1/repos/${REPO}/issues/${INDEX}/times`); assert.deepEqual(write.body, { time: 25 * 60 }); }); test('長度由腳本自己算:議題的建立時間減掉 --since,agent 不必做減法', async (t) => { const stub = await withStub(t); const { json } = await run(早於建立(90), [], stub); assert.equal(json.data.秒數, 90); assert.equal(json.data.議題建立時間, CREATED); }); test('不設時間上限:中間去開會的那幾個小時照實補登,不改口問使用者', async (t) => { const stub = await withStub(t); const 十小時 = 10 * 60 * 60; const { code, json } = await run(早於建立(十小時), [], stub); assert.equal(code, 0, '時間長不是失敗;記多了看得出來,記不到就永遠找不回來'); assert.equal(json.data.秒數, 十小時); assert.deepEqual(writes(stub).at(0).body, { time: 十小時 }); }); test('錶已經跑在這顆議題上時跳過:補登排在起錶之前,代表這一步做過了', async (t) => { const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] }); const { code, json } = await run(早於建立(600), [], stub); assert.equal(code, 0, json.error?.message); assert.equal(json.data.補登, false); assert.match(json.data.note, /起錶/); assert.deepEqual(writes(stub), [], '重跑不該把同一段時間記第二遍'); }); test('這顆議題上已經有自己的工時時跳過,重跑不會愈補愈多', async (t) => { const stub = await withStub(t, {}, { times: [{ id: 3, time: 1500 }] }); const { code, json } = await run(早於建立(600), [], stub); assert.equal(code, 0, json.error?.message); assert.equal(json.data.補登, false); assert.match(json.data.note, /工時/); assert.deepEqual(writes(stub), []); }); test('錶跑在別顆議題上不影響補登:補登只寫工時,不動任何錶', async (t) => { const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] }); const { code, json } = await run(早於建立(600), [], stub); assert.equal(code, 0, json.error?.message); assert.equal(json.data.補登, true); assert.deepEqual( writes(stub).map((r) => r.path), [`/api/v1/repos/${REPO}/issues/${INDEX}/times`], '不得順手停掉別顆議題上的錶', ); }); test('指令開始時間不早於議題建立時間時什麼都不補,也不算失敗', async (t) => { const stub = await withStub(t); const { code, json } = await run(new Date(Date.parse(CREATED) + 1000).toISOString(), [], stub); assert.equal(code, 0, '兩邊時鐘差幾秒是常事,不該讓整個流程停在這裡'); assert.equal(json.data.補登, false); assert.deepEqual(writes(stub), []); }); test('--since 不是可解析的時間時擋在打 Gitea 之前', async (t) => { const stub = await withStub(t); const { code, json } = await run('剛剛', [], stub); assert.equal(code, 1); assert.equal(json.error.code, 'BAD_SINCE'); assert.equal(stub.requests.length, 0); }); test('議題不存在時回可區分的錯誤碼', async (t) => { const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } }, }); const { json } = await run(早於建立(600), [], stub); assert.equal(json.error.code, 'ISSUE_NOT_FOUND'); assert.deepEqual(writes(stub), []); }); test('repo 的時間追蹤關著時,訊息與前置檢查第四層說的是同一件事', async (t) => { // 議題讀得到卻在 times 上 404,代表時間追蹤關著;試跑不跑前置檢查,這句話得由這裡說 const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/issues/${INDEX}/times`]: { status: 404, body: { message: 'not found' } }, }); const { code, json } = await run(早於建立(600), ['--dry-run'], stub); assert.equal(code, 1); assert.equal(json.error.code, 'TIME_TRACKER_OFF'); assert.match(json.error.message, /Enable Time Tracker/); }); test('--dry-run 印出將發出的補登,但一個字都不寫進去', async (t) => { const stub = await withStub(t); const { code, json } = await run(早於建立(1800), ['--dry-run'], stub); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.equal(json.data.秒數, 1800); assert.deepEqual(json.data.requests, [ { method: 'POST', path: `/repos/${REPO}/issues/${INDEX}/times`, body: { time: 1800 } }, ]); assert.deepEqual(writes(stub), [], '預覽不得真的寫入'); }); test('--dry-run 會先讀現況:已經補過的預覽出來就是什麼都不做', async (t) => { const stub = await withStub(t, {}, { times: [{ id: 3, time: 1500 }] }); const { json } = await run(早於建立(1800), ['--dry-run'], stub); assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘'); assert.equal(json.data.補登, false); });