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,
});
});
});