線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
記錄雞蛋糕的每一步前端煉成過程,從小白到(也許是)前端工程師的學習與分享。

Subscribe to 雞蛋糕的前端修煉屋
線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
<100 subscribers
<100 subscribers
用 TDD 最小步實作
runCliQuestions,從 dummy 回傳一路推進到呼叫askWithValidator,在紅燈 5 之後發現資料結構與責任可以拆分得更清楚,決定引入askOneRound。
這篇是 2025/12/23–24 兩天重構實作的過程記錄與整理,主要聚焦在:
用 Vitest 的測試替身(vi.fn())實作 spy / mock。
以 TDD 最小步驟實作 runCliQuestions。
在紅燈 5 之後,如何從回傳結構看出設計可以拆分成「一輪問答」與「多輪收集」。
延續前幾天的重構工作,目標是把 CLI 問答流程拆成幾個責任單一的模組,搭配「Parse, don't validate」原則與錯誤分層來設計:
runCliSession (錯誤邊界)
↓
runCliQuestions (問題流程編排)
↓
askWithValidator (單一問題+重試) ← 已完成
↓
validators (驗證規則) ← 已完成
validators:負責「純值」的檢查與轉換,成功回 { value },失敗丟 Error。
askWithValidator:負責單一 CLI 問題的讀取與重試邏輯,處理「可預期的驗證錯誤」。
runCliQuestions:今天的主角,負責「把多個問題依序問完,收集答案」。
runCliSession:日後會作為錯誤邊界與 readline 生命週期管理。
這篇專注在 runCliQuestions 的演化過程。
在這次 CLI 重構裡,askWithValidator 和 runCliQuestions 都會變成 async 函式,背後其實就是在操作 Promise 物件。
可以把 Promise 想成「描述一個非同步運算狀態與結果的物件」:
狀態:pending / fulfilled / rejected,可以想成規格裡說的 [[PromiseState]]。
結果:成功的值或失敗的 reason,可以想成 [[PromiseResult]]。
這些欄位是「內部槽位」,JavaScript 本身不讓你直接讀寫,但你可以用 then / catch / finally 這些方法,根據當前狀態建立新的 Promise 並接續運算。
最原始的 Promise 寫法長這樣:
const p = new Promise((resolve, reject) => {
setTimeout(() => {
// 這裡決定「物件的最終狀態+結果」
resolve('OK');
// 或:reject(new Error('Oops'));
}, 1000);
});
從語言層面拆解:
Promise 本身是一個「建構函式」:
被 new 呼叫時,會建立並初始化一個 Promise 物件。
它的參數是一個函式(executor),因此同時也是「高階函式」。
executor((resolve, reject) => { ... }):
會在 new Promise(...) 時同步執行一次。
拿到兩個函式參數 resolve / reject,在適當時機呼叫,更新 Promise 內部的狀態與結果。
目前為止,已經看到三種函式角色:
一般函式:可以被當成值使用,存變數、當參數、當回傳值,這是 JS 的一級函式特性。
高階函式:接收函式作為參數,或回傳函式作為結果(滿足一個條件就算)。
建構函式:可以用 new 呼叫、負責建立與初始化物件實例的函式,Promise 的原始語法是一個例子。
在 CLI 或 Node.js 環境中,常見的模式是把 callback-style API 包成 Promise:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFile(path, (error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
});
});
}
這裡的角色拆解:
Promise:建構函式 + 高階函式(參數是 executor 函式)。
executor:高階函式(拿到 resolve / reject 函式參數)。
readFilePromise:
回傳一個 Promise 物件;
本身也算高階函式(回傳值是由函式建立的物件,可以在其他地方組合)。
接下來是這兩天實作 runCliQuestions 的 TDD 流程,刻意用「非常小的紅燈 / 綠燈」循環往前推。
第一步先寫一個超假的需求,確保 Vitest 可以執行 async 測試、import 沒問題:
// util/runCliQuestions.test.js
import { describe, it, expect, vi } from "vitest";
import { runCliQuestions } from "./runCliQuestions.js";
describe("runCliQuestions", () => {
it("暫時實作:回傳 OK(第一步)", async () => {
const questionConfigs = vi.fn();
const result = await runCliQuestions(questionConfigs);
expect(result).toBe("OK");
});
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs){
return 'OK';
}
這個測試只是在確認:
測試檔可以 import 被測模組。
async 測試可以正常執行。
開始讓 runCliQuestions 的回傳值跟參數有一點關係:
it("第二步:暫時實作,先把 questionConfigs 原樣回傳", async () => {
const questionConfigs = [{ key: 'age' }];
const result = await runCliQuestions(questionConfigs);
expect(result).toEqual([{ key: 'age' }]);
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs) {
return questionConfigs;
}
這時候還沒有實際「問問題」,只是先讓函式的介面雛形穩定下來。
開始往「答案結構」靠近,先硬編一個 dummy 值:
it("第三步:單一問題,回傳 { key: 固定答案 } 結構", async () => {
const questionConfigs = [
{
key: 'age',
prompt: '請輸入年齡:',
// 之後才會用到 validator
validator: () => {},
},
];
const result = await runCliQuestions(questionConfigs);
expect(result).toEqual({ age: 'dummy-answer' });
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs) {
const answers = {};
const firstConfig = questionConfigs[0];
answers[firstConfig.key] = 'dummy-answer';
return answers;
}
這個階段的目的只是:
鎖定「一輪多欄位最終會是物件」這個事情。
先用固定值,之後再一層一層替換成真實流程。
接著要讓 runCliQuestions 可以處理多個問題設定:
// util/runCliQuestions.test.js
import { describe, it, expect, vi } from "vitest";
import { runCliQuestions } from "./runCliQuestions.js";
describe("runCliQuestions", () => {
it("問題流程編排", async () => {
const questionConfigs = [{
key: 'age',
prompt: '請輸入年齡:',
validator: vi.fn()
},
{
key: 'name',
prompt: '請輸入姓名:',
validator: vi.fn()
}];
const result = await runCliQuestions(questionConfigs);
expect(result).toEqual([{age: 'dummy-answer'}, {name: 'dummy-answer'}]);
});
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs){
const answers = [];
const firstConfig = questionConfigs[0];
const secondConfig = questionConfigs[1];
answers[0] = {[firstConfig.key]: 'dummy-answer'};
answers[1] = {[secondConfig.key]: 'dummy-answer'};
return answers;
}
過程中有出現一個宣告錯誤:answers[0] 和 answers[1] 還是 undefined,直接取屬性會噴 TypeError。
重點在於重新記得:
陣列的每一格要先有值(例如物件),才能用 answers[0].xxx 這種方式賦值。
// util/runCliQuestions.js(中途踩雷版本)
export function runCliQuestions(questionConfigs){
const answers = [];
const firstConfig = questionConfigs[0];
const secondConfig = questionConfigs[1];
answers[0][firstConfig.key] = 'dummy-answer';
answers[1][secondConfig.key] = 'dummy-answer';
return answers;
}
改成比較合理的實作(仍然用 dummy):
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs){
const answers = [];
questionConfigs.forEach((configs) => {
answers.push({[configs.key]: 'dummy-answer'})
})
return answers;
}
// 未來要延伸到非同步的版本,改成 for...of
export function runCliQuestions(questionConfigs){
const answers = [];
for(const config of questionConfigs){
answers.push({[config.key]: 'dummy-answer'})
}
return answers;
}
這時:
answers 的形狀是 [{ age: 'dummy-answer' }, { name: 'dummy-answer' }]。
雖然還不是最終想要的資料結構,但已經先練習把「多題問題流程」抽象成陣列迴圈。
這一步的目標是:
讓 runCliQuestions 不再回傳 dummy,而是真的去呼叫一個「問問題+驗證」的函式。
但在單元測試裡,不直接使用真實 askWithValidator,改用 Vitest mock 當 test double。
// Mock askWithValidator 的行為
const mockAskWithValidator = vi
.fn()
.mockResolvedValueOnce(25)
.mockResolvedValueOnce("Alice");
const result = await runCliQuestions(questionConfigs, mockAskWithValidator);
// 驗證:有沒有正確呼叫 askWithValidator
expect(mockAskWithValidator).toHaveBeenCalledTimes(2);
expect(mockAskWithValidator).toHaveBeenNthCalledWith(
1,
'請輸入年齡:',
questionConfigs[0].validator
);
expect(mockAskWithValidator).toHaveBeenNthCalledWith(
2,
'請輸入姓名:',
questionConfigs[1].validator
)
// 驗證:回傳值是用 mock 結果組起來的
expect(result).toEqual([{ age: 25 }, { name: 'Alice' }]);
});
});
這裡重點是:
透過 mockResolvedValueOnce 控制每次呼叫的非同步回傳值與順序。
讓測試只關注明確的行為:
呼叫幾次?
用什麼參數?
如何組成結果?
// util/runCliQuestions.js
export async function runCliQuestions(questionConfigs, askWithValidator){
const answers = [];
for(const config of questionConfigs){
const answer = await askWithValidator(config.prompt, config.validator);
answers.push({[config.key]: answer})
}
return answers;
}
這段程式碼達成:
使用 for...of + await,確保每題依序詢問,而不是先把所有 Promise 丟出去再一起 Promise.all。
runCliQuestions 對 askWithValidator 採用「依賴注入」方式(從參數傳入),讓單元測試可以輕易替換成 mock,不依賴真實 readline 或 while 重試的邏輯。
在紅燈 5 綠燈之後,回頭檢查目前的設計,發現兩個問題:
目前的回傳是:[{ age: 25 }, { name: 'Alice' }]
看起來像「兩筆紀錄,每筆只有一個欄位」。
真正語意卻是「同一個人的兩個欄位」。
語意上更貼切的結構會是:{ age: 25, name: 'Alice' }
也就是「一輪問答結果 = 一個欄位完整的物件」。
練習題裡有兩種常見模式:
類型 A:一輪輸入、一輪計算(多數題目)。
類型 B:同一組問題重複輸入多次(例如輸入多組英文名字)。
如果要同時描述「一輪」和「多輪」,比較自然的設計是:
一輪 → 物件:{ age, name }
多輪 → 物件陣列:[{ age, name }, { age, name }, ...]。
目前的 runCliQuestions 把「問題流程編排」和「一輪 / 多輪」混在一起,導致回傳結構無法同時清楚對應這兩種情境。
為了解決上面的兩個問題,下一步決定讓設計演化成:
抽出一個只處理「一輪問答」的函式 askOneRound:
輸入:questionConfigs + askWithValidator。
輸出:單一物件 { age: 25, name: 'Alice' }。
runCliQuestions 改為:
負責決定要呼叫 askOneRound 幾輪。
支援 rounds = 1 預設值。
回傳物件陣列 [{...}, {...}, ...],適合「多輪多筆資料」。
角色分工會變成:
askOneRound:一次表單填寫 / 一個人的資料。
runCliQuestions:多次表單填寫 / 多個人的資料,預設一次。
這部分的實作會在下一篇從紅燈 6 開始記錄。
從 TDD 的角度來看,這次不是「功能需求被 PM 改」,而是:
一開始只知道要「串多題 CLI」。
經過紅燈 1–5 的實作,逐步看見真正的抽象模式是「一輪 vs 多輪」。
最後決定讓程式設計對齊這個更抽象、語意更清晰的結構。
這很符合 TDD 常提到的「設計隨測試浮現(emergent design)」:
不是一開始就設計好所有 API,而是透過測試與重構,讓設計慢慢長出來。
這次實作過程中,紅燈 1–5 都還沒刻意拆 commit。事後回頭看,可以給自己一個下次的 commit 準則:
只在測試全綠時 commit
紅燈時不要 commit。
綠燈(包含重構後仍綠燈)時,才把這一小步的成果記錄下來。
每個 commit 代表一個「可以說得出口的小故事」
例如:
feat: add basic runCliQuestions pipeline
feat: make runCliQuestions async and call askWithValidator
refactor: extract askOneRound from runCliQuestions
避免把不相關的改動塞在同一個 commit 裡。
假設這次從頭來過,可以這樣切:
commit 1:
建立最初的 dummy 版 runCliQuestions,回傳 "OK" 或 questionConfigs。
message:feat: add initial runCliQuestions stub
commit 2:
支援多題問題流程,回傳 dummy 結構(物件陣列)。
message:feat: support multiple questions in runCliQuestions
commit 3(紅燈 5 完成):
引入 mock 版 askWithValidator,讓 runCliQuestions 使用 async/await 呼叫它並組裝真實答案。
message:feat: make runCliQuestions async and call askWithValidator
commit 4(未來抽出 askOneRound):
把「一輪問答」抽出成 askOneRound,runCliQuestions 加上 rounds 參數。
message:refactor: extract askOneRound and add rounds to runCliQuestions
整體原則是:
每一個「紅燈 → 綠燈 →(重構)」的完整小循環,或每 2–3 個非常小的循環,就值得打一個 commit。
Promise 不只是「語法糖」,而是「用固定欄位描述非同步狀態與結果的物件」,搭配高階建構函式與 chaining API(then / catch / finally)來組合非同步流程。
runCliQuestions 的 TDD 實作可以拆得很細:從 dummy 回傳、到回傳 dummy 結構、到多題流程、再到 async + mock 注入 askWithValidator。
用 Vitest 的 vi.fn().mockResolvedValueOnce(...) 可以很精確控制非同步函式每次呼叫的回傳值,適合測 CLI 這種「多次問答的流程」。
在紅燈 5 綠燈後,回頭檢查回傳結構與責任邊界,可以發現更好的抽象(askOneRound + runCliQuestions(rounds=1)),這是 TDD 下非常正常、也非常有價值的設計浮現過程。
git commit 不需要每一個紅燈都打一個,但可以在「每一個完整小故事」綠燈後 commit,讓歷史清楚又安全。
下一篇會從「紅燈 6」開始,實作:
askOneRound:單輪多題 → 回傳單一物件。
把 runCliQuestions 改成呼叫 askOneRound,並加上 rounds = 1 預設參數。
針對「多輪」情境(例如輸入四個英文名字)寫測試,確認物件陣列結構符合預期。
用 TDD 最小步實作
runCliQuestions,從 dummy 回傳一路推進到呼叫askWithValidator,在紅燈 5 之後發現資料結構與責任可以拆分得更清楚,決定引入askOneRound。
這篇是 2025/12/23–24 兩天重構實作的過程記錄與整理,主要聚焦在:
用 Vitest 的測試替身(vi.fn())實作 spy / mock。
以 TDD 最小步驟實作 runCliQuestions。
在紅燈 5 之後,如何從回傳結構看出設計可以拆分成「一輪問答」與「多輪收集」。
延續前幾天的重構工作,目標是把 CLI 問答流程拆成幾個責任單一的模組,搭配「Parse, don't validate」原則與錯誤分層來設計:
runCliSession (錯誤邊界)
↓
runCliQuestions (問題流程編排)
↓
askWithValidator (單一問題+重試) ← 已完成
↓
validators (驗證規則) ← 已完成
validators:負責「純值」的檢查與轉換,成功回 { value },失敗丟 Error。
askWithValidator:負責單一 CLI 問題的讀取與重試邏輯,處理「可預期的驗證錯誤」。
runCliQuestions:今天的主角,負責「把多個問題依序問完,收集答案」。
runCliSession:日後會作為錯誤邊界與 readline 生命週期管理。
這篇專注在 runCliQuestions 的演化過程。
在這次 CLI 重構裡,askWithValidator 和 runCliQuestions 都會變成 async 函式,背後其實就是在操作 Promise 物件。
可以把 Promise 想成「描述一個非同步運算狀態與結果的物件」:
狀態:pending / fulfilled / rejected,可以想成規格裡說的 [[PromiseState]]。
結果:成功的值或失敗的 reason,可以想成 [[PromiseResult]]。
這些欄位是「內部槽位」,JavaScript 本身不讓你直接讀寫,但你可以用 then / catch / finally 這些方法,根據當前狀態建立新的 Promise 並接續運算。
最原始的 Promise 寫法長這樣:
const p = new Promise((resolve, reject) => {
setTimeout(() => {
// 這裡決定「物件的最終狀態+結果」
resolve('OK');
// 或:reject(new Error('Oops'));
}, 1000);
});
從語言層面拆解:
Promise 本身是一個「建構函式」:
被 new 呼叫時,會建立並初始化一個 Promise 物件。
它的參數是一個函式(executor),因此同時也是「高階函式」。
executor((resolve, reject) => { ... }):
會在 new Promise(...) 時同步執行一次。
拿到兩個函式參數 resolve / reject,在適當時機呼叫,更新 Promise 內部的狀態與結果。
目前為止,已經看到三種函式角色:
一般函式:可以被當成值使用,存變數、當參數、當回傳值,這是 JS 的一級函式特性。
高階函式:接收函式作為參數,或回傳函式作為結果(滿足一個條件就算)。
建構函式:可以用 new 呼叫、負責建立與初始化物件實例的函式,Promise 的原始語法是一個例子。
在 CLI 或 Node.js 環境中,常見的模式是把 callback-style API 包成 Promise:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFile(path, (error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
});
});
}
這裡的角色拆解:
Promise:建構函式 + 高階函式(參數是 executor 函式)。
executor:高階函式(拿到 resolve / reject 函式參數)。
readFilePromise:
回傳一個 Promise 物件;
本身也算高階函式(回傳值是由函式建立的物件,可以在其他地方組合)。
接下來是這兩天實作 runCliQuestions 的 TDD 流程,刻意用「非常小的紅燈 / 綠燈」循環往前推。
第一步先寫一個超假的需求,確保 Vitest 可以執行 async 測試、import 沒問題:
// util/runCliQuestions.test.js
import { describe, it, expect, vi } from "vitest";
import { runCliQuestions } from "./runCliQuestions.js";
describe("runCliQuestions", () => {
it("暫時實作:回傳 OK(第一步)", async () => {
const questionConfigs = vi.fn();
const result = await runCliQuestions(questionConfigs);
expect(result).toBe("OK");
});
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs){
return 'OK';
}
這個測試只是在確認:
測試檔可以 import 被測模組。
async 測試可以正常執行。
開始讓 runCliQuestions 的回傳值跟參數有一點關係:
it("第二步:暫時實作,先把 questionConfigs 原樣回傳", async () => {
const questionConfigs = [{ key: 'age' }];
const result = await runCliQuestions(questionConfigs);
expect(result).toEqual([{ key: 'age' }]);
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs) {
return questionConfigs;
}
這時候還沒有實際「問問題」,只是先讓函式的介面雛形穩定下來。
開始往「答案結構」靠近,先硬編一個 dummy 值:
it("第三步:單一問題,回傳 { key: 固定答案 } 結構", async () => {
const questionConfigs = [
{
key: 'age',
prompt: '請輸入年齡:',
// 之後才會用到 validator
validator: () => {},
},
];
const result = await runCliQuestions(questionConfigs);
expect(result).toEqual({ age: 'dummy-answer' });
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs) {
const answers = {};
const firstConfig = questionConfigs[0];
answers[firstConfig.key] = 'dummy-answer';
return answers;
}
這個階段的目的只是:
鎖定「一輪多欄位最終會是物件」這個事情。
先用固定值,之後再一層一層替換成真實流程。
接著要讓 runCliQuestions 可以處理多個問題設定:
// util/runCliQuestions.test.js
import { describe, it, expect, vi } from "vitest";
import { runCliQuestions } from "./runCliQuestions.js";
describe("runCliQuestions", () => {
it("問題流程編排", async () => {
const questionConfigs = [{
key: 'age',
prompt: '請輸入年齡:',
validator: vi.fn()
},
{
key: 'name',
prompt: '請輸入姓名:',
validator: vi.fn()
}];
const result = await runCliQuestions(questionConfigs);
expect(result).toEqual([{age: 'dummy-answer'}, {name: 'dummy-answer'}]);
});
});
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs){
const answers = [];
const firstConfig = questionConfigs[0];
const secondConfig = questionConfigs[1];
answers[0] = {[firstConfig.key]: 'dummy-answer'};
answers[1] = {[secondConfig.key]: 'dummy-answer'};
return answers;
}
過程中有出現一個宣告錯誤:answers[0] 和 answers[1] 還是 undefined,直接取屬性會噴 TypeError。
重點在於重新記得:
陣列的每一格要先有值(例如物件),才能用 answers[0].xxx 這種方式賦值。
// util/runCliQuestions.js(中途踩雷版本)
export function runCliQuestions(questionConfigs){
const answers = [];
const firstConfig = questionConfigs[0];
const secondConfig = questionConfigs[1];
answers[0][firstConfig.key] = 'dummy-answer';
answers[1][secondConfig.key] = 'dummy-answer';
return answers;
}
改成比較合理的實作(仍然用 dummy):
// util/runCliQuestions.js
export function runCliQuestions(questionConfigs){
const answers = [];
questionConfigs.forEach((configs) => {
answers.push({[configs.key]: 'dummy-answer'})
})
return answers;
}
// 未來要延伸到非同步的版本,改成 for...of
export function runCliQuestions(questionConfigs){
const answers = [];
for(const config of questionConfigs){
answers.push({[config.key]: 'dummy-answer'})
}
return answers;
}
這時:
answers 的形狀是 [{ age: 'dummy-answer' }, { name: 'dummy-answer' }]。
雖然還不是最終想要的資料結構,但已經先練習把「多題問題流程」抽象成陣列迴圈。
這一步的目標是:
讓 runCliQuestions 不再回傳 dummy,而是真的去呼叫一個「問問題+驗證」的函式。
但在單元測試裡,不直接使用真實 askWithValidator,改用 Vitest mock 當 test double。
// Mock askWithValidator 的行為
const mockAskWithValidator = vi
.fn()
.mockResolvedValueOnce(25)
.mockResolvedValueOnce("Alice");
const result = await runCliQuestions(questionConfigs, mockAskWithValidator);
// 驗證:有沒有正確呼叫 askWithValidator
expect(mockAskWithValidator).toHaveBeenCalledTimes(2);
expect(mockAskWithValidator).toHaveBeenNthCalledWith(
1,
'請輸入年齡:',
questionConfigs[0].validator
);
expect(mockAskWithValidator).toHaveBeenNthCalledWith(
2,
'請輸入姓名:',
questionConfigs[1].validator
)
// 驗證:回傳值是用 mock 結果組起來的
expect(result).toEqual([{ age: 25 }, { name: 'Alice' }]);
});
});
這裡重點是:
透過 mockResolvedValueOnce 控制每次呼叫的非同步回傳值與順序。
讓測試只關注明確的行為:
呼叫幾次?
用什麼參數?
如何組成結果?
// util/runCliQuestions.js
export async function runCliQuestions(questionConfigs, askWithValidator){
const answers = [];
for(const config of questionConfigs){
const answer = await askWithValidator(config.prompt, config.validator);
answers.push({[config.key]: answer})
}
return answers;
}
這段程式碼達成:
使用 for...of + await,確保每題依序詢問,而不是先把所有 Promise 丟出去再一起 Promise.all。
runCliQuestions 對 askWithValidator 採用「依賴注入」方式(從參數傳入),讓單元測試可以輕易替換成 mock,不依賴真實 readline 或 while 重試的邏輯。
在紅燈 5 綠燈之後,回頭檢查目前的設計,發現兩個問題:
目前的回傳是:[{ age: 25 }, { name: 'Alice' }]
看起來像「兩筆紀錄,每筆只有一個欄位」。
真正語意卻是「同一個人的兩個欄位」。
語意上更貼切的結構會是:{ age: 25, name: 'Alice' }
也就是「一輪問答結果 = 一個欄位完整的物件」。
練習題裡有兩種常見模式:
類型 A:一輪輸入、一輪計算(多數題目)。
類型 B:同一組問題重複輸入多次(例如輸入多組英文名字)。
如果要同時描述「一輪」和「多輪」,比較自然的設計是:
一輪 → 物件:{ age, name }
多輪 → 物件陣列:[{ age, name }, { age, name }, ...]。
目前的 runCliQuestions 把「問題流程編排」和「一輪 / 多輪」混在一起,導致回傳結構無法同時清楚對應這兩種情境。
為了解決上面的兩個問題,下一步決定讓設計演化成:
抽出一個只處理「一輪問答」的函式 askOneRound:
輸入:questionConfigs + askWithValidator。
輸出:單一物件 { age: 25, name: 'Alice' }。
runCliQuestions 改為:
負責決定要呼叫 askOneRound 幾輪。
支援 rounds = 1 預設值。
回傳物件陣列 [{...}, {...}, ...],適合「多輪多筆資料」。
角色分工會變成:
askOneRound:一次表單填寫 / 一個人的資料。
runCliQuestions:多次表單填寫 / 多個人的資料,預設一次。
這部分的實作會在下一篇從紅燈 6 開始記錄。
從 TDD 的角度來看,這次不是「功能需求被 PM 改」,而是:
一開始只知道要「串多題 CLI」。
經過紅燈 1–5 的實作,逐步看見真正的抽象模式是「一輪 vs 多輪」。
最後決定讓程式設計對齊這個更抽象、語意更清晰的結構。
這很符合 TDD 常提到的「設計隨測試浮現(emergent design)」:
不是一開始就設計好所有 API,而是透過測試與重構,讓設計慢慢長出來。
這次實作過程中,紅燈 1–5 都還沒刻意拆 commit。事後回頭看,可以給自己一個下次的 commit 準則:
只在測試全綠時 commit
紅燈時不要 commit。
綠燈(包含重構後仍綠燈)時,才把這一小步的成果記錄下來。
每個 commit 代表一個「可以說得出口的小故事」
例如:
feat: add basic runCliQuestions pipeline
feat: make runCliQuestions async and call askWithValidator
refactor: extract askOneRound from runCliQuestions
避免把不相關的改動塞在同一個 commit 裡。
假設這次從頭來過,可以這樣切:
commit 1:
建立最初的 dummy 版 runCliQuestions,回傳 "OK" 或 questionConfigs。
message:feat: add initial runCliQuestions stub
commit 2:
支援多題問題流程,回傳 dummy 結構(物件陣列)。
message:feat: support multiple questions in runCliQuestions
commit 3(紅燈 5 完成):
引入 mock 版 askWithValidator,讓 runCliQuestions 使用 async/await 呼叫它並組裝真實答案。
message:feat: make runCliQuestions async and call askWithValidator
commit 4(未來抽出 askOneRound):
把「一輪問答」抽出成 askOneRound,runCliQuestions 加上 rounds 參數。
message:refactor: extract askOneRound and add rounds to runCliQuestions
整體原則是:
每一個「紅燈 → 綠燈 →(重構)」的完整小循環,或每 2–3 個非常小的循環,就值得打一個 commit。
Promise 不只是「語法糖」,而是「用固定欄位描述非同步狀態與結果的物件」,搭配高階建構函式與 chaining API(then / catch / finally)來組合非同步流程。
runCliQuestions 的 TDD 實作可以拆得很細:從 dummy 回傳、到回傳 dummy 結構、到多題流程、再到 async + mock 注入 askWithValidator。
用 Vitest 的 vi.fn().mockResolvedValueOnce(...) 可以很精確控制非同步函式每次呼叫的回傳值,適合測 CLI 這種「多次問答的流程」。
在紅燈 5 綠燈後,回頭檢查回傳結構與責任邊界,可以發現更好的抽象(askOneRound + runCliQuestions(rounds=1)),這是 TDD 下非常正常、也非常有價值的設計浮現過程。
git commit 不需要每一個紅燈都打一個,但可以在「每一個完整小故事」綠燈後 commit,讓歷史清楚又安全。
下一篇會從「紅燈 6」開始,實作:
askOneRound:單輪多題 → 回傳單一物件。
把 runCliQuestions 改成呼叫 askOneRound,並加上 rounds = 1 預設參數。
針對「多輪」情境(例如輸入四個英文名字)寫測試,確認物件陣列結構符合預期。
Share Dialog
Share Dialog
No activity yet