Newer
Older
client_dwatch / lib / db / __tests__ / clean-log-mapping.test.ts
import { describe, expect, it } from "vitest";
import {
  completionRateFromReport,
  computeConsecutiveProblemDays,
  foldProblemDaysByDate,
  formatModelName,
  hasCleanResult,
  isFutureSchedule,
  isProblemCompletion,
  latestReportByTimetableId,
  notExecutedFromReport,
  robotStatusFromErrors,
  type ConsecutiveInput,
} from "@/lib/db/clean-log-mapping";
import { fromDbJst } from "@/lib/format/date";
import { ROBOT_STATUS_STANDBY } from "@/lib/config";

// ---------------------------------------------------------------------------
// 期待値表(指示書R 2-5。CLAUDE.md §9.4「テストを先に書く / 境界値を必ず含める」)
// ---------------------------------------------------------------------------

describe("formatModelName", () => {
  // | maker_id | model_name | 期待値        | 備考                        |
  // |----------|-----------|---------------|-----------------------------|
  // | 1        | CC1       | PUDU CC1      | P1で唯一の実機(D-087)      |
  // | 2        | C30       | Keenon C30    | 将来候補                     |
  // | 99       | X1        | 99 X1         | ★未知のmaker_idは潰さない★ |
  it.each([
    [1, "CC1", "PUDU CC1"],
    [2, "C30", "Keenon C30"],
    [99, "X1", "99 X1"],
    // ★model_name がメーカー名込み(ステージング実測 2026-10-29)でも重ねない★
    [1, "PUDU CC1", "PUDU CC1"],
    [2, "Keenon C30", "Keenon C30"],
  ] as const)("maker_id=%i model=%s → %s", (makerId, model, expected) => {
    expect(formatModelName(makerId, model)).toBe(expected);
  });

  it("半角スペース1つで連結する(facilities() の MAKER_NAMES 前例に合わせる。D-090 項4)", () => {
    expect(formatModelName(1, "CC1")).toBe("PUDU CC1");
    expect(formatModelName(1, "CC1").split(" ")).toHaveLength(2);
  });
});

describe("robotStatusFromErrors", () => {
  const at = (iso: string) => new Date(iso);

  it("エラーが1件も無ければ Standby.(D-065 Q2 / D-098)", () => {
    expect(robotStatusFromErrors([])).toBe(ROBOT_STATUS_STANDBY);
    expect(robotStatusFromErrors([])).toBe("Standby.");
  });

  it("★英語識別子を日本語に変換しない(D-077 / D-088)★", () => {
    expect(
      robotStatusFromErrors([
        { error_type: "EmergencyStop", errored_at: at("2026-09-10T01:00:00+09:00") },
      ])
    ).toBe("EmergencyStop");
    expect(
      robotStatusFromErrors([
        { error_type: "Handrail Lifted", errored_at: at("2026-09-10T01:00:00+09:00") },
      ])
    ).toBe("Handrail Lifted");
  });

  it("複数ある場合は errored_at が最新の1件(指示書R 2-3)", () => {
    const errors = [
      { error_type: "Old", errored_at: at("2026-09-10T01:00:00+09:00") },
      { error_type: "New", errored_at: at("2026-09-10T01:30:00+09:00") },
    ];
    expect(robotStatusFromErrors(errors)).toBe("New");
    expect(robotStatusFromErrors([...errors].reverse())).toBe("New");
  });

  it("errored_at が null の行は最も古く扱う", () => {
    expect(
      robotStatusFromErrors([
        { error_type: "NoTime", errored_at: null },
        { error_type: "Timed", errored_at: at("2026-09-10T01:00:00+09:00") },
      ])
    ).toBe("Timed");
  });

  it("すべて errored_at が null なら入力順の先頭(安定ソート)", () => {
    expect(
      robotStatusFromErrors([
        { error_type: "First", errored_at: null },
        { error_type: "Second", errored_at: null },
      ])
    ).toBe("First");
  });
});

describe("hasCleanResult", () => {
  // ★取得元が `clean_status_histories` → `clean_task_reports` に変わった(指示書AQ 決定2・決定3)★。
  //   ★期待値は変わっていない★ — この関数は最初から「対応する結果行があるか」だけを見ている。
  //   ★「出発したか」の判定は `notExecutedFromReport()` が正★。
  //
  // | clean_task_reports の行 | 戻り  | 備考                                   |
  // |-------------------------|-------|----------------------------------------|
  // | 有り                    | true  | 結果が記録されている                     |
  // | 無し(undefined)         | false | 結果が記録されていない                   |
  //
  // ★「行が無い=出発失敗」という**結果**も変わっていない★ —
  //   ただしその判定を担うのは `notExecutedFromReport()`(undefined → true)に移った
  it("レポートの行があれば結果が記録されている", () => {
    expect(hasCleanResult({ clean_timetable_id: BigInt(1) })).toBe(true);
  });

  it("レポートの行が無ければ結果が記録されていない", () => {
    expect(hasCleanResult(undefined)).toBe(false);
  });

  it("Map.get() の戻り値をそのまま渡せる(呼び出し側の形)", () => {
    const map = new Map<string, { id: number }>([["1", { id: 1 }]]);
    expect(hasCleanResult(map.get("1"))).toBe(true);
    expect(hasCleanResult(map.get("2"))).toBe(false);
  });
});

