AIレビューで「結果の解釈が呼び出し側に散っている」と言われて、まとめにいった時の記録です。まとめる作業そのものより、途中で手が止まった1箇所のほうが書くことが多かった。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
サーバに綴りを送る処理は、送信のパイプ自体はとっくに1本にまとめてあった。詳細画面から「web に載せる」を押しても、設定画面から「同期」を押しても、通る道は同じ1本。そこは前に一度、片方だけ壊れる事故をやって直したところだった。
でも結果をどう読むかは、4箇所バラバラのままだった。
同じ形なのに、基準だけが違った
呼び出し側4箇所を並べるとこうなる。
| 呼び出し元 | 「成功した」と判断する条件 |
|---|---|
| 詳細画面・公開する | acceptedCount > 0 |
| 詳細画面・公開をやめる | 無条件(例外が飛ばなければ成功) |
| 設定・同期 | 無条件 |
| 設定・画像アップロード | 無条件 |
4つとも try して、認証エラーを捕まえて、それ以外は中身を捨てて(catch (_))、丸めた文言で返します。形はコピペしたみたいに同じなのに、成功の基準だけが揃っていません。
文言は画面ごとに違うので、判定も画面のそばに置いたままになります。
ちょうどいい判定が、既に置いてあった
集計クラスを開いたら、探していたものが最初から置いてあった。
/// 全アイテムが accepted または duplicated のとき true(空リストは vacuous true)。
bool get allOk => results.every(
(r) =>
r.status == SyncItemStatus.accepted || // 新しく受け取られた
r.status == SyncItemStatus.duplicated, // サーバに既に同じものがあった
);サーバは、送った綴り1件ごとに「受け取った / 既にあった / 失敗」を返してきます。その1件分ずつを並べたのが results で、allOk はそれを全部見て回ります。送るものが0件なら、返ってくる results も空になります。
allOk。名前も意味もそのまま。しかも本番のコードからは一度も呼ばれていなかった(テストからは呼ばれている)。使われないまま置いてある正しい部品に見えた。
これを成功判定の入口にすれば、4箇所の基準が一発で揃う。そう思って書き換えようとして、コメントの括弧書きで手が止まった。
「空リストは vacuous true」。
空のリストは、すべての条件を満たしている
every はリストの中身を1つずつ見て「全部が条件を満たすか」を返します。要素が1つも無ければ、条件を破る要素も1つも無いので true になります。
<int>[].every((n) => n > 100); // true(<int>[] は空のリスト。中を1つも見ずに true)
<int>[].every((n) => n < 0); // true(さっきと矛盾しているように見える)その罠は、過去に一度踏んでいた
allOk に乗り換えると何が起きるかを考えて、少し嫌な感じがした。詳細画面だけが持っていた acceptedCount > 0 という条件に、こういうコメントが付いていたからだ。
要約すると「送る綴りが1件も無かった時に『送信しました』と出さないため」。0件なのに成功トースト(画面の下に一瞬だけ出る、あの通知)を出すのは嘘になる、と書いてある。
これは想像で書かれた注意書きではなかった。前に一度、実際に0件になっている。
詳細画面が画面の持っている古いデータをそのまま送信に渡していた時期があって、送信直前のフィルタで対象が丸ごと落ちていた。押しても何も送られず、それでも画面は成功したように見える。その事故を直した時に、二度と静かに0件にならないよう acceptedCount > 0 が置かれた。
つまり allOk に乗り換えるというのは、その守りを外して、同じ穴を開け直すことだった。
リファクタで揃えようとしていたのに、揃えた先が一番ゆるい判定だった。
空を「成功」から外す
やったことは、判定の順番を変えただけ。「何も無い」を先に別の結末として抜く。
SyncOutcome syncOutcomeOf(SyncResultSummary summary) {
// 空を先に抜く。ここを通さずに allOk を見ると、0件が成功に見える。
if (summary.total == 0) return SyncOutcome.nothingToDo;
if (!summary.allOk) return SyncOutcome.partiallyFailed;
// 全部 duplicated=サーバは既に同じ状態。失敗ではないが「送った」とも違う。
if (summary.acceptedCount == 0) return SyncOutcome.alreadyUpToDate;
return SyncOutcome.succeeded;
}結末を true / false の2択でなく、名前の付いた6つにした。
| 結末 | 意味 |
|---|---|
succeeded |
新しく受理された |
alreadyUpToDate |
送ったが、サーバは既に同じ状態だった |
nothingToDo |
送るものが無かった |
partiallyFailed |
届いたが一部が失敗して返ってきた |
unauthenticated |
ログインが要る(サーバは叩いていない) |
failed |
通信・サーバ・DBのどこかで落ちた |
こう並べてみると、無条件成功だった側の bool(はい/いいえの2つしか持てない型)が4つの違うものを1つの true に潰していたのが分かる。「何も無かった」と「もう終わってた」と「新しく送れた」は、ユーザーに言うべきことが全部違います。
画面側は、この名前を見て文言を決めるだけになった。
テストには、罠のほうを書く
ここでいちばん迷ったのが、テストの書き方だった。
nothingToDo を期待するテストを1本書けば、それで固定はできます。でもそれだとなぜそこを気にしているのかが残りません。次に読んだ人(半年後の自分を含む)が「空なら nothingToDo、まあそうだろうな」と流して、allOk に戻してしまうかもしれません。
なので、罠のほうを先に書いてもらった。
test('空は成功と呼ばない(allOk は空で true になる=罠)', () {
final summary = summaryOf(const []);
// 先に前提を確かめる: allOk だけ見ると空が成功に見える。
expect(summary.allOk, isTrue); // expect(調べたい値, 期待する結果)の形
expect(syncOutcomeOf(summary), SyncOutcome.nothingToDo); // 本題: 空(0件)は nothingToDo になるべき
});1行目の expect は、このテストの主題(syncOutcomeOf)については何も検証していません。「この値は true なんですよ」という事実を、テストの中に置いているだけ。でもこれがあると、次に allOk を直接使おうとした人が、その手前でこの行を読むことになります。
コメントに書くのと違って、allOk の挙動が将来変わったらこの行が落ちます。説明が壊れたことに気付ける説明になっています。
見つけた穴を、直さなかった
ついでの話をひとつ。
結末に名前を付けた瞬間、既存の穴が2つ見えた。取り消しと全件同期が partiallyFailed(一部失敗)を成功として表示していることと、公開が「一部でも受理されたら送信しました」と出していること。どちらも1行で直せる位置にあった。
直していない。
この作業は「見た目も挙動も変えない、器だけ作る」と決めて始めたもので、ここで挙動を直すと、あとで何か変になった時に器のせいなのか、直したせいなのかが切り分けられなくなります。代わりに、その分岐に「これは現状維持・別の作業で消える」とコメントを置いた。
別ファイルのメモにも項目は立っている。ただしそこを読まずにこのコードだけ触る可能性があるので、消える予定の分岐そのものにも同じことを書いた。
学び
- 既にある便利な判定に乗り換える時、境界条件が元の判定と同じとは限りません。同じことをしているように見えても、片方だけが守っていたものがあります(
everyが空で真になるのは、その一例) - 「何も無い」を「成功」と同じ入口に入れません。空・ゼロ・未設定は、それ自体を1つの結末として名前を付けたほうが、あとで文言を決めるのが楽になります
- 1回の変更で答える問いは1つにしておきます。道中で見つけた穴を直すと、あとで何か変になった時に切り分けられません
allOk が本番から一度も呼ばれていなかった理由は、結局分からないままだ。書いた時に使うつもりだったのか、使おうとして同じところで止まったのか。少なくとも今回は、使われていない正しそうな部品が、そのまま使える部品ではなかった。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。