綴Photo の勉強ノートに戻る
開発記録2026.05.14

「写真なし月を隠す」が未来月まで隠せる罠 — 境界条件の見落とし

FlutterDart境界条件Edge Case綴Photo

綴Photo のモバイル版で、期間選択画面に「写真なし月を隠す」というボタンがあります。月別の本棚から、写真が1枚もなかった月のスロットをまとめて非表示にしてくれる便利機能。

これが、今日が 5月14日でも 2026年6〜12月を「写真なし=完了」として隠せてしまう 状態だった。未来の月はそりゃ写真がなくて当然なので、本来ならスキャン対象に入れちゃいけません。

実害は小さいけど、地味に放置するとユーザーが「あれ?今年12月分のはずがもう完了になってる?」と混乱しえます。境界条件の見落としネタとして残す。


この記事の前提: Codex の指摘で気づいた

今回もコードを自力で読んで気づいたわけじゃなくて、Codex(別の AI)に第三者レビューを走らせた結果 で出てきた指摘。

レビューを走らせた時点で「今日が 2026年5月14日 の場合、2026年6〜12月も『写真なし』として完了扱いにできます。未来月はまだ写真がなくて当然なので、現在月までに制限し、UI側も存在しない月を合成しない方がよいです」と具体的に出てきた。

第三者レビューはコストかかるけど、「今日が何月か」みたいな実行時のコンテキストを踏まえた指摘は、コード読みだと取りこぼしやすいです。


元のコード: 「1〜12月」を機械的に回していた

問題のコードは2箇所にあった。

① 走査側(写真の有無をチェック)

dart
Future<List<(int year, int month)>> scanEmptyMonthsInYear({
  required int year,
  required Map<String, String> visitStatuses,
}) async {
  final permission = await PhotoManager.requestPermissionExtend();
  if (!permission.isAuth && !permission.hasAccess) return [];
 
  final result = <(int, int)>[];
  for (var m = 1; m <= 12; m++) {  // ← 1〜12月を機械的に回す
    final key = '$year-${m.toString().padLeft(2, '0')}';
    if (visitStatuses[key] == PeriodVisitStatusValue.completed) continue;
 
    final hasPhotos = await hasAnyPhotosInYearMonth(year: year, month: m);
    if (!hasPhotos) {
      result.add((year, m));
    }
  }
  return result;
}

② UI 側(スロットの合成)

期間選択画面では、写真があった月だけじゃなくて「写真はないけど綴れる可能性のある月」もスロットとして並べて、ユーザーが選べるようにしています。これも同じく 1〜12 をループしていた。

dart
final monthMap = {for (final month in months) month.month: month};
final slots = <(int, YearMonthOption)>[];
for (var i = 1; i <= 12; i++) {  // ← ここも 1〜12 で機械的に合成
  final key = '$year-${i.toString().padLeft(2, '0')}';
  if (visitStatuses[key] == PeriodVisitStatusValue.completed) continue;
  slots.add((i, monthMap[i] ?? YearMonthOption(year, i)));
}

今日が 2026-05-14 のとき、2026年で動かすとこうなります。

写真の有無は端末の写真ライブラリ。完了印は端末 SQLite の PeriodVisitStatus。スロットは画面メモリで、保存しない。12345今日67891011121〜5月はライブラリを見るある月とない月に分かれる6〜12月は未来。写真ゼロが当然写真なし → 完了印を書ける走査側1〜12月を機械的に回す期間選択のスロット1〜12月を合成する。画面メモリどちらも同じ 1〜12。未来月を対象から外していない。

修正: 「現在月まで」を境界に持ち込む

直し方は素直に「年が現在年なら現在月まで、過去年なら 12月まで、未来年なら走査しない」に分岐します。

dart
final base = now ?? DateTime.now();
final endMonth = year < base.year
    ? 12              // 過去年 → 12月まで全部走査
    : year == base.year
    ? base.month      // 今年 → 今月まで(先の未来月は見ない)
    : 0;              // 未来年 → 0(走査しない)
 
for (var m = 1; m <= endMonth; m++) {
  // ...既存ロジック
}

UI 側も同じロジックでスロットを止めます。

今日は OS の時計(テストでは引数 now)。完了印は端末 SQLite の PeriodVisitStatus。どこまで見るかは計算で、保存しない。過去年1〜12月まで全部見る未来月は無い今年(今日が5月)1〜5月まで見る6〜12月は見ない未来年0 なので 1回も回らない聞く相手が無い走査側端末の写真ライブラリを聞く。同じ月まで期間選択のスロット同じ月まで並べる。画面メモリ走査側と UI 側で同じ境界。計算の交差で、置き場所ではない。

片方だけ直すと、「UI には出るがスキャンされない月」という半端な状態が出ます。

endMonth = 0 で「ループ実行されない」を表現するイディオム

未来年(2027年など)は endMonth = 0 にしています。for (var m = 1; m <= 0; m++) は 1 回も実行されないので、自然に「走査しない」が表現できます。Dart に限らず C 系の for ループ全般で使える手。

DateTime? now を引数で受け取る理由

scanEmptyMonthsInYear には DateTime? now を引数として足してあります。これはテスタビリティのため。

「今日が何月か」をハードコードした DateTime.now() で実装すると、ユニットテストで「今日が 12月のとき」「今日が 1月のとき」を試すのに OS の時計をいじる必要が出ます(これはかなり面倒)。引数で受け取れるようにしておくと、テストでは now: DateTime(2026, 12, 1) のように好きな日付を渡せます。

既存の呼び出し元は何も変えなくていいので、後方互換も壊しません。


学んだこと

  • 「1〜12月を全部見る」みたいな単純なループは境界条件を見落としやすいです。「今日より未来」を含むかどうかは特に意識しないと抜けます
  • 走査側と UI 側で同じ境界条件を共有します。片方だけ直すと、「スキャンはされないけど UI には出る」「UI には出ないけどスキャンされてしまう」みたいな半端な状態が出ます。コメントでお互いを明示するのが安全です
  • DateTime.now() を直接呼ぶ関数はテスタビリティが落ちます。DateTime? now を引数で受け取って、デフォルトを DateTime.now() にする小さなパターンは横展開しやすいです
  • 第三者レビュー(Codex)は「今日が何月か」みたいな実行時コンテキストを踏まえた指摘を出してくれるので、コード読みでは取りこぼしやすい類の罠を拾えます

おわりに(エミュレータでの確認で気づいた別の罠)

修正自体は簡単だったけど、エミュレータで動作確認した時に別の罠を踏みかけた。

「写真なし月を隠す」を押すと 全部の月が消えた という事象が起きた。コードの修正は正しかったので「何か別の問題?」と追ったら、エミュレータに adb push で突っ込んだ画像が EXIF を持っていなかった ことが原因だった。

EXIF は写真ファイルの中。createDateTime は photo_manager がメモリで返す値で、保存しない。完了印は端末 SQLite の PeriodVisitStatus。写真ファイルadb push の画像EXIF なしファイルの中photo_managercreateDateTime1970-01-01メモリ。保存しない走査の判定2026年5月は写真なしメモリ。保存しない全月完了PeriodVisitStatus端末 SQLite閉じても残る

これは今回の P1-2 のスコープ外で、進捗メモには別タスク(P3「hasAnyPhotosInYearMonth のメタデータ依存問題」)として残した。エミュレータで動作確認するときは、push する画像の EXIF をちゃんと整えてから入れる か、実機で確認する のが安全です。

この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。