Files
tea-sdlc/test/timer.test.js
T
jiantw83andClaude Opus 5 0154cf59d4 fix(claim): 被碼錶擋下時明說停錶不會動到既有的工作樹
議題 #38 的使用者故事第 30 條:使用者常以為停錶等於放棄那顆工作包,於是寧可
不停——工時就記到別顆議題去了。碼錶只管時間、工作樹只管檔案,兩者互不相干,
這件事要在擋下來的當下就講,不能指望使用者自己推論。

領取與起錶會撞到同一個擋路理由,訊息收進 lib 只寫一份。順手收掉 review 指出的
三處:planWorktree 沒用到的 repo 參數、與 path.resolve 同名而誤導的區域函式、
以及只有 lib 自己用得到卻對外 export 的兩支路徑函式。

回滾補上最後一道:git 清不掉時把目錄本身也刪掉。那條路徑在這次執行之前不存在
(不存在正是建立的前提),裡面不可能有使用者的東西,而留著它下一次重跑會直接
撞上 WORKTREE_PATH_TAKEN——一次失敗的建立不該讓人從此開不了工。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:29:12 +08:00

150 lines
5.4 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.
/**
* 起錶。
*
* 錶是工時報表的唯一來源,所以這一支的價值全在「什麼時候不該起」:工作樹還沒建好
* 不該起(那由流程的順序保證),自己的錶已經跑在別顆議題上更不該起——那會把兩顆
* 工作包的時間攪在一起。停錶一律由使用者自己來,這裡不提供。
*/
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 = 40;
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, { stopwatches = [] } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '以 worktree 建立工作包分支並備妥隔離環境',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('timer.js', ['--repo', REPO, '--index', String(INDEX), ...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([], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.index, INDEX);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`],
);
});
test('錶已經跑在這顆議題上時什麼都不做,重跑不會把計時打斷', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run([], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.已在計時, true, '要如實說這顆本來就在計時,不要假裝是這次起的');
assert.deepEqual(writes(stub), [], '重新起錶會把已經累積的時間切成兩段');
});
test('錶跑在別顆議題上時擋下,並指出是哪一顆', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
});
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /別的專案/);
});
test('不代替使用者停錶:訊息要說清楚下一步是他自己去停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { json } = await run([], stub);
assert.match(json.error.message, /停/);
assert.match(
json.error.message,
/不會動到任何既有的工作樹/,
'碼錶只管時間、工作樹只管檔案;不講清楚,使用者會以為停錶等於放棄那顆工作包',
);
});
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([], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.deepEqual(writes(stub), []);
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('timer.js', ['--repo', REPO, '--index', '0'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:已經在計時時預覽出來就是什麼都不做', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
assert.equal(json.data.已在計時, true);
});