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"],
  ] 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]);
    });

    it("★prechecks(): 未来の本清掃行から launch_failed を作らない★", () => {
      // ★実装と同じ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, 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, 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, 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);
  });

  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 = { 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,
    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,
    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([]);
  });
});
