配合分支:day25/start,Repo: 按我
昨天把「改心情」收回 ViewModel 裡頭,封裝得漂漂亮亮的,來驗收一下。隨便挑一張卡片,把心情從「震撼」改成「專注」,畫面馬上變了,用 Day 22 做的心情過濾去篩「專注」也找得到。看起來一切都很完美,差不多是該出事的時候了。
把 App 強制停止(Force stop)再打開⋯ 心情變回「震撼」了 =. =

照慣例,先不要叫 Agent 修,先來做 trace。
改心情的資料流,昨天才走過一次:

終點是 entries,也就是黏在 ViewModel 上的那份日記列表。再看一次 updateMood 本人:
fun updateMood(id: String, mood: Mood) {
val updatedEntries = entries.map { diaryEntry ->
if (diaryEntry.id == id) {
diaryEntry.copy(mood = mood)
} else {
diaryEntry
}
}
entries = updatedEntries // 👈🏼 到這裡就結束了
}
用 id 找到那一篇、copy() 一份新心情的(還記得 Day 06 的 copy() 嗎)、塞回 entries。畫面看的就是 entries(經過 getDisplayEntries() 過濾排序之後),所以畫面當然是對的。
那重開之後的 entries 是從哪裡來的?Day 12 做過:ViewModel 一建立,會透過 diaryStore.load() 把 diaries.json 讀回來。所以重開後看到的,是裝置儲存空間裡的那一份,不是剛剛記憶體裡改好的那一份。
假設:改心情的時候,根本沒有寫回 diaries.json!?
找 ViewModel 裡頭其他會動到 entries 的方法來對照。一個是新增:
fun saveDiary(title: String, note: String) {
// ... 省略
entries = listOf(entryToSave) + entries
diaryStore.save(entries) // 👈🏼 有存
// ... 省略
}
一個是刪除:
fun deleteDiary(id: String) {
// ... 省略
entries = updatedEntries
if (wasDeleted) {
diaryStore.save(entries) // 👈🏼 也有存
}
}
兩個都一樣:改完 entries,把整份列表丟給 diaryStore.save()。再回頭看 updateMood,改完 entries 就下班了ㄏ。
假設成立。
修 bug 之前,先把「預期的行為」寫成測試。打開 PhotoDiaryViewModelTest,發現昨天已經有一條在測 updateMood 了,雖然我昨天都沒提到但原始碼的部分,我是很認真在更新 😭。
看一下名字:
@Test
fun updateMood_changesTargetByStableId_preservesOtherEntriesAndDoesNotSave() {
// ... 省略
// Then:確認 target 保留 ID 並更新心情,其他日記完整值與順序不變,且沒有保存
assertEquals(0, diaryStore.saveCallCount) // 👈🏼 =. =
}
DoesNotSave。「且沒有保存」。昨天寫測試的時候,還很認真的驗證了「改心情之後不會存檔」,等於親手幫這隻臭蟲(bug)寫了一張保證書,測試還是 Pass。小丑竟是我自己 🤡
這是測試的一個小陷阱:測試通過,只代表程式跟測試講的一樣,不代表測試講的是對的。 所以今天要改的是這條測試的「預期」,不是再多加一條跟它打架的。
新的預期有兩件事:
save() 要被叫剛好一次
FakeDiaryStore 再 new 一個 ViewModel,讀回來的那篇日記要是新心情第 2 點是重點。測試裡沒辦法真的把 App 關掉重開,但可以模仿:重開說穿了就是「新的 ViewModel 去跟同一個 store 要資料」,那再 PhotoDiaryViewModel(application(), diaryStore) 一次就好了。

不過還卡在一個地方:Day 18 做的 FakeDiaryStore,load() 永遠回傳建構時給的 initialEntries,就算 save() 過了它也不記得。假的也要假得夠像,所以補一個「目前的列表」讓 save() 更新、load() 回傳:

跑!

expected:<1> but was:<0>
Failed 了,而且錯誤在對的地方:save() 被叫了 0 次。這個「測試改好、程式還沒修」的狀態留在分支 day25/persistence-contract-fails,想看純測試的 diff 可以切過去。
配合分支:day25/fixed,Repo: 按我
講了這麼多,修正只有一行:
fun updateMood(id: String, mood: Mood) {
val updatedEntries = entries.map { diaryEntry ->
// ... 省略
}
entries = updatedEntries
diaryStore.save(entries) // 👈🏼 加這行
}
跟新增、刪除長得一模一樣,改完記憶體,整份列表寫回去。再跑一次測試:

想確認測試真的有在保護,故意把剛加的那行拿掉再跑一次,應該只有這條 Failed、其他 7 條照樣 Pass,確認完記得加回來。這招叫 Mutation Testing(變異測試):故意把程式弄壞,看測試會不會叫。
測試裡的 FakeDiaryStore 終究是假的,真的 JSON 有沒有寫進去,用 Day 11 學過的 Device Explorer 看 diaries.json 最實在:

再強制停止、重開:

畫面對了,只代表記憶體(RAM)裡的東西對了;Day 11 就說過,App 一關記憶體就清空,能活下來的只有寫進裝置儲存空間的那份。所以 ViewModel 改資料的時候,不能只顧好自己手上的 entries,還要把 Day 18 用依賴注入塞進來的 DiaryStore 用完。新增和刪除都乖乖用了,只有改心情忘了。
有讀者可能會問:為什麼不幫 DiaryStore 加一個 update(id, mood) 就可以只改一篇?
e.g.
interface DiaryStore {
fun load(): List<DiaryEntry>?
fun save(entries: List<DiaryEntry>)
fun update(id: String, mood: Mood) // 👈🏼 只更新那一篇
}
每次整份重新倒進去聽起來很笨。(現在只能整份 save)
但但但 Day 23 才走火入魔過,現成的 save() 已經可以解決今天的問題,就先不要超前部署,等哪天日記多到整份重寫會卡再說(Day 19 講過的提早最佳化 ^_<~* )。
Day 21 到今天這五天,其實都在做同一件事:讓「改資料」這件事站得住腳。
getDisplayEntries() 換一份「給畫面看的」列表,來源 entries 不動DiaryStore 用完diaries.json。saveDiary、deleteDiary 改完都有 diaryStore.save(),只有 updateMood 沒有。diaryStore.save(entries):整份列表寫回內部儲存空間;今天的修正就是在 updateMood 補上這一行。FakeDiaryStore 的 save() 要把列表留給下一次 load(),不然重開的模仿不成立。diaries.json 確認同一個 id 的 mood 真的變了。