Newer
Older
client_dwatch / lib / clean-logs / __tests__ / query.test.ts
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"]);
  });
});