describe("isFutureSchedule", () => {
  // ★`clean_timetables` には1週間分の予定がまとめて登録される(ユーザー回答 2026-09-10)★
  //   → `notExecutedFromReport()` は「レポートの行が無い」を出発失敗と判定する(D-107 設計3-C)ので、
  //     ★本関数を外すと未来の予定が全部「出発失敗」になる(=C案の下では必須)★。
  //
  // | start_at        | now             | 未来か   | 備考                                       |
  // |-----------------|-----------------|----------|--------------------------------------------|
  // | now より前      | 基準時刻        | false    | 予定時刻は過ぎている → 出発失敗の判定対象   |
  // | ★now と同時刻★ | 基準時刻        | ★false★ | ★予定時刻には達している(`>` であって `>=` ではない)★ |
  // | now より後      | 基準時刻        | true     | まだ来ていない → 判定対象から外す           |
  const now = new Date("2026-09-10T12:00:00+09:00");

  it.each([
    ["1日前", "2026-09-09T12:00:00+09:00", false],
    ["1分前", "2026-09-10T11:59:00+09:00", false],
    ["1ミリ秒前", "2026-09-10T11:59:59.999+09:00", false],
    ["★同時刻★", "2026-09-10T12:00:00+09:00", false],
    ["1ミリ秒後", "2026-09-10T12:00:00.001+09:00", true],
    ["1分後", "2026-09-10T12:01:00+09:00", true],
    ["3日後", "2026-09-13T02:00:00+09:00", true],
  ] as const)("start_at が %s → 未来か=%s", (_label, startAt, expected) => {
    expect(isFutureSchedule(new Date(startAt), now)).toBe(expected);
  });

  it("now を省略すると現在時刻で判定する(既定値)", () => {
    expect(isFutureSchedule(new Date("2000-01-01T00:00:00+09:00"))).toBe(false);
    expect(isFutureSchedule(new Date("2999-01-01T00:00:00+09:00"))).toBe(true);
  });

  it("同じ now を渡せば結果が実行時刻で揺れない(テストの前提)", () => {
    const startAt = new Date("2026-09-10T12:00:00.001+09:00");
    expect(isFutureSchedule(startAt, now)).toBe(isFutureSchedule(startAt, new Date(now)));
  });

  // ★呼び出し側(`cleanLogs()` / `prechecks()`)が行う絞り込みの形をそのまま固定する★
  //   DBには触らない(既存テストと同じく純粋関数のみ)。
  describe("呼び出し側の絞り込み(cleanLogs() / prechecks() が共有する形)", () => {
    // `clean_timetables` の本清掃行に見立てた最小の形。レポートの有無は departed の判定材料
    const rows = [
      { id: 1, start_at: new Date("2026-09-08T02:00:00+09:00"), hasReport: true },
      { id: 2, start_at: new Date("2026-09-09T02:00:00+09:00"), hasReport: false },
      { id: 3, start_at: new Date("2026-09-10T12:00:00+09:00"), hasReport: false },
      { id: 4, start_at: new Date("2026-09-13T02:00:00+09:00"), hasReport: false },
    ];

    it("★cleanLogs(): 未来の本清掃行は行ごと結果に含めない★", () => {
      const kept = rows.filter((row) => !isFutureSchedule(row.start_at, now));
      expect(kept.map((row) => row.id)).toEqual([1, 2, 3]);
    });

    // ★旧: prechecks() の `launch_failed` の形★ — 2026-10-29 にアラートから出発失敗を外した。
    //   いまは清掃詳細ログ(`departed === false`)・ダッシュボード(未実施)が同じ2段で出発失敗を決める
    it("★未来の本清掃行を出発失敗にしない★", () => {
      // ★実装と同じ2段(①未来ならスキップ → ②`notExecutedFromReport()` が真なら出発失敗)★
      const launchFailed = rows.filter(
        (row) =>
          !isFutureSchedule(row.start_at, now) &&
          notExecutedFromReport(row.hasReport ? report(95) : undefined)
      );
      // id=2(過去・レポートなし)と id=3(同時刻・レポートなし)は出発失敗のまま。
      // id=4(3日後)だけが消える = 過去の真の出発失敗は1件も減らない
      expect(launchFailed.map((row) => row.id)).toEqual([2, 3]);
    });

    it("★未来行を残すと連続日数が水増しされる(行ごと除外する理由)★", () => {
      // 過去2日は問題なし(完了率95)、3日後の未来予定はレポートが無いので completion_rate=null(=問題あり)
      // ★面積(`clean_area` / `task_area`)は連続日数の判定に使わないので 0★(指示書AI 段階2)
      const logs: ConsecutiveInput[] = [
        { id: 1, unscheduled: false, facility_id: 1, scheduled_at: "2026-09-08T02:00:00+09:00", completion_rate: 95, departed: true, clean_area: 0, task_area: 0 },
        { id: 2, unscheduled: false, facility_id: 1, scheduled_at: "2026-09-09T02:00:00+09:00", completion_rate: 95, departed: true, clean_area: 0, task_area: 0 },
        { id: 4, unscheduled: false, facility_id: 1, scheduled_at: "2026-09-13T02:00:00+09:00", completion_rate: null, departed: false, clean_area: 0, task_area: 0 },
      ];
      // ★未来行を残すと「1日連続で問題あり」という実在しない事実が生まれる★
      expect(computeConsecutiveProblemDays(logs).get(4)).toBe(1);
      // ★除外すればそもそも行が無い★
      const filtered = logs.filter((log) => !isFutureSchedule(new Date(log.scheduled_at), now));
      expect(computeConsecutiveProblemDays(filtered).has(4)).toBe(false);
    });
  });

  // ★指示書Y — DBのDATETIMEは日本時間の壁時計値(TeamWorks第8回A2)。Prismaのアダプタは
  //   それをUTCとみなしてDateにするため、lib/db/index.ts・report-mapping.tsは
  //   `isFutureSchedule(fromDbJst(row.start_at), now)` という形で呼ぶ。
  //   ここではその合成をDBの生値(`new Date("<値>Z")`という、アダプタの実際の挙動と
  //   同じ形の入力)に対してテストする。isFutureSchedule() 自体は変更していない★
  describe("fromDbJst() と組み合わせた判定(DBの生値を渡したときに正しく判定できること)", () => {
    // ★翌朝の確認時刻。CLAUDE.md §1「夜間に自動実行された清掃の結果を、翌朝DFA運用担当が確認する」★
    const nextMorning = new Date("2026-09-11T08:00:00+09:00");

    it("深夜2時の清掃が翌朝8時の確認でfalseになる(画面から消えないこと。これが本書の存在理由)", () => {
      const dbRawValue = new Date("2026-09-11T02:00:00Z"); // DB生値 2026-09-11 02:00:00
      expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
      // ★補正前(fromDbJstを通さない)だと壊れていたことの記録 — trueになり画面から消えていた★
      expect(isFutureSchedule(dbRawValue, nextMorning)).toBe(true);
    });

    it("深夜1時30分の清掃も翌朝8時の確認でfalseになる", () => {
      const dbRawValue = new Date("2026-09-11T01:30:00Z");
      expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
    });

    it("当日夜23時の予定は翌朝8時の確認でtrueのまま(正しく未来)", () => {
      const dbRawValue = new Date("2026-09-11T23:00:00Z");
      expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(true);
    });

    it("DB生値がnowとちょうど同時刻ならfalse(> であって >= ではない)", () => {
      const dbRawValue = new Date("2026-09-11T08:00:00Z");
      expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
    });

    it("DB生値がnowの1秒後ならtrue", () => {
      const dbRawValue = new Date("2026-09-11T08:00:01Z");
      expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(true);
    });

    it("前夜23時59分59秒の予定は翌朝8時の確認でfalse", () => {
      const dbRawValue = new Date("2026-09-10T23:59:59Z");
      expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
    });
  });
});

