Newer
Older
client_dwatch / lib / format / __tests__ / period.test.ts
import { describe, expect, it } from "vitest";
import { todayJst } from "@/lib/format/date";
import {
  clampWeek,
  isCurrentPeriod,
  isPastPeriod,
  periodLabel,
  periodRange,
  prevPeriodLabel,
  shiftPeriod,
  weekOfMonth,
} from "@/lib/format/period";

/**
 * 期間ヘルパーの期待値表テスト(CLAUDE.md §9.4「テストを先に書く」)
 *
 * ★週は日曜始まり(CLAUDE.md §4.7)★
 * ★月跨ぎ週は「月境界で分割する」(D-071。TeamWorks のサマリ集計に合わせた)★
 *   週は日曜〜土曜だが、月をまたぐ週は1日で切り、それぞれの月の別々の週として数える。
 *   各月の第1週は必ず1日から始まり、最終週は必ず末日で終わる。
 *   ★週の長さは1〜7日で変わり、月の週番号は1〜6(第6週は発生する)★
 *
 *   旧ルール(D-067「1日を含む週がその月の第1週」)は破棄。
 *   同じ物理週を2つの月に分けない方式だったため、TeamWorks の週次サマリと帰属がズレていた。
 *
 * ★`week` は `number`。月次は 0 で表す(D-072。段階5で `number | null` から変更)★
 *   サマリ4テーブルの `week` が NOT NULL で「月次は 0」なので、内部表現も 0 に揃えた。
 *   `weekly` で `week < 1` は不正(`periodRange` は null / `periodLabel` は月だけのラベル)。
 *   ★`periodLabel` が「第0週」と出さないことをこのファイルで固定している★
 *
 * 2026年の曜日(このテストの前提。Date.UTC で検証済み):
 *   2026-02-01 日 / 2026-03-01 日 / 2026-05-01 金 / 2026-08-01 土 / 2026-09-01 火 /
 *   2026-12-01 火 / 2026-01-01 木 / 2027-01-01 金
 */

