挙動を変えないリファクタの検証中に踏んだ詰みバグと、それを直した実装がさらに別のバグを抱えていた話。判断の流れを勉強のためにまとめた制作メモです。
内部構造を整理するだけの「挙動は変えない」リファクタを一区切りして、綴る画面(写真を1枚ずつ見て「また今度/リアクション/言葉を綴る」を決める画面)を実機で一周していた。自動テストは全部緑(=失敗ゼロ)だし、見た目も同じ。なのに、ある操作の並びで画面が先に進めなくなった。
踏んだ手順はこう。写真を「また今度」にする → その写真まで1枚戻る → mood(リアクション)を付ける → mood を外す → 「次へ」。ここで、次へ進むボタンが効かなくなる。
詰みの正体は frontier を「枚数」で数えていたこと
問題は、frontier を決めた写真の枚数で数えていたことだった。
// 決定済み = 「また今度」の枚数 + 「綴った または リアクションを付けた」枚数
// union = 2つの入れ物を重ねて1つにする。
// 両方に入っている写真(綴った上にリアクションも付けた)は 1枚として数える
int get frontier =>
_passedKeys.length + _writtenKeys.union(_reactedKeys).length;
// index = いま見ている写真が何枚目か。
// 「今いる位置」が「決定済みの右端」より手前のときだけ、次へ進める
bool get canAdvance => index < frontier;前から順に決めていく限り、これで合う。決めるたびに枚数が1つ増え、右端が1つ前へ動く。でも今回の手順は「一度決めた写真に戻って、その決定を崩す」。また今度にした写真に mood を付けて、その mood を外すと、その写真は「未決定」に逆戻りして、枚数が1つ減る。すると、決定済みが先頭から途切れずに並んでいる前提が崩れて、いま見ている写真のところに「未決定の穴」が空く。
写真1枚だけで追うとこうなる。
この数え方は、書かれた時点では正しかった。frontier を入れた頃、決定は「また今度」と「綴った」の2つしかなくて、どちらも決定済みに足すだけだった。決定済みから抜ける操作が1つも無いなら、決定済みは前から順に積み上がる一方で、枚数がそのまま右端に一致する。崩れたのは、あとからリアクションを足したとき。mood はもう一度押すと外せる=決定済みから抜ける経路が初めてできた。数え方のコードは1文字も変えていないのに、周りに取り消せる操作が1つ増えただけで、前提のほうが静かに成り立たなくなっていた。
そのリアクションを足したときの設計は 写真1枚に何を残したか — 状態を「集合」で持って union で数える に書いた。また今度/リアクション/綴ったの3集合に組み直して、共存する2つを union で1回だけ数える、というところまで。そこでは決定を取り消して未決定へ戻る経路のことは見ていない。
このリファクタで作り込んだバグじゃない。リアクションを足した時点から潜っていて、挙動を変えないはずの検証が、たまたまその穴を踏み抜いた形だ。
「mood を外したら また今度 に戻す」で直すことにした
直し方はいくつかあったけれど、frontier の数え方そのものをいじると波及が大きい。この値は「次へ進めるか」だけじゃなく、「全部決め終わったか」の判定と、進捗表示(何枚中の何枚か)にも使い回している。数え方を変えると、その3つが一緒に動く。選んだのは、mood を外したときに『また今度』に戻すという案(案A と呼んでいた)。写真を未決定に落とさなければ、決定済みの列に穴が空かない=詰まない。
そのために、cursor(綴る画面の状態を持つ不変オブジェクト)に「この写真の mood は、元がまた今度だった」を覚えるフラグを持たせた。フラグと言っても真偽値ひとつではなく、写真ごとに旗を立てるので、_reactedFromPassed という集合(旗を立てた写真の key を放り込んでおく袋)を足してもらい、また今度→リアクションの遷移のときに記録し、mood を外すときにそれを見て、また今度へ戻す。画面の状態だけ戻しても DB に「また今度」の行が無いままなので、DB 側も mood の削除と また今度 の復元をひとまとめ(トランザクション)で戻すようにしてある。この記事で追うのは前者、画面側の記憶のほうだ。
// また今度→リアクションのとき「元がまた今度だった」を覚える
TsuzuruSessionCursor markReacted(String key) {
final wasPassed = _passedKeys.contains(key); // この写真、元は「また今度」だった?
return _copyWith(
// 波かっこ+ドット3つ = 元の袋を壊さずコピー。そこから 1枚 抜く / 1枚 足す
passedKeys: {..._passedKeys}..remove(key), // また今度の袋から外す
reactedKeys: {..._reactedKeys, key}, // リアクションの袋へ入れる
reactedFromPassed: wasPassed
? {..._reactedFromPassed, key} // ← また今度由来なら覚える
: ({..._reactedFromPassed}..remove(key)),
);
}
// mood を外すとき、覚えていたら また今度 へ戻す
TsuzuruSessionCursor unmarkReacted(String key) {
// 「元がまた今度」と覚えていて、かつ まだ言葉を綴っていない写真だけ戻す
// (綴ってあるなら決定済みのままでいい=また今度に戻す必要がない)
final restorePassed =
_reactedFromPassed.contains(key) && !_writtenKeys.contains(key);
// 以下省略: リアクションの袋から外し、restorePassed なら また今度の袋へ戻す
}テストを足して緑にして、実機でも「また今度→mood付け→外す→また今度に戻る」を確認した。詰みは消えた。ここで一度、直ったつもりでいた。
直したはずが、mood を「付け替える」と戻らない
もう一周していて、また詰みが再発した。ただし毎回じゃない。「出ない時がある」という、いちばん厄介な出方だった。
再現条件を絞ると、mood を付け替えたときだった。また今度にした写真に mood A を付けて、気が変わって mood B に変えて、それから外す。この並びだと、また今度に戻らず未決定に落ちて、また詰む。
犯人は、さっきの markReacted の**「そうでなかったとき」側**だった。
reactedFromPassed: wasPassed
? {..._reactedFromPassed, key}
: ({..._reactedFromPassed}..remove(key)), // ← 外れたとき。ここで記憶を消していたのが罠「もう passed じゃないんだから、また今度由来の記憶も要らないよね」と消しにいく実装になっていた。だから付け替えたあとに外すと、戻り先の記憶が無くなっていて、未決定に落ちる。
なぜ外れたとき側に「消す」を書いてしまうのか。cursor は不変オブジェクトで、更新は「今の値をコピーして一部だけ差し替える」形で書く。差し替えないフィールドは渡さなければ据え置きなので、触らないことは書ける。ただし条件分岐を三項演算子で式の中に持ち込んだ瞬間、外れたとき側にも必ず何かを書かされる。そこで「今の値をそのまま書き戻す」が思いつかないと、空欄を埋める気持ちで「それっぽい後始末」を書いてしまう。
ただし「if で書いていれば避けられた」という話ではない。else を書かなければ黙って据え置きになるが、それは据え置きが正解だっただけで、もし正解が「消す」のほうだったら、今度は何も書かされないまま旗が永久に残る ── しかも誰にも催促されないぶん、気づく機会はもっと少ない。三項演算子は問いがあることだけは見せてくれる。if は問いごと隠す。どちらも、問いに答える代わりにはならない。
直し方は、外れたとき側でフラグを消さず保持するだけだった。今の値をそのまま書き戻す=「何もしない」の明示形になっている。
reactedFromPassed: wasPassed
? {..._reactedFromPassed, key}
: _reactedFromPassed, // 消さず保持。クリアは「外す/また今度/綴る」のときだけフラグを消していい瞬間は3つに絞れる。mood を外したとき、明示的にまた今度にしたとき、言葉を綴ったとき。この3つ以外では触らない。「mood を別のに付け替える」はそのどれでもないから、記憶は残すのが正しかった。付け替え→外すのテストを足して、今度こそ緑にした。
セットより「消さない条件」を詰め損ねていた
書いていて腑に落ちたのは、フラグを足すとき「どういう時に立てるか」ばかり考えていて、「どういう時に消すか(消してはいけないか)」を詰めていなかったこと。立てる条件は、また今度→リアクションという分かりやすい入口があるから自然に書ける。消す条件は、その写真が「リアクション付き」から抜けていく出口を全部数え上げないと漏れる。今回は「同じ reacted のまま mood だけ変わる」という、状態が実質変わらない遷移を見落としていた。コードは書いてもらえても、こういう遷移の穴は操作の並びを手で試さないと出てこない。
手順に落とすなら、状態を1つ足すときに3つ揃える、になる。
2つの層は、別々の話に見えて形は同じだった。frontier は「決定は増える一方」を前提に枚数で数えていて、あとから mood の取り消しが入って崩れた。フラグのほうは「立てる」ばかり見ていて、あとから mood の付け替えが入って崩れた。どちらも、あとから「やり直せる操作」を足したときに、やり直しを想定していない古い前提が残っていた、という同じ壊れ方だ。
もう一つは、挙動を変えないリファクタの検証が、リファクタとは無関係の既存バグをあぶり出して、その修正がさらに次のバグを連れてきた、という入れ子だったこと。1枚めくると下からもう1枚出てくる感覚で、テストが緑でも実機で操作の並びを変えると別の層が見える。詰みが「毎回」じゃなく「時々」だったのも、この最後の層まで降りて初めて再現条件(付け替え)が言葉になった。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。