昨天櫃檯程式跑起來了,擋下了那一次重複借出:
> 會員編號:ISBN:✗ 《兩個人的偵探》已經在 2025-12-28 借給 李子涵,還沒回來。
那個✗是Lend裡的一個if。問題來了:怎麼知道它真的擋得住?
昨天那次是筆者手動輸入一個借出中的ISBN,看到訊息跳出來。這種驗證方式有兩個問題:下次改動程式碼之後沒有人會再手動試一遍,而且店裡真正會出事的情況不只這一種。
所以今天不加功能,把規則一條一條釘死。
昨天有一支Ironman.SeedData.Tests,那是產生器的。櫃檯規則要自己的一支:
dotnet new xunit -n Ironman.Desk.Tests -o tests/Ironman.Desk.Tests
dotnet sln add tests/Ironman.Desk.Tests
dotnet add tests/Ironman.Desk.Tests reference src/Ironman.Desk
分開的理由很簡單:產生器壞掉和櫃檯規則壞掉是兩件事,紅燈亮在哪一支就知道要看哪裡。
前九條不用昨天那一百本書。
// 測試不用種子資料,自己造三本書、兩位會員,規則才看得清楚。
private static readonly Book 書A = new("9780000000001", "書A", "作者甲");
private static readonly Book 書B = new("9780000000002", "書B", "作者乙");
private static readonly Book 書C = new("9780000000003", "書C", "作者丙");
private static readonly Member 小明 = new("M00001", "小明", "0911-000-001");
private static readonly Member 小華 = new("M00002", "小華", "0911-000-002");
private static readonly DateOnly 今天 = new(2026, 1, 5);
private static RentalDesk 空櫃檯() => new([書A, 書B, 書C], [小明, 小華], []);
種子資料的用途是重現同一家店:五百筆借閱、三十七筆未歸還,跨篇量測才比得出差別。規則測試要的是另一件事,一眼看得出這條在講什麼。小明借了書A,小華再借書A,讀的人不用回頭查《兩個人的偵探》是誰借走的。
第十條是例外,它刻意載入種子資料,等一下會說為什麼。
ISBN 是算過檢查碼的。RentalDesk現在不驗格式,但昨天說過假資料也守自己的規則——哪天加上驗證,這些跟借還無關的測試不該被連累。
借出要擋三件事:會員編號店裡沒有、這本書沒建檔、這本書還在別人手上。
[Fact]
public void 借給不存在的會員會被擋下()
{
var desk = 空櫃檯();
var ex = Assert.Throws<RentalException>(() => desk.Lend("M99999", 書A.Isbn, 今天));
Assert.Contains("找不到會員", ex.Message);
Assert.True(desk.IsAvailable(書A.Isbn)); // 沒有留下半筆紀錄
}
注意最後那一行。它檢查的不是「有沒有丟出例外」,是被擋下的那一次借出,不能在帳上留半筆。
這一行很容易被省略,而它擋的是一種很難查的錯:程式先把紀錄加進去、才發現會員不存在、然後丟例外——櫃檯螢幕顯示失敗,資料裡卻多了一筆。店員以為沒借成,系統以為借了。下一位客人拿同一本書來借,螢幕會說這本書已經借出去了,而誰也找不到是誰借的。
第三條是目前這個模型最不能破的規則:每種書只有一本,同一時間只能有一筆未歸還。
紙卡時代靠的是實體——書借出去了,書本人不在架上,第二位客人根本拿不到。現在書還在店裡、資料在記憶體裡,只剩這個判斷式擋著:
[Fact]
public void 同一本書借出中不能再借給別人()
{
var desk = 空櫃檯();
desk.Lend(小明.MemberId, 書A.Isbn, 今天);
var ex = Assert.Throws<RentalException>(() => desk.Lend(小華.MemberId, 書A.Isbn, 今天));
Assert.Contains("借給 小明", ex.Message);
Assert.Single(desk.OutstandingLoansOf(小明.MemberId));
Assert.Empty(desk.OutstandingLoansOf(小華.MemberId));
}
第一個斷言守的是櫃檯訊息:店員要說得出是誰借走的,才知道怎麼跟客人解釋。另外兩個守的是資料:小明那一筆還在,小華那邊什麼都沒有。
另一條「借不存在的書會被擋下」長得跟上面那條一樣,就不重貼了。
歸還有三個情境:正常還完可以再借、客人拿來一本店裡根本沒借出去的書、歸還日早於借出日。第二條和借出那兩條長得一樣,這裡貼第一條和第三條。
[Fact]
public void 歸還後這本書可以再借()
{
var desk = 空櫃檯();
desk.Lend(小明.MemberId, 書A.Isbn, 今天);
var returned = desk.Return(書A.Isbn, 今天.AddDays(7));
var again = desk.Lend(小華.MemberId, 書A.Isbn, 今天.AddDays(8));
Assert.Equal(今天.AddDays(7), returned.ReturnDate);
Assert.Equal(小華.MemberId, again.MemberId);
Assert.Empty(desk.OutstandingLoansOf(小明.MemberId));
}
[Fact]
public void 歸還日早於借出日會被擋下()
{
var desk = 空櫃檯();
desk.Lend(小明.MemberId, 書A.Isbn, 今天);
var ex = Assert.Throws<RentalException>(() => desk.Return(書A.Isbn, 今天.AddDays(-1)));
Assert.Contains("日期打錯了", ex.Message);
Assert.False(desk.IsAvailable(書A.Isbn)); // 還是借出中
}
第三條的情境不是客人搞鬼,是店員手滑把日期打錯。擋下來之後這本書要維持借出中——錯誤的輸入不能把帳改壞,這和借出那一條是同一件事。
日期是從外面傳進來的參數,所以測試可以固定今天是哪一天,不用等到真的過了七天。
昨天提過歸還是用with造一筆新紀錄換掉舊的。換的時候要先找到舊的那一筆在哪裡:
var returned = outstanding with { ReturnDate = today };
var index = _loans.IndexOf(outstanding);
_loans[index] = returned;
這段現在沒問題:outstanding本來就是從_loans裡撈出來的那一筆,找得到它。
真正的前提在更上面一層。Loan記的是Isbn——一個書目編號,不是店裡那一本實體書:
public sealed record Loan(string MemberId, string Isbn, DateOnly LoanDate, DateOnly? ReturnDate);
每種書只有一本的時候,書目編號就足以指出是哪一本書。第六天老闆會多買一本同樣的書,同一個ISBN在店裡有兩本;客人拿一本來還,程式從Isbn看不出還的是哪一本,也看不出該把哪一筆借閱結掉。
那一天要先讓每一本實體書有自己的識別,Loan才知道自己指向誰。
這種「現在對、但靠著某個前提」的地方,值得在它還對的時候就寫下來。等它壞掉的時候,通常已經沒有人記得當初為什麼這樣寫。
週末下午客人抱著三本書到櫃檯不是稀奇事。
[Fact]
public void 一次借三本是三筆紀錄()
{
var desk = 空櫃檯();
desk.Lend(小明.MemberId, 書A.Isbn, 今天);
desk.Lend(小明.MemberId, 書B.Isbn, 今天);
desk.Lend(小明.MemberId, 書C.Isbn, 今天);
Assert.Equal(3, desk.OutstandingLoansOf(小明.MemberId).Count);
Assert.Equal(3, desk.LoanCount);
}
前面九條擋的是會出錯的事。這一條不一樣,它擋的是之後某個人的好主意。
紙卡時代這是三張借閱卡各寫一行,沒有「一次借閱包含三本書」這種東西。但這個系統只要活得夠久,就會有人(包括筆者自己,或者一個被要求「把資料結構做得更好」的AI)想加一個LoanItems,把三本書包成一張單。
包起來會壞掉的是還書:客人只還其中一本,那張單要算還了還是沒還。
所以這一條釘的不是一條規則,是一個決定——一次借還就是一筆。
Ironman.Desk.Tests今天才開,十條全是新的:
借出後多一筆未歸還紀錄
借給不存在的會員會被擋下
借不存在的書會被擋下
同一本書借出中不能再借給別人
歸還後這本書可以再借
歸還沒借出去的書會被擋下
歸還日早於借出日會被擋下
一次借三本是三筆紀錄
搜尋書名用關鍵字
載入種子資料後的未歸還數量和產生器一致
Passed! - Failed: 0, Passed: 10, Skipped: 0, Total: 10, Duration: 22 ms - Ironman.Desk.Tests.dll (net10.0)
從方案根目錄跑是二十六筆:產生器十六、櫃檯十條。
最後一條就是前面說的那個例外。它載入昨天那份種子資料,檢查櫃檯算出來的未歸還筆數和產生器自己算的一樣——昨天啟動畫面那個「未歸還 37 筆」如果哪天對不上,這條會先叫。前九條把規則講清楚,這一條把兩邊接起來。
然後是今天真正該停一下的地方:十條測試寫完,src/底下一行都沒有改。
$ git diff day03 day04 -- src/
(沒有輸出)
這代表昨天那三個throw本來就寫對了。但也代表今天這十條測試沒有發現任何問題——它們追認了昨天寫下的東西,而不是挑戰它。
測試在這種時候最沒有說服力:全綠,不表示規則是對的,只表示程式做的事和筆者以為它做的事一致。真正會檢驗這些規則的是第六天,老闆多買一本書的時候。
今天的程式碼會放在day04這個tag:
git clone --branch day04 https://github.com/EugeneSu0515/highflyer-ironman.git
cd highflyer-ironman
dotnet test
規則有了、測試有了,但老闆還沒問過那個最現實的問題:這些東西用Excel不行嗎。
第五天筆者真的開一份試算表做一版,然後看它撐到哪裡。
昨天結尾問的那個問題——FindBook是一路掃過去的,一百本很快,兩千本呢——今天沒有動它,因為還沒有人抱怨慢。第七天店裡會真的開始等,那時再量。
這個系列是筆者正在寫的一套教學內容的濃縮版;完整版含每一次量測的原始輸出與更長的決策紀錄,三十天結束後會繼續往下走。
程式碼:https://github.com/EugeneSu0515/highflyer-ironman