describe("weekOfMonth(月跨ぎ週の帰属。★D-071★)", () => {
  // ★期待値表1(指示書H)。2020〜2040 の全252か月を総当たりして検証済みの表★
  // | 入力 | 曜日 | 期待 year/month/week | 何を固定するか |
  const table: Array<[string, string, number, number, number, string]> = [
    // 1日が土曜の31日月(2026年8月)。★第1週が1日しかなく、第6週が発生する★
    ["2026-08-01", "土", 2026, 8, 1, "★1日が土曜 → 第1週は1日だけ★"],
    ["2026-08-02", "日", 2026, 8, 2, "日曜 → 第2週の初日"],
    ["2026-08-29", "土", 2026, 8, 5, "土曜 → 第5週の最終日"],
    ["2026-08-30", "日", 2026, 8, 6, "★日曜だが8月に残る → 8月第6週(旧D-067なら9月第1週)★"],
    ["2026-08-31", "月", 2026, 8, 6, "★月末 → 8月第6週★"],

    // 月境界で切れる週(2026-08-30〜09-05 が2つの期間に分かれる)
    ["2026-09-01", "火", 2026, 9, 1, "★火曜だが月初 → 9月第1週の初日★"],
    ["2026-09-05", "土", 2026, 9, 1, "★土曜 → 9月第1週の最終日(5日間)★"],
    ["2026-09-06", "日", 2026, 9, 2, "★日曜 → 9月第2週★"],
    ["2026-09-12", "土", 2026, 9, 2, "★土曜 → 9月第2週★"],
    ["2026-09-30", "水", 2026, 9, 5, "月末が水曜 → 第5週(4日間)"],

    // 7月(1日が水曜の31日月)は5週
    ["2026-07-26", "日", 2026, 7, 5, "★7月に残る(旧D-067では「8月第1週」だった)★"],
    ["2026-07-25", "土", 2026, 7, 4, "第4週の最終日(7月は全5週)"],

    // 1日が金曜の31日月(2026年5月)。この月も第6週が発生する
    ["2026-05-01", "金", 2026, 5, 1, "1日が金曜の31日月 → この月も第6週が発生する"],
    ["2026-05-31", "日", 2026, 5, 6, "★日曜だが月末 → 5月第6週(1日だけ)★"],

    // 月初が日曜の月(2026年2月・3月)
    ["2026-02-01", "日", 2026, 2, 1, "1日が日曜の月 → 第1週が7日ちょうど"],
    ["2026-02-28", "土", 2026, 2, 4, "28日・日曜始まりはちょうど4週"],
    ["2026-03-01", "日", 2026, 3, 1, "月初が日曜"],

    // 年跨ぎ週も月境界で切れる
    ["2026-12-26", "土", 2026, 12, 4, "第4週の最終日(12月は全5週)"],
    ["2026-12-27", "日", 2026, 12, 5, "★12月に残る(旧D-067では「2027年1月第1週」だった)★"],
    ["2026-12-31", "木", 2026, 12, 5, "★年末日も12月第5週(旧D-067では「2027年1月第1週」)★"],
    ["2027-01-01", "金", 2027, 1, 1, "年初"],
    ["2025-12-28", "日", 2025, 12, 5, "★2025年12月に残る(旧D-067では「2026年1月第1週」だった)★"],
    ["2026-01-03", "土", 2026, 1, 1, "同じ週の最終日"],
    ["2026-01-01", "木", 2026, 1, 1, "年初。1日が木曜"],
  ];

  for (const [input, dow, year, month, week, intent] of table) {
    it(`${input}(${dow}) → ${year}年${month}月 第${week}週(${intent})`, () => {
      expect(weekOfMonth(input)).toEqual({ year, month, week });
    });
  }

  /**
   * ★H-3: 同じ物理週が2つの期間に分かれること★
   *
   * 2026-08-30(日)〜09-05(土) は暦の上では1つの週だが、D-071 では
   * 08-30〜08-31 が8月第6週、09-01〜09-05 が9月第1週になる。
   * 旧ルール(D-067)ではどちらも「9月第1週」だった。
   */
  it("★H-3★ 同じ物理週でも月境界で別の期間になる(旧D-067では同じ9月第1週だった)", () => {
    const before = weekOfMonth("2026-08-30");
    const after = weekOfMonth("2026-09-01");
    expect(before).toEqual({ year: 2026, month: 8, week: 6 });
    expect(after).toEqual({ year: 2026, month: 9, week: 1 });
    expect(before).not.toEqual(after);
  });

  /**
   * ★期待値表5: 不変条件(2020〜2040 を総当たり)★
   *
   * D-071 では「1日が金曜または土曜の30日/31日月」で第6週が発生する。
   * 252か月中55か月(最初の3件は 2020-05 / 2020-08 / 2021-01)。
   * 2026年は5月(1日が金曜の31日月)と8月(1日が土曜の31日月)。
   *
   * 旧ルール(D-067)では「週末(土)がその月にある週」だけを数えていたため
   * 最大5だったが、月境界で切る D-071 では月にかかる週をすべて数えるので6まで出る。
   */
  it("★D-071 では週番号は最大6。第6週は発生する★(2020〜2040を総当たり)", () => {
    const found = new Set<number>();
    const perMonth = new Map<string, Set<number>>();

    const cursor = new Date(Date.UTC(2020, 0, 1));
    const end = Date.UTC(2040, 11, 31);
    while (cursor.getTime() <= end) {
      const date = cursor.toISOString().slice(0, 10);
      const { year, month, week } = weekOfMonth(date);
      found.add(week);
      const key = `${year}-${month}`;
      if (!perMonth.has(key)) perMonth.set(key, new Set());
      perMonth.get(key)?.add(week);
      cursor.setUTCDate(cursor.getUTCDate() + 1);
    }

    expect([...found].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);

    // 各月の週番号は 1..N の連番(欠番がない)
    for (const [key, weeks] of perMonth) {
      const sorted = [...weeks].sort((a, b) => a - b);
      expect(sorted, key).toEqual(
        Array.from({ length: sorted.length }, (_, i) => i + 1)
      );
    }

    // 第6週が発生する月は252か月中55か月
    const sixWeekMonths = [...perMonth.entries()]
      .filter(([, weeks]) => weeks.has(6))
      .map(([key]) => key);
    expect(perMonth.size).toBe(252);
    expect(sixWeekMonths).toHaveLength(55);
    expect(sixWeekMonths.slice(0, 3)).toEqual(["2020-5", "2020-8", "2021-1"]);
    // 2026年で第6週が発生するのは5月と8月だけ
    expect(sixWeekMonths.filter((key) => key.startsWith("2026-"))).toEqual([
      "2026-5",
      "2026-8",
    ]);
  });

  it("2026年8月(1日が土曜の31日月)は★6週ある★", () => {
    expect(weekOfMonth("2026-08-29")).toEqual({ year: 2026, month: 8, week: 5 });
    expect(weekOfMonth("2026-08-31")).toEqual({ year: 2026, month: 8, week: 6 });
    // 第6週は存在する。第7週は存在しない
    expect(periodRange("weekly", 2026, 8, 6)).not.toBeNull();
    expect(periodRange("weekly", 2026, 8, 7)).toBeNull();
  });
});