// ---------------------------------------------------------------------------
// ★取得元の切替(指示書AQ 決定2・決定3。2026-09-30 ユーザー確定)の期待値表★
//
// ★清掃実績の参照元が `clean_status_histories` → `clean_task_reports` に変わった★。
// ★★新しいテーブルに `clean_error` 列が無い★★(`prisma/schema.prisma:517-558`)ので、
//   旧実装の「`clean_error === 1` なら未実施」という第2分岐は★条件として成立しない★。
//
// | 旧(clean_status_histories)                       | 新(clean_task_reports)          |
// |----------------------------------------------------|-----------------------------------|
// | 行が無い **または** `clean_error === 1` → 未実施    | ★行が無い → 未実施★(第1分岐のみ)|
// | 順序キー = `ended_at`(NOT NULL)                   | ★`ended_at ?? started_at`★       |
// | `clean_error === 1` のとき完了率 null               | ★行が無いときだけ完了率 null★    |
//
// ★これは新しい推測ではない★ — 残した第1分岐は旧実装の第1分岐と同一の規則(指示書AQ §1-1)。
// ---------------------------------------------------------------------------

/**
 * `clean_task_reports` の行に見立てた最小の形。
 * ★完了率は面積から `runCompletionRate()` で決まる★(D-155)ので、`task_area = 100` に対して
 * `clean_area = rate` とし、`clean_ratio` 列にも同じ値を入れる(★列は完了率に使っていない。D6★)。
 */
const report = (rate: number) => ({ clean_area: rate, task_area: 100, clean_ratio: rate });

describe("notExecutedFromReport", () => {
  // | 入力                       | 期待     | 意味                                             |
  // |----------------------------|----------|--------------------------------------------------|
  // | ★undefined(行なし)★     | ★true★  | ★未実施=出発失敗。データ収集処理の障害も含む★  |
  // | 行あり(clean_ratio 95)   | false    | 実施された                                        |
  // | ★行あり(clean_ratio 0)★ | ★false★ | ★「0%で実施」は未実施ではない★                  |
  // | 行あり(status 3/4/5/6)   | false    | ★`status` を判定材料にしない(D-077)★          |
  it("★レポートの行が無ければ未実施(出発失敗)★(指示書AQ §1-1 / D-107 設計3-C。fail-loud)", () => {
    expect(notExecutedFromReport(undefined)).toBe(true);
  });

  it("レポートの行があれば実施された扱い", () => {
    expect(notExecutedFromReport(report(95))).toBe(false);
  });

  it("★完了率 0(clean_area = 0)でも行があれば未実施ではない★(「0%で実施」と「未実施」は別物)", () => {
    expect(notExecutedFromReport(report(0))).toBe(false);
  });

  // ★★`clean_task_reports.status` は判定に使わない(D-155 項2。値の意味は第10回 N-2 で受領済み。`prisma/schema.prisma:529`)★★ —
  //   未実施は「行が無いこと」だけで決める。★判定材料にしていないことを固定する★
  it.each([3, 4, 5, 6])(
    "★status=%i を未実施の判定材料にしない(D-077。CSV実測の4値すべて false)★",
    (status) => {
      expect(notExecutedFromReport({ clean_ratio: 0, status })).toBe(false);
    }
  );

  it("★判定材料は「行の有無」だけ★(他の列を足していないことの証明)", () => {
    // どんな中身でも、行がある限り false でなければならない
    const anyRows = [
      { clean_ratio: 0 },
      { clean_ratio: 100 },
      { clean_ratio: 0, status: 4, ended_at: null },
      {},
    ];
    expect(anyRows.map((row) => notExecutedFromReport(row))).toEqual([
      false,
      false,
      false,
      false,
    ]);
  });
});

