import { describe, expect, it } from "vitest";
import {
CLEAN_LOG_SORT_KEYS,
CLEAN_LOG_SORT_ORDERS,
capCleanLogs,
parseCleanLogQuery,
parseDate,
filterCleanLogs,
} from "@/lib/clean-logs/query";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { formatHm, formatMd, inclusiveDayCount, toJstIso } from "@/lib/format/date";
import { judgeEscalationLevel, matchAdviceRules } from "@/lib/judge";
import type { CleanLogItem } from "@/lib/types";
import { ESCALATION_THRESHOLDS, MAX_DATE_RANGE_DAYS } from "@/lib/config";
function row(overrides: Partial<CleanLogItem>): CleanLogItem {
return {
id: 1,
row_key: "tt-1",
report_id: null,
unscheduled: false,
facility_id: 1,
facility_name: "メガバリューマート中央本店",
model_name: "PUDU CC1",
serial_number: "8111R5628060011",
scheduled_at: "2026-08-27T02:00:00+09:00",
status_1h: "待機中",
status_1h_minutes: 60,
status_30m: "待機中",
status_30m_minutes: 30,
departed: true,
// ★型追従(指示書AQ §5-2。旧 `clean_error` から改名)★。`departed: true` なので
// レポートの行がある=`clean_task_reports.status` の生値(例: 4)。未実施なら null
task_status: 4,
// ★結果待ち(D-173)★ — 既定はレポートあり・確定の行(false)
result_pending: false,
completion_rate: 90.1,
grade: "◎",
consecutive_problem_days: 0,
escalation_level: null,
...overrides,
};
}
describe("formatMd / formatHm", () => {
it("ISO8601(+09:00)からモックと同じ日付・時刻にする", () => {
expect(formatMd("2026-08-27T02:00:00+09:00")).toBe("8/27");
expect(formatHm("2026-08-27T02:00:00+09:00")).toBe("02:00");
});
});
describe("inclusiveDayCount", () => {
it("両端を含む日数を返す", () => {
expect(inclusiveDayCount("2026-08-01", "2026-08-01")).toBe(1);
expect(inclusiveDayCount("2026-08-01", "2026-08-31")).toBe(31);
expect(inclusiveDayCount("2026-08-01", "2026-09-01")).toBe(32);
});
});
/**
* 日付範囲の上限(指示書AI 段階7)
*
* ★期待値表(指示書AI 9-2)★
*
* | # | 入力 | 期待 |
* |-----|-------------------------------|--------------------------|
* | 9-1 | 両方指定・731日 | 200(上限ちょうどは通る)|
* | 9-2 | 両方指定・732日 | ★422★ |
* | 9-3 | ★`date_from` のみ★ | ★200(上限の検証をしない)★ |
* | 9-4 | ★`date_to` のみ★ | ★200★ |
* | 9-5 | ★両方なし・`facility_name` のみ★ | ★200★ |
*
* ★★9-3〜9-5 は「実装が無いこと」を固定するテストです★★ —
* ユーザー指示(2026-09-15)は「未指定で開いた場合や、店舗指定のみの場合は上限無し」。
* ★片方だけの指定に上限をかける実装をうっかり足すと、ここが赤くなる★。
*
* ★境界の両側(731 / 732)を必ず入れる★(`CLAUDE.md` §9.4)。
* ★`>` と `>=` の取り違えは、片側だけのテストでは通ってしまう★。
*/
describe("parseCleanLogQuery — 日付範囲の上限", () => {
/** `base` の n 日後を `YYYY-MM-DD` で返す(UTC 基準で日を足すだけの計算用) */
function plusDays(base: string, n: number): string {
return new Date(Date.parse(`${base}T00:00:00Z`) + n * 86_400_000)
.toISOString()
.slice(0, 10);
}
// ★上限の値そのものを1箇所で固定する★ — 下のテストは定数から日付を組み立てるので、
// 値を変えても自動追従してしまう。「2年(731日)にした」という決定はここで押さえる。
it("上限は2年(731日)", () => {
expect(MAX_DATE_RANGE_DAYS).toBe(731);
});
it("9-1: 両方指定・上限ちょうど(731日)は通る", () => {
const from = "2026-01-01";
const params = new URLSearchParams({
date_from: from,
// 両端を含む日数なので、上限ちょうどは「(上限-1)日後」
date_to: plusDays(from, MAX_DATE_RANGE_DAYS - 1),
});
expect(parseCleanLogQuery(params).ok).toBe(true);
});
it("9-2: 両方指定・上限+1日(732日)は422", () => {
const from = "2026-01-01";
const params = new URLSearchParams({
date_from: from,
date_to: plusDays(from, MAX_DATE_RANGE_DAYS),
});
const parsed = parseCleanLogQuery(params);
expect(parsed.ok).toBe(false);
if (!parsed.ok) {
expect(parsed.status).toBe(422);
expect(parsed.error.error.code).toBe("validation_failed");
}
});
it("9-3: date_from のみなら上限を検証しない(10年でも通る)", () => {
const params = new URLSearchParams({ date_from: "2016-01-01" });
expect(parseCleanLogQuery(params).ok).toBe(true);
});
it("9-4: date_to のみなら上限を検証しない", () => {
const params = new URLSearchParams({ date_to: "2026-09-15" });
expect(parseCleanLogQuery(params).ok).toBe(true);
});
it("9-5: 日付なし・facility_name のみは通る(上限なし)", () => {
const params = new URLSearchParams({ facility_name: "メガバリューマート" });
expect(parseCleanLogQuery(params).ok).toBe(true);
});
it("date_from > date_to は日数に関わらず422", () => {
const params = new URLSearchParams({
date_from: "2026-09-02",
date_to: "2026-09-01",
});
const parsed = parseCleanLogQuery(params);
expect(parsed.ok).toBe(false);
if (!parsed.ok) expect(parsed.status).toBe(422);
});
});
/**
* ★group_id / customer_id(指示書AK 段階2)★
*
* ★★このファイルには D-053 の「無視する」コメントも既存テストも無かった★★ —
* `d-watch-api.yaml` は最初からこの2つを宣言していて、★パーサが黙って捨てていた★。
* だからここは「反転」ではなく ★新規追加★(他の5本の兄弟APIと同じ期待値表に揃える)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | 2-1 | `group_id=1` | `1`(★値まで見る★) |
* | 2-2 | `customer_id=2` | `2` |
* | 2-3 | 未指定 | ★`null`★(絞らない) |
* | 2-4 | `group_id=0` | ★422★(0 は「グループなし」のセンチネル。有効なIDではない。D-011) |
* | 2-5 | `group_id=1abc` | ★422★(★`parseInt` の前方一致で `1` にしない★。D-123 で実際に踏んだ罠) |
* | 2-6 | `group_id=-1` | 422 |
* | 2-7 | `group_id=1.5` | 422 |
* | 2-8 | `9007199254740992`(安全整数超) | 422 |
*
* ★同時指定は AND★(`d-watch-api.yaml` の `GroupId`)。★どちらか片方を捨てない★ —
* ここでは両方が値として残ることだけを確かめる(施設への解決は段階3、反映は段階4)。
*/
describe("parseCleanLogQuery — group_id / customer_id(指示書AK 段階2)", () => {
it("両方を値として読む(AND。★片方を捨てない★)", () => {
const parsed = parseCleanLogQuery(new URLSearchParams({ group_id: "1", customer_id: "2" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.groupId).toBe(1);
expect(parsed.query.customerId).toBe(2);
});
it("未指定は null(絞らない)", () => {
const parsed = parseCleanLogQuery(new URLSearchParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.groupId).toBeNull();
expect(parsed.query.customerId).toBeNull();
});
it.each(["0", "1abc", "-1", "1.5", "9007199254740992"])(
"group_id=%s は422",
(value) => {
const parsed = parseCleanLogQuery(new URLSearchParams({ group_id: value }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422を期待");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
group_id: ["1以上の整数で指定してください"],
});
}
);
it.each(["0", "1abc"])("customer_id=%s も同じ規則で422", (value) => {
const parsed = parseCleanLogQuery(new URLSearchParams({ customer_id: value }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422を期待");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
customer_id: ["1以上の整数で指定してください"],
});
});
});
describe("filterCleanLogs", () => {
const parsed = parseCleanLogQuery(
new URLSearchParams({ result: "problem", date_from: "2026-08-27", date_to: "2026-08-27" })
);
if (!parsed.ok) throw new Error("query");
it("△×のみを残す", () => {
const items = filterCleanLogs(
[
row({ id: 1, grade: "◎" }),
row({ id: 2, grade: "△", completion_rate: 45 }),
row({ id: 3, grade: "×", completion_rate: 0 }),
],
parsed.query
);
expect(items.map((r) => r.id)).toEqual([2, 3]);
});
});
/**
* ★結果待ち(D-173)の行の絞り込み★(ユーザー判断 2026-10-07。表2 C3)
*
* 結果待ち(R)は `grade: "×"` のまま(不変条件を壊さない)なので、★旗を見ないと
* 「問題あり」「×のみ」に混ざる★。R は失敗に数えないので、★`problem` / `critical` には出さない★。
* ★`all` には出す★(画面で「結果待ち」と見えるようにする)。
*
* | # | result | R(結果待ち) | F(確定した出発失敗) | S(◎) |
* |------|----------|---------------|-----------------------|--------|
* | C3-1 | problem | ★出さない★ | 出す | 出さない |
* | C3-2 | critical | ★出さない★ | 出す | 出さない |
* | C3-3 | all | ★出す★ | 出す | 出す |
*/
describe("filterCleanLogs — 結果待ち(表2 C3。D-173)", () => {
const notDeparted = {
departed: false,
task_status: null,
completion_rate: null,
grade: "×",
} as const;
const rows = [
row({ id: 1, ...notDeparted, result_pending: true }), // R
row({ id: 2, ...notDeparted, result_pending: false }), // F
row({ id: 3 }), // S
];
function resultQuery(result: string) {
const parsedResult = parseCleanLogQuery(new URLSearchParams({ result }));
if (!parsedResult.ok) throw new Error("query");
return parsedResult.query;
}
it.each<[string, string, number[]]>([
["C3-1", "problem", [2]],
["C3-2", "critical", [2]],
["C3-3", "all", [1, 2, 3]],
])("%s: result=%s → id %j", (_id, result, expected) => {
expect(filterCleanLogs(rows, resultQuery(result)).map((r) => r.id)).toEqual(expected);
});
});
/**
* 検索(facility_name / model / serial)の挙動を固定する。
*
* ★指示書AG 段階1★ — この3つの入力欄には、これまでテストが1件も無かった。
* テストが無いまま正規化(段階2・段階3)を入れると、間違えても赤くならない。
* そこで「いまどうなっているか」を先に固定してから触る。
*
* ★期待値表(指示書AG 3-段階1)★
*
* | # | 入力 | 期待 |
* |----|----------------------------------|--------------------------------|
* | 1 | facility_name に施設名の一部 | ヒットする |
* | 2 | facility_name に存在しない語 | 0件 |
* | 3 | model に `CC1` | ヒットする |
* | 4 | ★model に `cc1`★ | ★ヒットする(段階3で反転)★ |
* | 5 | serial にシリアルの一部(大文字)| ヒットする |
* | 6 | ★serial を小文字にしたもの★ | ★ヒットする(段階3で反転)★ |
* | 7 | 3つを同時指定 | AND で効く |
* | 8 | すべて空文字 | 素通し(全件) |
*
* ★fixtures には依存しない★ — `row()` の合成データだけで組み立てる
* (`lib/fixtures/` は変更しない。指示書AG D-6)。
*/
describe("filterCleanLogs の検索", () => {
/** 日付・grade では絞らない(検索だけを見る)クエリを作る */
function searchQuery(params: Record<string, string>) {
const parsedSearch = parseCleanLogQuery(new URLSearchParams(params));
if (!parsedSearch.ok) throw new Error("query");
return parsedSearch.query;
}
const rows = [
row({ id: 1, facility_name: "メガバリューマート中央本店" }),
row({
id: 2,
facility_name: "バリューマート戸塚",
model_name: "PUDU MT1",
serial_number: "8111R5628060022",
}),
];
// #1
it("facility_name に施設名の一部を入れるとヒットする", () => {
const items = filterCleanLogs(rows, searchQuery({ facility_name: "戸塚" }));
expect(items.map((r) => r.id)).toEqual([2]);
});
// #2
it("facility_name に存在しない語を入れると0件", () => {
const items = filterCleanLogs(
rows,
searchQuery({ facility_name: "ZZZ-NOMATCH" })
);
expect(items).toEqual([]);
});
// #3
it("model に CC1 を入れるとヒットする", () => {
const items = filterCleanLogs(rows, searchQuery({ model: "CC1" }));
expect(items.map((r) => r.id)).toEqual([1]);
});
// #4 ★★段階3 で反転させた★★(指示書AG 3-段階3)
// 段階1 の期待値は「0件」だった。清掃詳細ログの検索だけが大文字小文字を区別しており、
// 機種を小文字で `cc1` と打つ(自然な操作)と 0件になっていた。
// 段階3 で `includesFoldPlain` に寄せ、他4系統と同じくヒットするようにした。
it("model に cc1(小文字)を入れてもヒットする", () => {
const items = filterCleanLogs(rows, searchQuery({ model: "cc1" }));
expect(items.map((r) => r.id)).toEqual([1]);
});
// #5
it("serial にシリアルの一部(大文字を含む)を入れるとヒットする", () => {
const items = filterCleanLogs(rows, searchQuery({ serial: "8111R56280600" }));
expect(items.map((r) => r.id)).toEqual([1, 2]);
});
// #6 ★★段階3 で反転させた★★(指示書AG 3-段階3)
// 段階1 の期待値は「0件」だった。施設マスタ・スケジュール・D-S制御盤の3系統は
// `8111r` でヒットするのに、清掃詳細ログだけが 0件になっていた
// (同じシリアルを同じ社内画面で探しているのに、入力欄によって食い違っていた)。
// 段階3 で `includesFoldPlain` に寄せて揃えた。
it("serial を小文字にしてもヒットする", () => {
const items = filterCleanLogs(rows, searchQuery({ serial: "8111r56280600" }));
expect(items.map((r) => r.id)).toEqual([1, 2]);
});
// #7
it("facility_name / model / serial を同時に指定すると AND で効く", () => {
const hit = filterCleanLogs(
rows,
searchQuery({
facility_name: "戸塚",
model: "MT1",
serial: "0022",
})
);
expect(hit.map((r) => r.id)).toEqual([2]);
// 1つでも外れれば落ちる(OR ではない)
const miss = filterCleanLogs(
rows,
searchQuery({
facility_name: "戸塚",
model: "CC1",
serial: "0022",
})
);
expect(miss).toEqual([]);
});
// #8
it("すべて空文字なら素通しで全件返る", () => {
const items = filterCleanLogs(
rows,
searchQuery({ facility_name: "", model: "", serial: "" })
);
expect(items.map((r) => r.id)).toEqual([1, 2]);
});
// ★記号ゆれ(指示書AG 段階3)★ 5系統すべてに1件ずつ置く
// ★施設名は中点を打たなくても引ける★が、★シリアルは記号を除かない★。
// 合成データで組む(`lib/fixtures/` の施設名には記号が1文字も無いため再現できない。D-6)。
// `ドソ・キホーテ` は中点入りの架空名(実在の店名は使わない)。
it("施設名は中点を打たなくても引ける/シリアルの記号は除かない", () => {
const symbolRows = [
row({
id: 10,
facility_name: "ドソ・キホーテ戸塚",
serial_number: "PUDU-CC1-0001",
}),
];
// 施設名: 中点なしでヒットする(これが本件の本命)
expect(
filterCleanLogs(symbolRows, searchQuery({ facility_name: "ドソキホーテ" }))
.map((r) => r.id)
).toEqual([10]);
// ★シリアル: 記号を除かないので `10001` では引けない★
// (除くと `CC1` の 1 と `0001` が繋がって誤ヒットする。D-2)
expect(filterCleanLogs(symbolRows, searchQuery({ serial: "10001" }))).toEqual(
[]
);
// ハイフンごと打てば当たる(大文字小文字は無視する)
expect(
filterCleanLogs(symbolRows, searchQuery({ serial: "cc1-0001" })).map(
(r) => r.id
)
).toEqual([10]);
});
/**
* ★記号だけの入力(2026-09-14 に反転。案A)★
*
* ★反転前は `facility_name=-` で全件が出ていた★(`includesFoldName` が素通ししていた)。
* ★清掃詳細ログの3欄は互いに AND(q の OR は無い)★ので、ここは
* 「施設名だけ 0件に倒れ、model / serial は記号をそのまま探す」ことを固定する。
*
* ★空白だけの入力は trim で空文字=未指定になる★ — `parseCleanLogQuery` が
* `.trim()` するため ★`includesFoldName` に届く前に空文字になり素通しする★。
* ★`・` や `-` は trim で落ちない★ので、記号だけの入力として 0件になる。
*/
it("★facility_name に記号だけを入れると 0件★/★空白だけは trim で未指定になる★", () => {
const symbolRows = [
row({
id: 10,
facility_name: "ドソ・キホーテ戸塚",
model_name: "PUDU CC1",
serial_number: "PUDU-CC1-0001",
}),
];
// ★記号だけ → 0件(反転後)★
for (const needle of ["・", "-", "_", "ー", "・ー", "--"]) {
expect(
filterCleanLogs(symbolRows, searchQuery({ facility_name: needle }))
).toEqual([]);
}
// ★空白だけ → パーサの `.trim()` で空文字=未指定になり素通しする★
for (const raw of [" ", " ", "\t"]) {
const parsedQuery = searchQuery({ facility_name: raw });
expect(parsedQuery.facilityName).toBe("");
expect(filterCleanLogs(symbolRows, parsedQuery).map((r) => r.id)).toEqual([10]);
}
// ★空文字(未指定)は反転していない★ — 素通し
expect(
filterCleanLogs(symbolRows, searchQuery({ facility_name: "" })).map((r) => r.id)
).toEqual([10]);
// ★model / serial は `includesFoldPlain` のまま★ — 記号をそのまま探す
expect(
filterCleanLogs(symbolRows, searchQuery({ serial: "-" })).map((r) => r.id)
).toEqual([10]);
expect(
filterCleanLogs(symbolRows, searchQuery({ model: " " })).map((r) => r.id)
).toEqual([10]); // 空白だけの model も trim で未指定になるので素通し
expect(filterCleanLogs(symbolRows, searchQuery({ model: "-" }))).toEqual([]);
});
});
// ★指示書AF 段階6 — /clean-logs にも同じ表示上限を効かせる★
// 画面に出ない値だが、契約の片方だけ頭打ちにすると「同じ施設・同じ日なのに
// 清掃詳細ログとレポートで連続日数が違う」(D-109 項2 / D-103)が復活する。
describe("capCleanLogs", () => {
it("#1 上限ちょうど(30 / 上限30)はそのまま。capped:false", () => {
const [item] = capCleanLogs([row({ consecutive_problem_days: 30 })], 30);
expect(item.consecutive_problem_days).toBe(30);
expect(item.consecutive_problem_days_capped).toBe(false);
});
it("#2 上限+1(31 / 上限30)は 30 / capped:true", () => {
const [item] = capCleanLogs([row({ consecutive_problem_days: 31 })], 30);
expect(item.consecutive_problem_days).toBe(30);
expect(item.consecutive_problem_days_capped).toBe(true);
});
it("#3 ★escalation_level は L3 のまま★(判定は生値。指示書AF 禁止事項9)", () => {
const [item] = capCleanLogs(
[row({ consecutive_problem_days: 45, escalation_level: "L3" })],
ESCALATION_THRESHOLDS.level3Days
);
expect(item.consecutive_problem_days).toBe(ESCALATION_THRESHOLDS.level3Days);
expect(item.consecutive_problem_days_capped).toBe(true);
expect(item.escalation_level).toBe("L3");
});
it("#4 空配列は空配列(例外にしない)", () => {
expect(capCleanLogs([], 30)).toEqual([]);
});
it("#5 連続0日は 0 / capped:false", () => {
const [item] = capCleanLogs([row({ consecutive_problem_days: 0 })], 30);
expect(item.consecutive_problem_days).toBe(0);
expect(item.consecutive_problem_days_capped).toBe(false);
});
it("連続日数以外のフィールドは書き換えない", () => {
const before = row({ consecutive_problem_days: 31, grade: "×" });
const [item] = capCleanLogs([before], 30);
expect(item.id).toBe(before.id);
expect(item.facility_name).toBe(before.facility_name);
expect(item.grade).toBe("×");
expect(item.completion_rate).toBe(before.completion_rate);
// ★入力を書き換えない(純粋関数)★
expect(before.consecutive_problem_days).toBe(31);
expect(before.consecutive_problem_days_capped).toBeUndefined();
});
it("★絞り込みは生値で行われる★(上限をかけてから絞り込んでいないこと)", () => {
// filterCleanLogs は連続日数を見ないが、ルートの適用順(絞り込み → 切り出し →
// 上限)を崩すと meta.total が変わりうる。切り出し後に掛けても件数が変わらないことを固定する
const rows = [
row({ id: 1, consecutive_problem_days: 45 }),
row({ id: 2, consecutive_problem_days: 31 }),
row({ id: 3, consecutive_problem_days: 2 }),
];
const capped = capCleanLogs(rows, 30);
expect(capped).toHaveLength(rows.length);
expect(capped.map((r) => r.id)).toEqual([1, 2, 3]);
expect(capped.map((r) => r.consecutive_problem_days)).toEqual([30, 30, 2]);
});
});
// ★★指示書AF 禁止事項9 の機械化★★ — `/clean-logs/{id}/advice` 相当の入力で、
// 判定(judgeAdvice / judgeEscalationLevel)に渡る値が★生値のまま★であることを固定する。
// アドバイスの共通関数(`lib/clean-logs/advice-response.ts` の `adviceResponse()`。
// `/clean-logs/{id}/advice` と `/clean-logs/unscheduled/{report_id}/advice` の両ルートから呼ばれる)は
// - matchAdviceRules / judgeEscalationLevel には `log.consecutive_problem_days`(生値)
// - レスポンスの `consecutive_problem_days` にだけ `capConsecutiveDays()` の結果
// を渡す。この対応が崩れると「30日以上と出ているのに Lv3 が出ない」になる。
describe("表示上限は判定に影響しない(/clean-logs/{id}/advice 相当)", () => {
const RAW_DAYS = 45;
const MAX_DAYS = ESCALATION_THRESHOLDS.level3Days; // 設定できる下限=最も厳しい上限
const log = row({
consecutive_problem_days: RAW_DAYS,
departed: true,
completion_rate: 12,
grade: "△",
});
it("判定に渡す値は上限をかけない生値のまま", () => {
// ★ルートと同じ入力の作り方★(上限をかけた値を渡していないこと)
expect(log.consecutive_problem_days).toBe(RAW_DAYS);
expect(judgeEscalationLevel(log.consecutive_problem_days)).toBe("L3");
expect(
matchAdviceRules({
departed: log.departed,
completionRate: log.completion_rate,
consecutiveProblemDays: log.consecutive_problem_days,
status1h: log.status_1h,
status30m: log.status_30m,
})
).toContain("R4");
});
it("★頭打ちした値で判定すると結果が変わってしまう★(だから判定にかけてはいけない)", () => {
const { days, capped } = capConsecutiveDays(log.consecutive_problem_days, MAX_DAYS);
expect(capped).toBe(true);
expect(days).toBe(MAX_DAYS);
// 表示値と生値で R4 の該当は変わらない(L3 の日数ちょうどでも R4 は立つ)が、
// 表示値のほうが小さいことを固定しておく(判定の入力に使えば必ず甘くなる方向)
expect(days).toBeLessThan(RAW_DAYS);
});
it("レスポンスに載せる値だけが頭打ちになり、escalation_level は生値の判定結果", () => {
// ルートの組み立てを再現する
const escalationLevel = judgeEscalationLevel(log.consecutive_problem_days); // ★生値★
const capped = capConsecutiveDays(log.consecutive_problem_days, MAX_DAYS);
const body = {
escalation_level: escalationLevel,
consecutive_problem_days: capped.days,
consecutive_problem_days_capped: capped.capped,
};
expect(body.escalation_level).toBe("L3");
expect(body.consecutive_problem_days).toBe(MAX_DAYS);
expect(body.consecutive_problem_days_capped).toBe(true);
});
});
// ★表8 Q1〜Q5(指示書BB A7・D-182)★ — 存在しない日付は null(黙って翌月に繰り越さない)
describe("parseDate — 存在しない日付(指示書BB 表8)", () => {
it.each([
["Q1", "2026-02-31", false],
["Q2(2026 は閏年でない)", "2026-02-29", false],
["Q3(閏年)", "2028-02-29", true],
["Q4", "2026-04-31", false],
["Q5", "2026-12-31", true],
["Q5", "2026-02-28", true],
] as const)("%s: %s → 有効か %s", (_label, value, valid) => {
const d = parseDate(value);
if (valid) {
expect(d).not.toBeNull();
expect(toJstIso(d as Date).slice(0, 10)).toBe(value);
} else {
expect(d).toBeNull();
}
});
it("★Q6: `parseCleanLogQuery()` は `date_from=2026-02-31` を、形式違いと同じ422にする★", () => {
const bad = parseCleanLogQuery(new URLSearchParams({ date_from: "2026-02-31" }));
const format = parseCleanLogQuery(new URLSearchParams({ date_from: "2026/02/03" }));
expect(bad.ok).toBe(false);
expect(format.ok).toBe(false);
if (bad.ok || format.ok) return;
expect(bad.status).toBe(422);
expect(bad.error).toEqual(format.error);
});
});
/** 表S2 — 並べ替えクエリ(指示書BH §3) */
describe("parseCleanLogQuery — sort / order(指示書BH 表S2)", () => {
it("S2-1: sort 省略 → sort null・order asc", () => {
const parsed = parseCleanLogQuery(new URLSearchParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.sort).toBeNull();
expect(parsed.query.order).toBe("asc");
});
it("S2-2: sort=(空)→ 省略と同じ", () => {
const parsed = parseCleanLogQuery(new URLSearchParams({ sort: "" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.sort).toBeNull();
expect(parsed.query.order).toBe("asc");
});
it("S2-2: sort=%20(空白)→ 省略と同じ", () => {
const parsed = parseCleanLogQuery(new URLSearchParams("sort=%20"));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.sort).toBeNull();
expect(parsed.query.order).toBe("asc");
});
it("order= / order=%20 → 省略と同じ(sort 省略)", () => {
for (const qs of ["order=", "order=%20"]) {
const parsed = parseCleanLogQuery(new URLSearchParams(qs));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.sort).toBeNull();
expect(parsed.query.order).toBe("asc");
}
});
it("S2-3: sort=grade(order 省略)→ grade・asc", () => {
const parsed = parseCleanLogQuery(new URLSearchParams({ sort: "grade" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.sort).toBe("grade");
expect(parsed.query.order).toBe("asc");
});
it("S2-4: sort=facility_name → 422", () => {
const parsed = parseCleanLogQuery(new URLSearchParams({ sort: "facility_name" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
sort: [`${CLEAN_LOG_SORT_KEYS.join(" / ")} のいずれかを指定してください`],
});
});
it.each(["precheck_1", "foo", "Grade"])("S2-5: sort=%s → 422", (sort) => {
const parsed = parseCleanLogQuery(new URLSearchParams({ sort }));
expect(parsed.ok).toBe(false);
if (!parsed.ok) expect(parsed.status).toBe(422);
});
it("S2-6: order=up(sort 省略)→ 422", () => {
const parsed = parseCleanLogQuery(new URLSearchParams({ order: "up" }));
expect(parsed.ok).toBe(false);
if (!parsed.ok) expect(parsed.status).toBe(422);
if (parsed.ok) throw new Error("422");
expect(parsed.error.error.details).toEqual({
order: [`${CLEAN_LOG_SORT_ORDERS.join(" / ")} のいずれかを指定してください`],
});
});
it("S2-7: sort=scheduled_at&order=desc → sort null(422 にならない)", () => {
const parsed = parseCleanLogQuery(
new URLSearchParams({ sort: "scheduled_at", order: "desc" })
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.sort).toBeNull();
expect(parsed.query.order).toBe("desc");
});
it("S2-8: 422 の details は入力値をエコーしない", () => {
const parsed = parseCleanLogQuery(new URLSearchParams({ sort: "facility_name" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422");
expect(JSON.stringify(parsed.error)).not.toContain("facility_name");
const sortDetail = parsed.error.error.details?.sort;
expect(Array.isArray(sortDetail) && sortDetail[0]).toContain(CLEAN_LOG_SORT_KEYS[0]);
});
});
describe("parseCleanLogQuery — schedule(指示書BH 表P)", () => {
it.each([
["P-1: なし", "", null],
["P-2: schedule=", "schedule=", null],
["P-3: schedule=%20", "schedule=%20", null],
["P-4: schedule=scheduled", "schedule=scheduled", "scheduled"],
["P-5: schedule=unscheduled", "schedule=unscheduled", "unscheduled"],
["P-6: schedule=%20unscheduled%20", "schedule=%20unscheduled%20", "unscheduled"],
] as const)("%s", (_label, qs, expected) => {
const parsed = parseCleanLogQuery(new URLSearchParams(qs));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.schedule).toBe(expected);
});
it.each(["all", "main", "Scheduled", "UNSCHEDULED"])(
"P-7: schedule=%s → 422",
(value) => {
const parsed = parseCleanLogQuery(new URLSearchParams({ schedule: value }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("scheduleの値が不正です");
expect(parsed.error.error.details).toEqual({
schedule: ["scheduled / unscheduled のいずれかを指定してください"],
});
expect(JSON.stringify(parsed.error)).not.toContain(value);
}
);
it("P-8: sort=bad&schedule=bad → details のキーは sort だけ", () => {
const parsed = parseCleanLogQuery(
new URLSearchParams({ sort: "bad", schedule: "bad" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422");
expect(Object.keys(parsed.error.error.details ?? {})).toEqual(["sort"]);
});
});