describe("periodRange(期間の [開始日, 終了日]。両端含む)", () => {
  // ★月次の week は 0(D-072)。null ではない★
  it("monthly は暦月で締める", () => {
    expect(periodRange("monthly", 2026, 9, 0)).toEqual({
      from: "2026-09-01",
      to: "2026-09-30",
    });
    expect(periodRange("monthly", 2026, 12, 0)).toEqual({
      from: "2026-12-01",
      to: "2026-12-31",
    });
    expect(periodRange("monthly", 2026, 2, 0)).toEqual({
      from: "2026-02-01",
      to: "2026-02-28",
    });
    // 閏年
    expect(periodRange("monthly", 2028, 2, 0)).toEqual({
      from: "2028-02-01",
      to: "2028-02-29",
    });
  });

  it("monthly は week を無視する(YAML の Week の定義どおり)", () => {
    expect(periodRange("monthly", 2026, 9, 3)).toEqual({
      from: "2026-09-01",
      to: "2026-09-30",
    });
  });

  // ★期待値表2(指示書H): 2026年8月・9月の全週。週の長さが1〜7日で変わる★
  // | year | month | week | from | to | 日数 | 備考 |
  const weeklyTable: Array<[number, number, number, string, string, number, string]> = [
    [2026, 8, 1, "2026-08-01", "2026-08-01", 1, "★1日だけの週★"],
    [2026, 8, 2, "2026-08-02", "2026-08-08", 7, ""],
    [2026, 8, 3, "2026-08-09", "2026-08-15", 7, ""],
    [2026, 8, 4, "2026-08-16", "2026-08-22", 7, ""],
    [2026, 8, 5, "2026-08-23", "2026-08-29", 7, ""],
    [2026, 8, 6, "2026-08-30", "2026-08-31", 2, "★月境界で切れる★"],
    [2026, 9, 1, "2026-09-01", "2026-09-05", 5, "★月境界で切れる★"],
    [2026, 9, 2, "2026-09-06", "2026-09-12", 7, ""],
    [2026, 9, 3, "2026-09-13", "2026-09-19", 7, ""],
    [2026, 9, 4, "2026-09-20", "2026-09-26", 7, ""],
    [2026, 9, 5, "2026-09-27", "2026-09-30", 4, "★月境界で切れる★"],
  ];

  for (const [year, month, week, from, to, days, note] of weeklyTable) {
    it(`weekly ${year}年${month}月 第${week}週 = ${from}〜${to}(${days}日)${note}`, () => {
      const range = periodRange("weekly", year, month, week);
      expect(range).toEqual({ from, to });
      if (!range) throw new Error("range");
      // 日数(週が7日固定でないことを固定する)
      const fromDay = Number(range.from.slice(8));
      const toDay = Number(range.to.slice(8));
      expect(toDay - fromDay + 1).toBe(days);
    });
  }

  it("weekly の年跨ぎも月境界で切る(2027年1月 第1週は元日から)", () => {
    // 旧D-067では 2026-12-27〜2027-01-02 だった
    expect(periodRange("weekly", 2027, 1, 1)).toEqual({
      from: "2027-01-01",
      to: "2027-01-02",
    });
    expect(periodRange("weekly", 2026, 12, 5)).toEqual({
      from: "2026-12-27",
      to: "2026-12-31",
    });
  });

  it("その月に存在しない週は null(★5週しかない月の第6週など★)", () => {
    // ★旧D-067では null だったが、D-071 では存在する2件★
    expect(periodRange("weekly", 2026, 12, 5)).toEqual({
      from: "2026-12-27",
      to: "2026-12-31",
    });
    expect(periodRange("weekly", 2026, 7, 5)).toEqual({
      from: "2026-07-26",
      to: "2026-07-31",
    });
    // 存在しないのはその先
    expect(periodRange("weekly", 2026, 8, 7)).toBeNull();
    expect(periodRange("weekly", 2026, 9, 6)).toBeNull();
    expect(periodRange("weekly", 2026, 12, 6)).toBeNull();
    expect(periodRange("weekly", 2026, 7, 6)).toBeNull();
  });

  it("不正な入力は null", () => {
    // ★week=0 は「月次」を表す予約値(D-072)。weekly では不正なので null★
    expect(periodRange("weekly", 2026, 9, 0)).toBeNull();
    expect(periodRange("weekly", 2026, 9, -1)).toBeNull(); // week < 1
    expect(periodRange("weekly", 2026, 9, 1.5)).toBeNull();
    expect(periodRange("weekly", 2026, 13, 1)).toBeNull(); // month 範囲外
    expect(periodRange("monthly", 2026, 0, 0)).toBeNull();
    expect(periodRange("monthly", 2026, 13, 0)).toBeNull();
    expect(periodRange("monthly", 2026.5, 9, 0)).toBeNull();
  });

  it("periodRange と weekOfMonth は往復する", () => {
    for (let month = 1; month <= 12; month++) {
      for (let week = 1; week <= 6; week++) {
        const range = periodRange("weekly", 2026, month, week);
        if (!range) continue;
        expect(weekOfMonth(range.from)).toEqual({ year: 2026, month, week });
        expect(weekOfMonth(range.to)).toEqual({ year: 2026, month, week });
      }
    }
  });

  it("★H-1★ 週の長さが1日になるケース(2026年8月 第1週は 08-01 だけ)", () => {
    const range = periodRange("weekly", 2026, 8, 1);
    expect(range).toEqual({ from: "2026-08-01", to: "2026-08-01" });
    expect(range?.from).toBe(range?.to);
  });

  it("★H-2★ 月境界で切れるケース(2026年8月 第6週の to は 08-31。09-05 ではない)", () => {
    const range = periodRange("weekly", 2026, 8, 6);
    expect(range?.to).toBe("2026-08-31");
    expect(range?.to).not.toBe("2026-09-05");
    expect(range?.from).toBe("2026-08-30");
  });

  it("★H-4★ 第6週の存在(8月は第6週がある / 9月は無い)", () => {
    expect(periodRange("weekly", 2026, 8, 6)).toEqual({
      from: "2026-08-30",
      to: "2026-08-31",
    });
    expect(periodRange("weekly", 2026, 9, 6)).toBeNull();
  });

  /**
   * ★H-5: 全日被覆(往復)。2020〜2040 を総当たり★
   *
   * 各月について periodRange の全週の日を並べると、その月の1日〜末日と一致し、
   * 各日で weekOfMonth() が元の (年,月,週) を返す。
   * 週の開始は「1日 or 日曜」、週の終了は「末日 or 土曜」。
   */
  it("★H-5★ 各月の週がその月の全日をちょうど1回ずつ覆い、往復する(2020〜2040を総当たり)", () => {
    const lengthDistribution = new Map<number, number>();
    const oneDayWeeks: string[] = [];
    let totalWeeks = 0;

    for (let year = 2020; year <= 2040; year++) {
      for (let month = 1; month <= 12; month++) {
        const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
        const covered: number[] = [];
        let week = 1;
        for (; ; week++) {
          const range = periodRange("weekly", year, month, week);
          if (!range) break;
          const key = `${year}-${String(month).padStart(2, "0")}`;
          expect(range.from.slice(0, 7), key).toBe(key);
          expect(range.to.slice(0, 7), key).toBe(key);
          const fromDay = Number(range.from.slice(8));
          const toDay = Number(range.to.slice(8));

          // 週の開始は「1日 or 日曜」/ 終了は「末日 or 土曜」
          const fromDow = new Date(
            Date.UTC(year, month - 1, fromDay)
          ).getUTCDay();
          const toDow = new Date(Date.UTC(year, month - 1, toDay)).getUTCDay();
          expect(fromDay === 1 || fromDow === 0, `${key} 第${week}週 開始`).toBe(
            true
          );
          expect(toDay === lastDay || toDow === 6, `${key} 第${week}週 終了`).toBe(
            true
          );

          const length = toDay - fromDay + 1;
          expect(length, `${key} 第${week}週 日数`).toBeGreaterThanOrEqual(1);
          expect(length, `${key} 第${week}週 日数`).toBeLessThanOrEqual(7);
          lengthDistribution.set(
            length,
            (lengthDistribution.get(length) ?? 0) + 1
          );
          if (length === 1) oneDayWeeks.push(range.from);

          for (let day = fromDay; day <= toDay; day++) {
            covered.push(day);
            const date = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
            expect(weekOfMonth(date), date).toEqual({ year, month, week });
          }
          totalWeeks++;
        }

        // 存在しない週(weeksInMonth + 1)は必ず null → 上のループが break した週番号
        expect(periodRange("weekly", year, month, week)).toBeNull();
        // 1日〜末日をちょうど1回ずつ覆う
        expect(covered, `${year}-${month}`).toEqual(
          Array.from({ length: lastDay }, (_, i) => i + 1)
        );
      }
    }

    // ★週の長さの分布(全1313週)★
    expect(totalWeeks).toBe(1313);
    expect([...lengthDistribution.entries()].sort((a, b) => a[0] - b[0])).toEqual([
      [1, 73],
      [2, 71],
      [3, 73],
      [4, 74],
      [5, 70],
      [6, 73],
      [7, 879],
    ]);
    // ★1日しかない週は73件 / 21年(年2〜3回)。2026年は 08-01 と 05-31★
    expect(oneDayWeeks).toHaveLength(73);
    expect(oneDayWeeks.filter((date) => date.startsWith("2026"))).toEqual([
      "2026-05-31",
      "2026-08-01",
    ]);
  });
});