describe("completionRateFromReport", () => {
  // ★D-155(指示書AS 段階3-1): 完了率は1回ごとの面積比 `runCompletionRate()`(第11回で確定。D-158)★
  //   ★旧: D-151 では `clean_ratio` 列の生値だった(面積から再計算しない)★
  //
  // | 入力(clean_area / task_area=100) | 期待     | 意味                                          |
  // |------------------------------------|----------|-----------------------------------------------|
  // | 92.4                               | 92.4     | 小数第1位まで残る                              |
  // | ★0★                               | ★0★     | ★「0%で実施」と「未実施」は別物★             |
  // | 89.5                               | 89.5     | ★表示90%だが判定は○(CLAUDE.md §9.4 の境界)★|
  // | 90.4                               | 90.4     | ★表示90%だが判定は◎(同上)★                |
  // | 100                                | 100      | 上限                                           |
  // | ★undefined(行なし)★             | ★null★  | ★未実施★                                     |
  // | ★task_area = 0★                   | ★0★     | ★0% として数える(P-5=B。指示書AU 表3)★     |
  it.each([
    [92.4, 92.4],
    [0, 0],
    [9.9, 9.9],
    [10.0, 10.0],
    [70.0, 70.0],
    [89.5, 89.5],
    [90.0, 90.0],
    [90.4, 90.4],
    [100, 100],
  ] as const)("clean_area=%s / task_area=100 → %s", (rate, expected) => {
    expect(completionRateFromReport(report(rate))).toBe(expected);
  });

  it("★レポートの行が無ければ null★(不変条件を行なしの場合にも保つ)", () => {
    expect(completionRateFromReport(undefined)).toBeNull();
  });

  it("★丸めない★(小数第1位がそのまま残る。CLAUDE.md §4.3)", () => {
    expect(completionRateFromReport(report(89.5))).toBe(89.5);
    expect(completionRateFromReport(report(90.4))).toBe(90.4);
  });

  // ★★境界: 完了率 null(未実施)と 0(0%で実施)は別物★★(指示書U 落とし穴1。★区別を維持★)
  it("★null(未実施)と 0(0%で実施)を取り違えない★", () => {
    expect(completionRateFromReport(undefined)).toBeNull();
    expect(completionRateFromReport(undefined)).not.toBe(0);
    expect(completionRateFromReport(report(0))).toBe(0);
    expect(completionRateFromReport(report(0))).not.toBeNull();
  });

  // ★★`clean_ratio` 列を読んでいないことの証明(D-155 / 指示書AS D6)★★ —
  //   CSV実測の乖離例「面積比 13.8% ↔ `clean_ratio` 100.0%」「面積比 162.8% ↔ `clean_ratio` 61.0%」を
  //   そのまま入れて、★面積の側(上限100・小数第1位切り捨て)★が返ることを固定する
  it.each([
    // [clean_area, task_area, clean_ratio(読まない), 期待(= runCompletionRate)]
    [13.8, 100, 100.0, 13.8],
    [162.8, 100, 61.0, 100.0],
    // ★`clean_area > task_area` の行はCSV実測189行中129行(68.3%)★ — 上限100で切る
    [150, 100, 42.5, 100.0],
    [50, 100, 12.3, 50.0],
  ])(
    "★clean_area=%s / task_area=%s → clean_ratio 列(%s)ではなく面積から %s★",
    (cleanArea, taskArea, cleanRatio, expected) => {
      // ★DB から来る行は面積も `clean_ratio` も持っている★(行をそのまま渡す作法。D6)
      const dbRow = {
        clean_ratio: cleanRatio,
        clean_area: cleanArea,
        task_area: taskArea,
      };
      expect(completionRateFromReport(dbRow)).toBe(expected);
    }
  );

  it("★task_area = 0 は 0★(P-5=B。指示書AU 表3「詳細ログの1行」。行はある=出発している)", () => {
    const dbRow = { clean_ratio: 80, clean_area: 10, task_area: 0 };
    expect(completionRateFromReport(dbRow)).toBe(0);
    expect(notExecutedFromReport(dbRow)).toBe(false);
  });
});

describe("★不変条件: departed === false ⟺ completion_rate === null(P-5=B で両向きに戻った)★", () => {
  // ★旧: D-155 で `task_area = 0` の清掃が「出発したが完了率 null」になり、逆は成り立たなかった★(指示書AS 段階3-1)。
  //   ★P-5=B(第11回。D-158)で `task_area = 0` は 0% になり、両向きに成り立つ★。
  //   ★それでも未実施は `departed` で数える(【未決1】案A。D-156 / 指示書AU D3)★
  //
  // | 入力                     | departed | completion_rate |
  // |--------------------------|----------|-----------------|
  // | 92.4 / 100               | true     | 92.4            |
  // | ★0 / 100★               | ★true★  | ★0★            |
  // | ★10 / 0(task_area=0)★ | ★true★  | ★0★            |
  // | ★レポート行なし★        | ★false★ | ★null★         |
  const cases: ReadonlyArray<
    readonly [
      string,
      { clean_area: number; task_area: number; clean_ratio: number } | undefined,
      boolean,
      number | null,
    ]
  > = [
    ["92.4 / 100", report(92.4), true, 92.4],
    ["0 / 100(0%で実施)", report(0), true, 0],
    ["100 / 100", report(100), true, 100],
    ["★10 / 0(task_area = 0)★", { clean_area: 10, task_area: 0, clean_ratio: 0 }, true, 0],
    ["★レポート行なし★", undefined, false, null],
  ];

  it.each(cases)("%s → departed=%s / completion_rate=%s", (_label, row, departed, rate) => {
    // ★呼び出し側(`cleanLogs()`)が作る形をそのまま固定する★
    expect(!notExecutedFromReport(row)).toBe(departed);
    expect(completionRateFromReport(row)).toBe(rate);
  });

  it("全ケースで departed === false ⟹ completion_rate === null", () => {
    for (const [label, row] of cases) {
      const departed = !notExecutedFromReport(row);
      const rate = completionRateFromReport(row);
      if (!departed) expect(rate, label).toBeNull();
    }
  });

  it("★両向きに成り立つ★: 全ケースで completion_rate === null ⟹ departed === false(task_area = 0 も 0。P-5=B)", () => {
    for (const [label, row] of cases) {
      const departed = !notExecutedFromReport(row);
      const rate = completionRateFromReport(row);
      if (rate === null) expect(departed, label).toBe(false);
    }
    const row = { clean_area: 10, task_area: 0, clean_ratio: 0 };
    expect(!notExecutedFromReport(row)).toBe(true);
    expect(completionRateFromReport(row)).toBe(0);
  });
});

