綴Photo の勉強ノートに戻る
技術ブログ2026.07.28

何も送っていないのに「全部うまくいった」— 成功判定を1本にまとめる途中で手が止まった話

DartFlutterリファクタリングテスト綴Photo

AIレビューで「結果の解釈が呼び出し側に散っている」と言われて、まとめにいった時の記録です。まとめる作業そのものより、途中で手が止まった1箇所のほうが書くことが多かった。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

サーバに綴りを送る処理は、送信のパイプ自体はとっくに1本にまとめてあった。詳細画面から「web に載せる」を押しても、設定画面から「同期」を押しても、通る道は同じ1本。そこは前に一度、片方だけ壊れる事故をやって直したところだった。

でも結果をどう読むかは、4箇所バラバラのままだった。

同じ形なのに、基準だけが違った

呼び出し側4箇所を並べるとこうなる。

呼び出し元 「成功した」と判断する条件
詳細画面・公開する acceptedCount > 0
詳細画面・公開をやめる 無条件(例外が飛ばなければ成功)
設定・同期 無条件
設定・画像アップロード 無条件

4つとも try して、認証エラーを捕まえて、それ以外は中身を捨てて(catch (_))、丸めた文言で返します。形はコピペしたみたいに同じなのに、成功の基準だけが揃っていません。

1件ごとの結果は HTTP 応答のメモリ。DB にも prefs にも残さない。画面は4つ詳細・公開する詳細・公開をやめる設定・同期設定・画像アップロード押す場所は違う送り先は同じ送るサーバ送信は1本1件ごとに受け取り既にあった / 失敗返る届いた resultsメモリ・保存しない成功の読みは4箇所公開だけ件数が要るほか3つは無条件基準が揃っていない

文言は画面ごとに違うので、判定も画面のそばに置いたままになります。

ちょうどいい判定が、既に置いてあった

集計クラスを開いたら、探していたものが最初から置いてあった。

dart
/// 全アイテムが 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 になります。

dart
<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 に乗り換えるというのは、その守りを外して、同じ穴を開け直すことだった。

集計 results はメモリ。画面のトーストは保存しない。0件(画面の古いデータで対象が落ちた)空の resultsメモリ・POSTしていないallOk は true空でも同じ印画面「送信しました」0件なのに成功に見える全部サーバに拒否されたfailed が並ぶサーバ応答・メモリallOk は false破る要素がある画面は止まるここは正しい

リファクタで揃えようとしていたのに、揃えた先が一番ゆるい判定だった。

空を「成功」から外す

やったことは、判定の順番を変えただけ。「何も無い」を先に別の結末として抜く。

dart
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;
}
集計 results は HTTP 応答のメモリ。結末の名前もメモリ。保存しない。届いた results1件ごとの返事メモリ・保存しない空なら POST していない件数 0 を先に見るそのあと受理を見る0件を先に抜くnothingToDo何も無い。成功から外す拒否が混ざるpartiallyFailed届いたが一部が失敗新規 0 件alreadyUpToDateサーバは既に同じ状態新規ありsucceeded新しく受理された

結末を true / false の2択でなく、名前の付いた6つにした。

結末 意味
succeeded 新しく受理された
alreadyUpToDate 送ったが、サーバは既に同じ状態だった
nothingToDo 送るものが無かった
partiallyFailed 届いたが一部が失敗して返ってきた
unauthenticated ログインが要る(サーバは叩いていない)
failed 通信・サーバ・DBのどこかで落ちた

こう並べてみると、無条件成功だった側の bool(はい/いいえの2つしか持てない型)が4つの違うものを1つの true に潰していたのが分かる。「何も無かった」と「もう終わってた」と「新しく送れた」は、ユーザーに言うべきことが全部違います。

画面側は、この名前を見て文言を決めるだけになった。

テストには、罠のほうを書く

ここでいちばん迷ったのが、テストの書き方だった。

nothingToDo を期待するテストを1本書けば、それで固定はできます。でもそれだとなぜそこを気にしているのかが残りません。次に読んだ人(半年後の自分を含む)が「空なら nothingToDo、まあそうだろうな」と流して、allOk に戻してしまうかもしれません。

なので、罠のほうを先に書いてもらった。

dart
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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。