describe("isCurrentPeriod(当期=進行中かどうか)", () => {
  // 2026年9月 第2週 = 2026-09-06(日)〜2026-09-12(土)
  it("weekly: 初日 / 最終日は true、前日 / 翌日は false", () => {
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-06")).toBe(true); // 初日
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-09")).toBe(true); // 途中
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-12")).toBe(true); // 最終日
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-05")).toBe(false); // 前日
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-13")).toBe(false); // 翌日
  });

  it("weekly: ★月跨ぎ週は月境界で切れるので、8月の日は「9月第1週」に入らない(D-071)★", () => {
    // 旧D-067では true だった(08-30 が9月第1週の初日)
    expect(isCurrentPeriod("weekly", 2026, 9, 1, "2026-08-30")).toBe(false);
    // 08-30 / 08-31 は8月第6週(2日しかない週)に入る
    expect(isCurrentPeriod("weekly", 2026, 8, 6, "2026-08-30")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 8, 6, "2026-08-31")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 8, 6, "2026-09-01")).toBe(false);
    // 9月第1週は 09-01 から
    expect(isCurrentPeriod("weekly", 2026, 9, 1, "2026-09-01")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 9, 1, "2026-09-05")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 8, 5, "2026-08-29")).toBe(true);
  });

  // ★月次の week は 0(D-072)★
  it("monthly: 初日 / 最終日は true、前日 / 翌日は false", () => {
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-09-01")).toBe(true);
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-09-30")).toBe(true);
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-08-31")).toBe(false);
    expect(isCurrentPeriod("monthly", 2026, 10, 0, "2026-09-30")).toBe(false);
  });

  it("存在しない期間 / 不正な today は false", () => {
    // ★2026年12月は5週あるので、実在しない週は第6週★(旧D-067では第5週が実在しなかった)
    expect(isCurrentPeriod("weekly", 2026, 12, 6, "2026-12-28")).toBe(false);
    expect(isCurrentPeriod("weekly", 2026, 9, 6, "2026-09-30")).toBe(false);
    // ★weekly で week=0(月次の予約値)は当期にならない(D-072)★
    expect(isCurrentPeriod("weekly", 2026, 9, 0, "2026-09-07")).toBe(false);
    expect(isCurrentPeriod("monthly", 2026, 13, 0, "2026-09-07")).toBe(false);
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-9-7")).toBe(false);
  });

  it("today を省略すると JST の今日を使う", () => {
    const today = todayJst();
    const year = Number(today.slice(0, 4));
    const month = Number(today.slice(5, 7));
    expect(isCurrentPeriod("monthly", year, month, 0)).toBe(true);

    const current = weekOfMonth(today);
    expect(
      isCurrentPeriod("weekly", current.year, current.month, current.week)
    ).toBe(true);
  });
});