describe("latestReportByTimetableId", () => {
  /**
   * `clean_task_reports` の行に見立てた最小の形。
   * ★`ended_at` は nullable★(`prisma/schema.prisma:541`)、`started_at` は NOT NULL(`:540`)。
   */
  const row = (
    timetableId: number | null,
    startedAt: string,
    endedAt: string | null,
    cleanRatio = 0
  ) => ({
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    started_at: new Date(startedAt),
    ended_at: endedAt === null ? null : new Date(endedAt),
    // ★完了率は面積から決まる(D-155)★ — `task_area = 100` に対して `clean_area = cleanRatio`。
    //   `clean_ratio` 列にも同じ値を入れる(列は完了率に使っていない。D6)
    clean_area: cleanRatio,
    task_area: 100,
    clean_ratio: cleanRatio,
  });

  it("キーは clean_timetable_id.toString()(BigInt は === で引けない)", () => {
    const map = latestReportByTimetableId([
      row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
    ]);
    expect(map.get("1001")).toBeDefined();
    expect(map.size).toBe(1);
  });

  it("★同一 timetable が複数あれば ended_at が新しい方★", () => {
    const older = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
    const newer = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T04:30:00+09:00");
    expect(latestReportByTimetableId([older, newer]).get("1001")).toBe(newer);
    // 入力順が逆でも同じ答え(DBの返却順に依存しない)
    expect(latestReportByTimetableId([newer, older]).get("1001")).toBe(newer);
  });

  it("★ended_at 同着なら入力順の先頭★(現行の `<` 比較の挙動を維持する)", () => {
    const first = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
    const second = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
    expect(latestReportByTimetableId([first, second]).get("1001")).toBe(first);
  });

  // -------------------------------------------------------------------------
  // ★★順序キー: `ended_at` があればそれ、無ければ `started_at`★★(指示書AQ §1-4 / 落とし穴3)
  // -------------------------------------------------------------------------

  it("★ended_at が null の行は started_at で比較する★(3-1)", () => {
    // ended_at なし・started_at が新しい方が勝つ
    const older = row(1001, "2026-09-10T02:00:00+09:00", null);
    const newer = row(1001, "2026-09-10T05:00:00+09:00", null);
    expect(latestReportByTimetableId([older, newer]).get("1001")).toBe(newer);
    expect(latestReportByTimetableId([newer, older]).get("1001")).toBe(newer);
  });

  it("★ended_at 有りと無しが混在しても落ちない★(3-2。null を getTime() しない)", () => {
    // ended_at=null 側の started_at(05:00) が、ended_at 有り側の ended_at(03:30) より新しい
    const ended = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
    const running = row(1001, "2026-09-10T05:00:00+09:00", null);
    expect(latestReportByTimetableId([ended, running]).get("1001")).toBe(running);
    expect(latestReportByTimetableId([running, ended]).get("1001")).toBe(running);
  });

  it("★ended_at 有りの方が新しければそちらが勝つ★(3-3。混在の逆向き)", () => {
    const running = row(1001, "2026-09-10T02:00:00+09:00", null);
    const ended = row(1001, "2026-09-10T02:30:00+09:00", "2026-09-10T06:00:00+09:00");
    expect(latestReportByTimetableId([running, ended]).get("1001")).toBe(ended);
    expect(latestReportByTimetableId([ended, running]).get("1001")).toBe(ended);
  });

  it("★started_at ではなく ended_at を優先する★(3-4。started_at だけで比較していない証明)", () => {
    // started_at は A の方が新しいが、ended_at は B の方が新しい → B が勝つ
    const a = row(1001, "2026-09-10T04:00:00+09:00", "2026-09-10T04:30:00+09:00");
    const b = row(1001, "2026-09-10T01:00:00+09:00", "2026-09-10T07:00:00+09:00");
    expect(latestReportByTimetableId([a, b]).get("1001")).toBe(b);
    expect(latestReportByTimetableId([b, a]).get("1001")).toBe(b);
  });

  it("★ended_at が両方 null で started_at も同着なら入力順の先頭★(3-5)", () => {
    const first = row(1001, "2026-09-10T02:00:00+09:00", null);
    const second = row(1001, "2026-09-10T02:00:00+09:00", null);
    expect(latestReportByTimetableId([first, second]).get("1001")).toBe(first);
  });

  it("★ended_at が全部 null でも Map に入る(行ごと消えない)★(3-6)", () => {
    const map = latestReportByTimetableId([row(1001, "2026-09-10T02:00:00+09:00", null)]);
    expect(map.size).toBe(1);
    expect(map.get("1001")?.ended_at).toBeNull();
  });

  // -------------------------------------------------------------------------
  // ★★`clean_timetable_id` が NULL の行★★(指示書AQ 落とし穴1)
  // -------------------------------------------------------------------------

  it("★clean_timetable_id が null の行は Map に入れない★(\"null\" という偽のキーを作らない)", () => {
    const map = latestReportByTimetableId([
      row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
    ]);
    expect(map.size).toBe(0);
    expect(map.has("null")).toBe(false);
  });

  it("★null の行が混ざっても、他の行の対応は壊れない★", () => {
    const valid = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
    const orphanA = row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T09:00:00+09:00");
    const orphanB = row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T10:00:00+09:00");
    const map = latestReportByTimetableId([orphanA, valid, orphanB]);
    expect(map.size).toBe(1);
    expect(map.get("1001")).toBe(valid);
  });

  // -------------------------------------------------------------------------
  // ★★`clean_timetable_id` が 0 の行★★(TeamWorks 回答 2026-10-27「0は予定外タスク」。NULL と同じ扱い)
  // -------------------------------------------------------------------------

  it("★clean_timetable_id が 0 の行は Map に入れない★(\"0\" という偽のキーを作らない。NULL と同じ)", () => {
    const map = latestReportByTimetableId([
      row(0, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
    ]);
    expect(map.size).toBe(0);
    expect(map.has("0")).toBe(false);
  });

  it("★0 と null の行が混ざっても、他の行の対応は壊れない★(予定外どうしが上書きし合わない)", () => {
    const valid = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
    const zeroA = row(0, "2026-09-10T02:00:00+09:00", "2026-09-10T09:00:00+09:00");
    const zeroB = row(0, "2026-09-10T02:00:00+09:00", "2026-09-10T10:00:00+09:00");
    const orphan = row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T11:00:00+09:00");
    const map = latestReportByTimetableId([zeroA, valid, orphan, zeroB]);
    expect(map.size).toBe(1);
    expect(map.get("1001")).toBe(valid);
  });

  it("★null の行に対応する本清掃は「レポート行なし」=出発失敗として出る★(fail-loud)", () => {
    const map = latestReportByTimetableId([
      row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
    ]);
    expect(notExecutedFromReport(map.get("1001"))).toBe(true);
    expect(completionRateFromReport(map.get("1001"))).toBeNull();
  });

  it("別 timetable は別エントリ", () => {
    const map = latestReportByTimetableId([
      row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
      row(1002, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
    ]);
    expect(map.size).toBe(2);
    expect([...map.keys()].sort()).toEqual(["1001", "1002"]);
  });

  it("空配列なら空の Map", () => {
    expect(latestReportByTimetableId([]).size).toBe(0);
  });

  it("★どの行を選ぶかで completion_rate の答えが変わる★(規則を1箇所にした理由。落とし穴2)", () => {
    const old = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00", 12.5);
    const latest = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T04:30:00+09:00", 95.0);
    expect(completionRateFromReport(latestReportByTimetableId([old, latest]).get("1001"))).toBe(
      95.0
    );
    // ★DB返却順の後勝ちだと答えが変わっていた★
    expect(completionRateFromReport(latestReportByTimetableId([latest, old]).get("1001"))).toBe(
      95.0
    );
  });

  it("★戻りの Map.get() をそのまま判定関数に渡せる(呼び出し側の形)★", () => {
    const map = latestReportByTimetableId([
      row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00", 80),
    ]);
    expect(notExecutedFromReport(map.get("1001"))).toBe(false);
    // ★存在しないキー → undefined → 出発失敗(設計3-C / 指示書AQ §1-1)★
    expect(notExecutedFromReport(map.get("9999"))).toBe(true);
    expect(completionRateFromReport(map.get("9999"))).toBeNull();
  });
});

// ★★表5(指示書AS §4。【未決1】案A。D-156)— 連続日数の問題日は `completion_rate` で見る(変えない)★★
//   U2(行がある・`task_area = 0` → completion_rate 0.0。P-5=B)は ★問題日のまま★(0.0 は ×)
describe("isProblemCompletion — 表5(指示書AS)", () => {
  it.each<[string, { clean_area: number; task_area: number; clean_ratio: number } | undefined, boolean]>([
    ["U1 レポートの行が無い → 問題", undefined, true],
    ["★U2 行がある・task_area = 0 → 0.0 で問題(×。P-5=B)★", { clean_area: 10, task_area: 0, clean_ratio: 0 }, true],
    ["U3 行がある・50/100 → 問題(△)", { clean_area: 50, task_area: 100, clean_ratio: 50 }, true],
    ["U4 行がある・100/100 → 問題なし", { clean_area: 100, task_area: 100, clean_ratio: 100 }, false],
  ])("%s", (_name, reportRow, expected) => {
    expect(isProblemCompletion(completionRateFromReport(reportRow))).toBe(expected);
  });

  it("U5 同じ施設・同じ日に U1 と U4 → その日は問題(OR)", () => {
    const base = { unscheduled: false, facility_id: 1, scheduled_at: "2026-09-10T02:00:00+09:00", clean_area: 0, task_area: 0 };
    expect(
      foldProblemDaysByDate([
        { ...base, id: 1, completion_rate: null, departed: false },
        { ...base, id: 2, completion_rate: 100, departed: true },
      ])
    ).toEqual([{ date: "2026-09-10", isProblem: true }]);
  });
});

describe("isProblemCompletion", () => {
  // | 完了率 | 問題あり | 備考                                    |
  // |--------|----------|------------------------------------------|
  // | null   | true     | 未実施                                    |
  // | 0      | true     | ×                                        |
  // | 9.9    | true     | ×                                        |
  // | 10.0   | true     | △                                        |
  // | 69.9   | true     | △                                        |
  // | 70.0   | ★false★ | ★○なので問題なし(D-139。旧 D-020b では △ で問題ありだった)★|
  // | 70.1   | false    | ○                                        |
  // | 90.0   | false    | ◎(D-139)                               |
  // | 90.1   | false    | ◎                                        |
  it.each([
    [null, true],
    [0, true],
    [9.9, true],
    [10.0, true],
    [69.9, true],
    [70.0, false],
    [70.1, false],
    [90.0, false],
    [90.1, false],
  ] as const)("completion_rate=%s → 問題あり=%s", (rate, expected) => {
    expect(isProblemCompletion(rate)).toBe(expected);
  });

  it("★完了率ちょうど70.0は○なので「問題なし」(D-139。2026-09-16ユーザー指示)★", () => {
    // ユーザー原文: 「◯、×は90%以上のように境はふくめてください」
    // CLAUDE.md §4.1 の△が「10%以上70%未満」になったので 70.0 は ○ = 問題なし。
    // ★judgeGrade() と同じ規則★(入力が完了率か grade かの違いだけ)。
    expect(isProblemCompletion(70.0)).toBe(false);
    // ★直前の 69.9 は △ のまま問題あり★(境界の片側だけが動いたことの確認)
    expect(isProblemCompletion(69.9)).toBe(true);
  });
});

describe("computeConsecutiveProblemDays", () => {
  // ★指示書V 段階3(不具合B)で分割キーを serial_number → facility_id に変更した★
  //   (CLAUDE.md §4.5 の R3/R4「同一施設で」に合わせるため。1施設に複数の清掃エリアが
  //   実在する= serial_number では代用にならない)。第2引数は施設id(数値)
  const row = (
    id: number,
    facilityId: number,
    date: string,
    rate: number | null
  ): ConsecutiveInput => ({
    id,
    unscheduled: false,
    facility_id: facilityId,
    scheduled_at: `${date}T02:00:00+09:00`,
    completion_rate: rate,
    // ★連続日数は `departed` を見ない★(見るのは `completion_rate` だけ)。ここでは
    //   null = レポート行なしとして詰める(`task_area = 0` のケースは表5 U2 で別に確かめる)
    departed: rate !== null,
    // ★連続日数は面積を見ない★(見るのは `completion_rate` だけ)。指示書AI 段階2
    clean_area: 0,
    task_area: 0,
  });

  it("問題なしの行は 0", () => {
    const map = computeConsecutiveProblemDays([row(1, 1, "2026-09-01", 95)]);
    expect(map.get(1)).toBe(0);
  });

  it("1日 / 2日 / 3日と積み上がる", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 95),
      row(2, 1, "2026-09-02", 40),
      row(3, 1, "2026-09-03", null),
      row(4, 1, "2026-09-04", 10),
    ]);
    expect(map.get(1)).toBe(0);
    expect(map.get(2)).toBe(1);
    expect(map.get(3)).toBe(2);
    expect(map.get(4)).toBe(3);
  });

  it("問題なしの日が入ると 0 にリセットされる", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 40),
      row(2, 1, "2026-09-02", 95),
      row(3, 1, "2026-09-03", 40),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(0);
    expect(map.get(3)).toBe(1);
  });

  it("★スケジュール非存在日を挟んでも連続が途切れない(D-026)★(b4)", () => {
    // 月水金スケジュールの施設。カレンダー上は5日間だが行があるのは3日だけ → L3相当の3
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-07", 41),
      row(2, 1, "2026-09-09", 33),
      row(3, 1, "2026-09-11", null),
    ]);
    expect(map.get(3)).toBe(3);
  });

  it("入力が日付降順でも正しく数える(並べ替えは関数の責任)", () => {
    const map = computeConsecutiveProblemDays([
      row(3, 1, "2026-09-03", 20),
      row(1, 1, "2026-09-01", 20),
      row(2, 1, "2026-09-02", 20),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(2);
    expect(map.get(3)).toBe(3);
  });

  it("施設(facility_id)ごとに独立して数える(b5。1台のみの施設。b3相当)", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 20),
      row(2, 2, "2026-09-01", 95),
      row(3, 1, "2026-09-02", 20),
      row(4, 2, "2026-09-02", 20),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(0);
    expect(map.get(3)).toBe(2);
    expect(map.get(4)).toBe(1);
  });

  // ★決定2(2026-09-11 ユーザー確定): 同一ロボットが同じ日に2回清掃した場合も「1日」に畳む★
  //   (D-026「連続はスケジュール存在日の連続」=日単位)。
  //   ★★意図した仕様変更: 期待値を 2 → 1 に直した(b8)★★ — 旧実装は畳み込みが無く、
  //   同じ日の2行を2日と数えていた
  it("同一ロボットが同じ日に2回清掃しても1日として数える(決定2。日単位。b8)", () => {
    const map = computeConsecutiveProblemDays([
      { ...row(1, 1, "2026-09-01", 20), scheduled_at: "2026-09-01T02:00:00+09:00" },
      { ...row(2, 1, "2026-09-01", 20), scheduled_at: "2026-09-01T22:00:00+09:00" },
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(1);
  });

  it("空配列でも落ちない(b10)", () => {
    expect(computeConsecutiveProblemDays([]).size).toBe(0);
  });

  // -------------------------------------------------------------------------
  // ★指示書V 段階3 期待値表(b) — 複数ロボット・施設単位の畳み込み(すべて必須。ユーザー指定)★
  // -------------------------------------------------------------------------

  it("b1: 2台・3日・毎日どちらか1台が問題 → 3日連続(同じ日の全行が同じ値)", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 50), // 問題
      row(2, 1, "2026-09-01", 95),
      row(3, 1, "2026-09-02", 95),
      row(4, 1, "2026-09-02", 40), // 問題
      row(5, 1, "2026-09-03", 30), // 問題
      row(6, 1, "2026-09-03", 99),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(1);
    expect(map.get(3)).toBe(2);
    expect(map.get(4)).toBe(2);
    expect(map.get(5)).toBe(3);
    expect(map.get(6)).toBe(3);
  });

  it("b2: 2台・同じ日に両方問題でも2にならない(落とし穴1)", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 50),
      row(2, 1, "2026-09-01", 40),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(1);
  });

  it("b6: 同じ日に複数台・最新日が問題なし", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 50),
      row(2, 1, "2026-09-02", 95),
      row(3, 1, "2026-09-02", 99),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(0);
    expect(map.get(3)).toBe(0);
  });

  it("b7: ロボットが違っても施設が同じなら連続する(仕様の本体。旧実装では両方1だった)", () => {
    // id1はロボットA相当・id2はロボットB相当。旧実装(serial_number分割)では別集計になり
    // どちらも1のままだったが、施設単位に揃えると2に連続する
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 50),
      row(2, 1, "2026-09-02", 50),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(2);
  });

  it("b9: 未実施(completion_rate === null)は問題ありとして数える", () => {
    const map = computeConsecutiveProblemDays([row(1, 1, "2026-09-01", null)]);
    expect(map.get(1)).toBe(1);
  });

  it("b11: 同じ日の行の並び順を変えても結果が同じ(b1と同じ入力を任意の順で渡す)", () => {
    const map = computeConsecutiveProblemDays([
      row(4, 1, "2026-09-02", 40),
      row(6, 1, "2026-09-03", 99),
      row(1, 1, "2026-09-01", 50),
      row(3, 1, "2026-09-02", 95),
      row(5, 1, "2026-09-03", 30),
      row(2, 1, "2026-09-01", 95),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(1);
    expect(map.get(3)).toBe(2);
    expect(map.get(4)).toBe(2);
    expect(map.get(5)).toBe(3);
    expect(map.get(6)).toBe(3);
  });

  // ★★「正常終了した行があっても、同じ日に履歴が無い行があればその日は問題日」★★
  //   `isProblemCompletion(null) === true` なので OR で畳むと true になる(D-109 決定)。
  //   ★★気づいた人が「正常終了しているのだから false では」と直さないように固定する★★
  it("b12: 同一日に履歴が無い行(null)と正常終了の行(95)が混在 → 両方とも連続1", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", null),
      row(2, 1, "2026-09-01", 95),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(1);
  });

  // ★★D-139(2026-09-16): 完了率ちょうど70.0は ○ になった★★
  //   (旧 D-020b 下では △=問題ありで連続1だった。指示書V 決定3)。
  //   ★ここを直し忘れると、連続日数が静かに1日多く数えられ、
  //   L1〜L3 → R3/R4 → 要レスキュー店舗まで波及する★
  it("d1: 完了率ちょうど70.0の行は○なので連続0(D-139。旧規則では1だった)", () => {
    const map = computeConsecutiveProblemDays([row(1, 1, "2026-09-01", 70.0)]);
    expect(map.get(1)).toBe(0);
  });

  it("d2: 69.9(△)は問題あり、翌日70.0(○)で連続はリセットされる", () => {
    const map = computeConsecutiveProblemDays([
      row(1, 1, "2026-09-01", 69.9),
      row(2, 1, "2026-09-02", 70.0),
    ]);
    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(0);
  });
});

