🐴

うるう日にしか発生しないバグ

2024/03/01に公開

昨日うるう日にしか発生しないバグに遭遇した。Javascriptを書く人には有名な話だとは思うので大して面白くはないかもしれないが一応メモ。

詳しくは書けないがバグが発生した関数の仕様としてはざっくりと下記のような感じ。

  • 対象の年月日が基準日の1年前から1年後の間に含まれる場合はtrueを返しそうでない場合はfalseを返す
  • 引数として2020-12-24というフォーマットの文字列が渡される(判定対象の日)
  • 引数として2021-01-01というフォーマットの文字列が渡される(+-1年の基準日)
  • Javascriptで書く

(例)
対象の日: 2024/10/10 基準日: 2024/01/28
この時、trueになる範囲は2023/01/28 ~ 2025/01/28。なので2024/10/10はtrue。2023/01/28も2025/01/28もtrueになる。閉区間。

とあるコードの中で実装されていた処理が下記。

const isWithinOneYear = (input: string, base: string) => {
  const baseDate = new Date(`${base}T00:00:00.000Z`);
  const inputDate = new Date(`${input}T00:00:00.000Z`);

  const prevYear = baseDate.getFullYear() - 1;
  const nextYear = baseDate.getFullYear() + 1;
  const lower = baseDate.setFullYear(prevYear);
  const upper = baseDate.setFullYear(nextYear);

  return inputDate >= new Date(lower) && inputDate <= new Date(upper);
};

これをうるう日以外に実行すると例えば以下のようになる。

// 例: 2023-01-29 ~ 2025-01-29に含まれる日付の場合はtrueになってほしい
const notLeapDate = "2024-01-29";
isWithinOneYear("2025-01-29", notLeapDate) // true 想定通り
isWithinOneYear("2025-01-30", notLeapDate) // false 想定通り
isWithinOneYear("2023-01-29", notLeapDate) // true 想定通り
isWithinOneYear("2023-01-28", notLeapDate) // false 想定通り

一方でうるう日に実行すると以下のようになる。

// 2023-03-01 ~ 2025-02-28に含まれる日付の場合はtrueになってほしい
const leapDate = "2024-02-29";
isWithinOneYear("2025-02-28", leapDate) // true 想定通り
isWithinOneYear("2025-03-01", leapDate) // true 間違ってる。falseになってほしい。
isWithinOneYear("2023-03-01", leapDate) // true 想定通り
isWithinOneYear("2023-02-28", leapDate) // false 想定通り

うるう日から+-1年以内の場合2025-03-01falseを返してほしいがtrueが返ってくる。

これはJavascriptが下記のようにうるう日の翌年として3/1を返すのが原因(setUTCFullYearなどを使っても同様)。

const date = new Date(`2024-02-29T00:00:00.000Z`)
date.setFullYear(date.getFullYear() + 1)
console.log(date) //=> Sat Mar 01 2025 09:00:00 GMT+0900 (日本標準時)

2025-03-01が上限になってしまうのでisWithinOneYear("2025-03-01", leapDate)trueになってしまうというわけ。

うるう日の1年後については言語ごとに何を返すのかがまちまちっぽくて、例えばRubyの場合は2/29の翌年は2/28になるからこのような問題は起きないはず(Javascriptを責める気はないです)。

で、先の実装はどうしたら良かったのかというと一番シンプルなのはうるう日だけ上限を一日戻してあげる。

const isWithinOneYear = (input: string, base: string) => {
  const baseDate = new Date(`${base}T00:00:00.000Z`);
  const inputDate = new Date(`${input}T00:00:00.000Z`);

  // うるう年の2/29だけ戻す1日分の秒数を設定しておく
  let forLeapSec = 0;
  if (
    ((baseDate.getFullYear() % 4 === 0 && baseDate.getFullYear() % 100 !== 0) ||
      baseDate.getFullYear() % 400 === 0) &&
    baseDate.getMonth() === 1 &&
    baseDate.getDate() === 29
  ) {
    forLeapSec = 24 * 60 * 60 * 1000;
  }

  const prevYear = baseDate.getFullYear() - 1;
  const nextYear = baseDate.getFullYear() + 1;
  const lower = baseDate.setFullYear(prevYear);
  const upper = baseDate.setFullYear(nextYear);

  return inputDate >= new Date(lower) && inputDate <= new Date(upper - forLeapSec);
};

他にも色々と格好いいやり方はあると思うが、これで一応は想定通りに動く。

一口に+-1年以内言っても、2/29の1年前に2/28を含むのが正しい要件もあるし、2/29の1年後に3/1が含むのが正しい要件もある。dayjs等のライブラリを使ってれば安心とかそういう問題ではない。1年という要件を細部まで確認しなかった実装者の落ち度。

ちなみにChatGPTに先の要件を伝えて生成してもらったコードもうるう年が考慮されておらず手直しが必要だった。AIで生成されたコードが要件に合うかどうかを読み解ける程度の力はまだ我々には必要そう。

とまぁこんな感じですが、今回のうるう日に限らず、JavascriptのDateは他にもmonthが0始まりだったりタイムゾーンを変更できなかったり何かとハマりがち...。

リンク

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date

Discussion