describe("isPastPeriod(終わった期間=確定期間かどうか)", () => {
  // 2026年9月 第2週 = 2026-09-06(日)〜2026-09-12(土)
  it("weekly: 最終日の翌日から true。最終日・期間中・期間より前(未来の期間)は false", () => {
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-13")).toBe(true); // 翌日
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-12")).toBe(false); // 最終日(当期)
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-06")).toBe(false); // 初日(当期)
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-05")).toBe(false); // 未来の期間
  });

  it("monthly / yearly", () => {
    expect(isPastPeriod("monthly", 2026, 9, 0, "2026-10-01")).toBe(true);
    expect(isPastPeriod("monthly", 2026, 9, 0, "2026-09-30")).toBe(false);
    expect(isPastPeriod("monthly", 2026, 10, 0, "2026-09-30")).toBe(false);
    expect(isPastPeriod("yearly", 2025, 0, 0, "2026-01-01")).toBe(true);
    expect(isPastPeriod("yearly", 2026, 0, 0, "2026-12-31")).toBe(false);
    expect(isPastPeriod("yearly", 2027, 0, 0, "2026-10-19")).toBe(false);
  });

  it("存在しない期間 / 不正な today は false", () => {
    expect(isPastPeriod("weekly", 2026, 9, 6, "2026-12-31")).toBe(false);
    expect(isPastPeriod("weekly", 2026, 9, 0, "2026-12-31")).toBe(false);
    expect(isPastPeriod("monthly", 2026, 9, 0, "2026-10-1")).toBe(false);
  });
});

describe("periodLabel / prevPeriodLabel(画面の期間ナビ)", () => {
  it("weekly は「YYYY年M月 第N週」", () => {
    expect(periodLabel("weekly", 2026, 3, 4)).toBe("2026年3月 第4週");
    expect(periodLabel("weekly", 2026, 12, 1)).toBe("2026年12月 第1週");
  });

  it("monthly は「YYYY年M月」。week は無視する", () => {
    // ★月次の week は 0(D-072)★
    expect(periodLabel("monthly", 2026, 3, 0)).toBe("2026年3月");
    expect(periodLabel("monthly", 2026, 3, 4)).toBe("2026年3月");
  });

  /**
   * ★段階5(D-072)の要件: `periodLabel` は「第0週」と出してはいけない★
   *
   * `week` の内部表現を `number | null` から `number`(月次は 0)へ変えたため、
   * `weekly` に 0 が渡ってくる経路ができた(`shiftPeriod` の monthly が 0 を返す)。
   * 旧実装の `week === null` 分岐と同じ挙動 = 月だけのラベルに落とすことを固定する。
   */
  it("★weekly で week=0(月次の予約値)でも「第0週」と出さず月だけのラベルに落とす★", () => {
    expect(periodLabel("weekly", 2026, 3, 0)).toBe("2026年3月");
    expect(periodLabel("weekly", 2026, 3, 0)).not.toContain("第0週");
    expect(periodLabel("weekly", 2026, 12, 0)).toBe("2026年12月");
    // 負の値(あり得ないが、渡ってきても「第-1週」を出さない)
    expect(periodLabel("weekly", 2026, 3, -1)).toBe("2026年3月");
    // 第1週は通常どおり出す(0 だけを落としていることの裏取り)
    expect(periodLabel("weekly", 2026, 3, 1)).toBe("2026年3月 第1週");
  });

  it("前期比のラベル", () => {
    expect(prevPeriodLabel("weekly")).toBe("前週");
    expect(prevPeriodLabel("monthly")).toBe("前月");
  });
});