describe("foldProblemDaysByDate", () => {
  const row = (
    id: number,
    facilityId: number,
    date: string,
    rate: number | null,
    time = "02:00:00"
  ): ConsecutiveInput => ({
    id,
    unscheduled: false,
    facility_id: facilityId,
    scheduled_at: `${date}T${time}+09:00`,
    completion_rate: rate,
    departed: rate !== null,
    // ★畳み込みは面積を見ない★(見るのは `completion_rate` だけ)。指示書AI 段階2
    clean_area: 0,
    task_area: 0,
  });

  it("a1: 1日1行", () => {
    expect(foldProblemDaysByDate([row(1, 1, "2026-09-01", 50)])).toEqual([
      { date: "2026-09-01", isProblem: true },
    ]);
  });

  it("a2: 同一日に問題行と正常行があれば1要素(OR)", () => {
    expect(
      foldProblemDaysByDate([
        row(1, 1, "2026-09-01", 50),
        row(2, 1, "2026-09-01", 95),
      ])
    ).toEqual([{ date: "2026-09-01", isProblem: true }]);
  });

  it("a3: 同一日に正常行だけなら問題なし", () => {
    expect(
      foldProblemDaysByDate([
        row(1, 1, "2026-09-01", 95),
        row(2, 1, "2026-09-01", 99),
      ])
    ).toEqual([{ date: "2026-09-01", isProblem: false }]);
  });

  it("a4: 入力順が逆でもa2と同じ結果(順序非依存)", () => {
    expect(
      foldProblemDaysByDate([
        row(2, 1, "2026-09-01", 95),
        row(1, 1, "2026-09-01", 50),
      ])
    ).toEqual([{ date: "2026-09-01", isProblem: true }]);
  });

  it("a5: 複数日は日付昇順で返す", () => {
    expect(
      foldProblemDaysByDate([
        row(1, 1, "2026-09-01", 50),
        row(2, 1, "2026-09-03", 95),
      ])
    ).toEqual([
      { date: "2026-09-01", isProblem: true },
      { date: "2026-09-03", isProblem: false },
    ]);
  });

  it("a6: 日付が降順で来ても昇順に並ぶ", () => {
    expect(
      foldProblemDaysByDate([
        row(2, 1, "2026-09-03", 95),
        row(1, 1, "2026-09-01", 50),
      ])
    ).toEqual([
      { date: "2026-09-01", isProblem: true },
      { date: "2026-09-03", isProblem: false },
    ]);
  });

  it("a7: 未実施(completion_rate === null)は問題あり", () => {
    expect(foldProblemDaysByDate([row(1, 1, "2026-09-01", null)])).toEqual([
      { date: "2026-09-01", isProblem: true },
    ]);
  });

  it("a8: 同一日・同一ロボットが2回(決定2)でも1要素", () => {
    expect(
      foldProblemDaysByDate([
        row(1, 1, "2026-09-01", 20, "02:00:00"),
        row(2, 1, "2026-09-01", 20, "22:00:00"),
      ])
    ).toEqual([{ date: "2026-09-01", isProblem: true }]);
  });

  // ★★「正常終了した行があっても、同じ日に履歴が無い行があればその日は問題日」★★
  //   `isProblemCompletion(null) === true` なので OR で畳むと true になる(D-109 決定)。
  //   ★★気づいた人が「正常終了しているのだから false では」と直さないように固定する★★
  it("a10: 同一日に履歴が無い行(null)と正常終了の行(95)が混在しても問題日(OR)", () => {
    expect(
      foldProblemDaysByDate([
        row(1, 1, "2026-09-01", null),
        row(2, 1, "2026-09-01", 95),
      ])
    ).toEqual([{ date: "2026-09-01", isProblem: true }]);
  });

  it("a9: 空配列は空配列", () => {
    expect(foldProblemDaysByDate([])).toEqual([]);
  });
});