describe("shiftPeriod(期間ナビの ‹ ›)", () => {
  // ★monthly は week に 0 を返す(D-072。null ではない)★
  it("monthly: 月を1つ動かす。week は 0 を返す", () => {
    expect(shiftPeriod("monthly", 2026, 9, 0, 1)).toEqual({
      year: 2026,
      month: 10,
      week: 0,
    });
    expect(shiftPeriod("monthly", 2026, 9, 0, -1)).toEqual({
      year: 2026,
      month: 8,
      week: 0,
    });
    // 週番号が入っていても monthly は読み捨てて 0 を返す
    expect(shiftPeriod("monthly", 2026, 9, 4, 1)).toEqual({
      year: 2026,
      month: 10,
      week: 0,
    });
  });

  it("monthly: 12月 +1 で翌年1月へ / 1月 -1 で前年12月へ", () => {
    expect(shiftPeriod("monthly", 2026, 12, 0, 1)).toEqual({
      year: 2027,
      month: 1,
      week: 0,
    });
    expect(shiftPeriod("monthly", 2026, 1, 0, -1)).toEqual({
      year: 2025,
      month: 12,
      week: 0,
    });
  });

  it("weekly: 同じ月の中を動く", () => {
    expect(shiftPeriod("weekly", 2026, 9, 1, 1)).toEqual({
      year: 2026,
      month: 9,
      week: 2,
    });
    expect(shiftPeriod("weekly", 2026, 9, 2, -1)).toEqual({
      year: 2026,
      month: 9,
      week: 1,
    });
  });

  // ★H-6 / 期待値表4(指示書H): shiftPeriod が月と年を跨ぐこと★
  // | 起点 year/month/week | delta | 期待 year/month/week | 何を固定するか |
  const shiftTable: Array<
    [number, number, number, -1 | 1, number, number, number, string]
  > = [
    [2026, 8, 6, -1, 2026, 8, 5, "8月第6週(2日)の前は8月第5週"],
    [2026, 8, 6, 1, 2026, 9, 1, "★8月第6週の次は9月第1週(月をまたぐ)★"],
    [2026, 9, 1, -1, 2026, 8, 6, "★旧D-067では 8月第5週 だった★"],
    [2026, 9, 1, 1, 2026, 9, 2, "9月第1週の次は9月第2週"],
    [2026, 8, 1, -1, 2026, 7, 5, "★旧D-067では 7月第4週 だった★。1日だけの週(08-01)の前"],
    [2026, 8, 1, 1, 2026, 8, 2, "1日だけの週の次は8月第2週"],
    [2026, 9, 5, 1, 2026, 10, 1, "★月末の週の次は10月第1週★"],
    [2026, 1, 1, -1, 2025, 12, 5, "★年をまたぐ。旧D-067では 2025年12月第4週 だった★"],
    [2025, 12, 5, 1, 2026, 1, 1, "★年をまたいで戻る★"],
    [2026, 8, 5, 1, 2026, 8, 6, "★旧D-067では 9月第1週 だった(8月の最終週ではない)★"],
    [2026, 12, 4, 1, 2026, 12, 5, "★旧D-067では 2027年1月第1週 だった★"],
  ];

  for (const [
    year,
    month,
    week,
    delta,
    expectedYear,
    expectedMonth,
    expectedWeek,
    intent,
  ] of shiftTable) {
    const sign = delta > 0 ? "+1" : "-1";
    it(`★H-6★ ${year}年${month}月 第${week}週 ${sign} → ${expectedYear}年${expectedMonth}月 第${expectedWeek}週(${intent})`, () => {
      expect(shiftPeriod("weekly", year, month, week, delta)).toEqual({
        year: expectedYear,
        month: expectedMonth,
        week: expectedWeek,
      });
    });
  }

  it("weekly: +1 と -1 は往復する(2026年を通しで確認)", () => {
    let cursor: { year: number; month: number; week: number } = {
      year: 2026,
      month: 1,
      week: 1,
    };
    for (let i = 0; i < 60; i++) {
      const next = shiftPeriod(
        "weekly",
        cursor.year,
        cursor.month,
        cursor.week,
        1
      );
      const back = shiftPeriod("weekly", next.year, next.month, next.week, -1);
      expect(back).toEqual(cursor);
      cursor = next;
    }
  });

  // ★「週が未選択」は week=0(月次の予約値。旧実装の null と同じ扱い。D-072)★
  it("weekly で week が無い(0)ときは第1週から動かす", () => {
    expect(shiftPeriod("weekly", 2026, 9, 0, 1)).toEqual({
      year: 2026,
      month: 9,
      week: 2,
    });
    expect(shiftPeriod("weekly", 2026, 9, 0, -1)).toEqual({
      year: 2026,
      month: 8,
      week: 6,
    });
  });
});

describe("clampWeek(移動先の月に無い週番号を丸める。★D-072★)", () => {
  // | 入力 | 期待 | 何を固定するか |
  const table: Array<[number, number, number, number, string]> = [
    [2026, 9, 6, 5, "9月は5週なので第5週へ丸める"],
    [2026, 8, 6, 6, "8月は6週あるのでそのまま"],
    [2026, 2, 5, 4, "2026年2月は4週"],
    [2026, 9, 0, 1, "下限も丸める"],
    [2026, 9, 3, 3, "範囲内はそのまま"],
  ];

  for (const [year, month, week, expected, intent] of table) {
    it(`clampWeek(${year}, ${month}, ${week}) → ${expected}(${intent})`, () => {
      expect(clampWeek(year, month, week)).toBe(expected);
    });
  }
});

/**
 * ★年次(yearly)★ — 確定期間のサマリに週・月・年の3粒度が入るようになったため追加
 * (指示書AQ 決定1。2026-09-30)。
 *
 * ★年次は `month=0` / `week=0` が前提★(CLAUDE.md §4.7「`month=0 AND week=0` が年次」)。
 * `periodRange` の `monthly` が `week` を無視するのと同じ扱いで、`yearly` は
 * ★`month` / `week` をどちらも無視する★(0 以外が来ても落とさず年で締める)。
 *
 * ★`weekOfMonth()` は触っていない★(月跨ぎ週の帰属ルールの唯一の持ち主。D-071)。
 *
 * 期待値表(先に確定 → テスト → 実装。CLAUDE.md §0-2 / §9.4):
 *
 * | #    | 関数            | 入力                                 | 期待値                   | 何を固定するか |
 * |------|-----------------|--------------------------------------|--------------------------|---|
 * | Y-1  | periodRange     | ("yearly", 2026, 0, 0)               | 2026-01-01 〜 2026-12-31 | 年次の基本形。★`month=0` で null にならないこと★ |
 * | Y-2  | periodRange     | ("yearly", 2028, 0, 0)               | 2028-01-01 〜 2028-12-31 | うるう年でも終端は 12-31 |
 * | Y-3  | periodRange     | 2028年の日数                          | ★366日★                 | うるう日 02-29 が範囲に入る |
 * | Y-4  | periodRange     | 2026年の日数                          | 365日                    | 平年 |
 * | Y-5  | periodRange     | ("yearly", 2026, 9, 3)               | 2026-01-01 〜 2026-12-31 | month / week は無視する |
 * | Y-6  | periodRange     | ("yearly", 2026.5, 0, 0)             | null                     | year が整数でないものは弾く |
 * | Y-7  | isCurrentPeriod | ("yearly", 2026, 0, 0, "2026-09-30") | ★true★                  | 当年は当期 |
 * | Y-8  | isCurrentPeriod | ("yearly", 2026, 0, 0, "2026-01-01") | true                     | 初日(境界) |
 * | Y-9  | isCurrentPeriod | ("yearly", 2026, 0, 0, "2026-12-31") | true                     | 最終日(境界) |
 * | Y-10 | isCurrentPeriod | ("yearly", 2026, 0, 0, "2025-12-31") | false                    | 前日 |
 * | Y-11 | isCurrentPeriod | ("yearly", 2026, 0, 0, "2027-01-01") | false                    | 翌日 |
 * | Y-12 | isCurrentPeriod | ("yearly", 2025, 0, 0, "2026-09-30") | ★false★                 | 前年は当期ではない |
 * | Y-13 | isCurrentPeriod | ("yearly", 2028, 0, 0, "2028-02-29") | true                     | うるう日 |
 * | Y-14 | periodLabel     | ("yearly", 2026, 0, 0)               | "2026年"                 | 「2026年0月」と出さない |
 * | Y-15 | periodLabel     | ("yearly", 2026, 9, 3)               | "2026年"                 | month / week を出さない |
 * | Y-16 | prevPeriodLabel | "yearly"                             | "前年"                    | |
 * | Y-17 | shiftPeriod     | ("yearly", 2026, 0, 0, -1)           | { 2025, 0, 0 }           | year を ±1、month / week は 0 のまま |
 * | Y-18 | shiftPeriod     | ("yearly", 2026, 0, 0, +1)           | { 2027, 0, 0 }           | |
 * | Y-19 | shiftPeriod     | ("yearly", 2026, 9, 3, +1)           | { 2027, ★0★, ★0★ }     | 粒度を切り替えた直後の残骸を 0 に落とす |
 * | Y-20 | shiftPeriod     | 往復(+1 → -1)                       | 元に戻る                  | |
 *
 * ★既存の週次・月次は1件も変えていない★(上の describe 群がそのまま通ることで固定)。
 */