// ★表10 N4(指示書BB A6・D16・D-182)★ — 連続日数は施設ごとに独立に数えるので、入力を施設で絞っても
//   絞った施設の値は変わらない(`loadConsecutiveInputs()` に `facility_ids` を渡してよい根拠)
describe("computeConsecutiveProblemDays — 施設で絞っても値が変わらない(指示書BB 表10 N4)", () => {
  const row = (id: number, facilityId: number, date: string, rate: number | null): ConsecutiveInput => ({
    id,
    unscheduled: false,
    facility_id: facilityId,
    scheduled_at: `${date}T02:00:00+09:00`,
    completion_rate: rate,
    departed: rate !== null,
    clean_area: 0,
    task_area: 0,
  });
  // 施設1 = 3日連続の問題(L3)/施設2 = 正常/施設3 = 予定の無い日(09-02)を挟む2日連続
  const rows: ConsecutiveInput[] = [
    row(11, 1, "2026-09-01", 40),
    row(12, 1, "2026-09-02", null),
    row(13, 1, "2026-09-03", 5),
    row(21, 2, "2026-09-01", 95),
    row(22, 2, "2026-09-02", 92),
    row(23, 2, "2026-09-03", 99),
    row(31, 3, "2026-09-01", 40),
    row(33, 3, "2026-09-03", 30),
  ];

  it("★施設 {1, 3} に絞った入力の各 id の値が、全施設の入力の同じ id の値と一致する★", () => {
    const all = computeConsecutiveProblemDays(rows);
    const scoped = computeConsecutiveProblemDays(rows.filter((r) => r.facility_id === 1 || r.facility_id === 3));
    expect([...scoped.keys()].sort((a, b) => a - b)).toEqual([11, 12, 13, 31, 33]);
    for (const [id, days] of scoped) expect(days).toBe(all.get(id));
    // 値そのもの(施設1 は 3日目で 3・施設3 は予定の無い日を挟んで 2)
    expect(scoped.get(13)).toBe(3);
    expect(scoped.get(33)).toBe(2);
  });
});