describe("yearly(年次)。★month=0 / week=0 が前提★", () => {
  /** [from, to] の日数(両端含む)。うるう年の検算用 */
  const daysInclusive = (from: string, to: string): number => {
    const ms = (date: string): number => {
      const [y, m, d] = date.split("-").map(Number);
      return Date.UTC(y, m - 1, d);
    };
    return (ms(to) - ms(from)) / 86_400_000 + 1;
  };

  describe("periodRange", () => {
    it("★Y-1★ 2026年 → 2026-01-01 〜 2026-12-31(★month=0 でも null にならない★)", () => {
      expect(periodRange("yearly", 2026, 0, 0)).toEqual({
        from: "2026-01-01",
        to: "2026-12-31",
      });
    });

    it("★Y-2 / Y-3★ うるう年 2028年も終端は 12-31。範囲は366日(02-29 を含む)", () => {
      const range = periodRange("yearly", 2028, 0, 0);
      expect(range).toEqual({ from: "2028-01-01", to: "2028-12-31" });
      expect(daysInclusive(range!.from, range!.to)).toBe(366);
      // 辞書順 = 日付順なので、うるう日が範囲内にあることは文字列比較で確認できる
      expect(range!.from <= "2028-02-29" && "2028-02-29" <= range!.to).toBe(true);
    });

    it("★Y-4★ 平年 2026年は365日", () => {
      const range = periodRange("yearly", 2026, 0, 0);
      expect(daysInclusive(range!.from, range!.to)).toBe(365);
    });

    it("★Y-5★ month / week は無視する(monthly が week を無視するのと同じ)", () => {
      const expected = { from: "2026-01-01", to: "2026-12-31" };
      expect(periodRange("yearly", 2026, 9, 3)).toEqual(expected);
      expect(periodRange("yearly", 2026, 12, 6)).toEqual(expected);
      // ★weekly なら null になる組み合わせでも、yearly は年で締める★
      expect(periodRange("weekly", 2026, 9, 6)).toBeNull();
      expect(periodRange("yearly", 2026, 9, 6)).toEqual(expected);
    });

    it("★Y-6★ year が整数でなければ null", () => {
      expect(periodRange("yearly", 2026.5, 0, 0)).toBeNull();
      expect(periodRange("yearly", Number.NaN, 0, 0)).toBeNull();
    });

    it("年をまたいで連続する(2026年の翌日が2027年の初日)", () => {
      expect(periodRange("yearly", 2026, 0, 0)?.to).toBe("2026-12-31");
      expect(periodRange("yearly", 2027, 0, 0)?.from).toBe("2027-01-01");
    });
  });

  describe("isCurrentPeriod", () => {
    // | 年 | today | 期待 | 何を固定するか |
    const table: Array<[number, string, boolean, string]> = [
      [2026, "2026-09-30", true, "★Y-7★ 当年は当期"],
      [2026, "2026-01-01", true, "★Y-8★ 初日(境界を含む)"],
      [2026, "2026-12-31", true, "★Y-9★ 最終日(境界を含む)"],
      [2026, "2025-12-31", false, "★Y-10★ 前日"],
      [2026, "2027-01-01", false, "★Y-11★ 翌日"],
      [2025, "2026-09-30", false, "★Y-12★ 前年は当期ではない"],
      [2027, "2026-09-30", false, "翌年も当期ではない"],
      [2028, "2028-02-29", true, "★Y-13★ うるう日"],
    ];

    for (const [year, today, expected, intent] of table) {
      it(`isCurrentPeriod("yearly", ${year}, 0, 0, "${today}") → ${expected}(${intent})`, () => {
        expect(isCurrentPeriod("yearly", year, 0, 0, today)).toBe(expected);
      });
    }

    it("不正な today は false", () => {
      expect(isCurrentPeriod("yearly", 2026, 0, 0, "2026/09/30")).toBe(false);
      expect(isCurrentPeriod("yearly", 2026, 0, 0, "")).toBe(false);
    });

    it("today を省略すると JST の今日を使う(当年は true)", () => {
      const thisYear = Number(todayJst().slice(0, 4));
      expect(isCurrentPeriod("yearly", thisYear, 0, 0)).toBe(true);
      expect(isCurrentPeriod("yearly", thisYear - 1, 0, 0)).toBe(false);
      expect(isCurrentPeriod("yearly", thisYear + 1, 0, 0)).toBe(false);
    });
  });

  describe("periodLabel / prevPeriodLabel", () => {
    it("★Y-14 / Y-15★ yearly は「YYYY年」。month / week は出さない", () => {
      expect(periodLabel("yearly", 2026, 0, 0)).toBe("2026年");
      expect(periodLabel("yearly", 2026, 9, 3)).toBe("2026年");
      expect(periodLabel("yearly", 2028, 12, 6)).toBe("2028年");
      // ★「2026年0月」「第0週」と出さないこと★
      expect(periodLabel("yearly", 2026, 0, 0)).not.toContain("月");
      expect(periodLabel("yearly", 2026, 0, 0)).not.toContain("週");
    });

    it("★Y-16★ 前期比のラベルは「前年」", () => {
      expect(prevPeriodLabel("yearly")).toBe("前年");
      // 既存の2つは変えていない
      expect(prevPeriodLabel("weekly")).toBe("前週");
      expect(prevPeriodLabel("monthly")).toBe("前月");
    });
  });

  describe("shiftPeriod", () => {
    it("★Y-17 / Y-18★ year を ±1 し、month / week は 0 のまま", () => {
      expect(shiftPeriod("yearly", 2026, 0, 0, -1)).toEqual({
        year: 2025,
        month: 0,
        week: 0,
      });
      expect(shiftPeriod("yearly", 2026, 0, 0, 1)).toEqual({
        year: 2027,
        month: 0,
        week: 0,
      });
    });

    it("★Y-19★ month / week に 0 以外が残っていても 0 に落とす(粒度切替直後の残骸)", () => {
      expect(shiftPeriod("yearly", 2026, 9, 3, 1)).toEqual({
        year: 2027,
        month: 0,
        week: 0,
      });
      expect(shiftPeriod("yearly", 2026, 8, 6, -1)).toEqual({
        year: 2025,
        month: 0,
        week: 0,
      });
    });

    it("★Y-20★ +1 と -1 は往復する(2020〜2040)", () => {
      for (let year = 2020; year <= 2040; year++) {
        const next = shiftPeriod("yearly", year, 0, 0, 1);
        expect(shiftPeriod("yearly", next.year, next.month, next.week, -1)).toEqual({
          year,
          month: 0,
          week: 0,
        });
      }
    });
  });

  it("★週次・月次の分岐に手を入れていないこと★(yearly 追加前の代表値を再確認)", () => {
    expect(periodRange("monthly", 2026, 9, 0)).toEqual({
      from: "2026-09-01",
      to: "2026-09-30",
    });
    expect(periodRange("weekly", 2026, 9, 1)).toEqual({
      from: "2026-09-01",
      to: "2026-09-05",
    });
    expect(periodRange("weekly", 2026, 9, 0)).toBeNull();
    expect(periodRange("monthly", 2026, 0, 0)).toBeNull();
    expect(periodLabel("weekly", 2026, 9, 2)).toBe("2026年9月 第2週");
    expect(periodLabel("monthly", 2026, 9, 0)).toBe("2026年9月");
    expect(shiftPeriod("monthly", 2026, 12, 0, 1)).toEqual({
      year: 2027,
      month: 1,
      week: 0,
    });
  });
});