Newer
Older
client_dwatch / lib / db / __tests__ / report-mapping.test.ts
import { describe, expect, it } from "vitest";
import { CLEAN_TASK_STATUS, ESCALATION_THRESHOLDS } from "@/lib/config";
import { translate } from "@/lib/i18n";
// ★`meanCleanRatio()` は `lib/judge/kpi.ts` に移した★(2026-09-30。挙動は同じ)
import {
  judgeEscalationLevel,
  judgeFacilityStatus,
  judgeGrade,
  meanCleanRatio,
} from "@/lib/judge";
import { weekOfMonth } from "@/lib/format/period";
import type { ContinuousProblem } from "@/lib/types";
import {
  computeConsecutiveProblemDays,
  latestReportByTimetableId,
  notExecutedFromReport,
  type ConsecutiveInput,
} from "../clean-log-mapping";
import {
  aggregateCleanStatsByFacility,
  capContinuousProblems,
  consecutiveProblemDaysAtPeriodEnd,
  continuousProblemsInPeriod,
  continuousProblemSummary,
  countMainErrorTypesInRange,
  customerSummariesByPeriodKey,
  emptyCleanStatsBucket,
  errorBreakdownItemsFromCounts,
  errorBreakdownRowsFromSummary,
  errorCountByMainTimetable,
  kpiFromCleanStats,
  kpiFromSummaryRatios,
  lastEntryInPeriod,
  launchFailedDaysInRun,
  mainTimetableStartAtById,
  periodMapKey,
  periodOfSummaryRow,
  problemRunDates,
  reportPeriodKeysFromDates,
  sumCleanStatsBuckets,
  SUMMARY_TEMPLATE_CAPPED_COMPLETION,
  SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED,
  SUMMARY_TEMPLATE_CAPPED_MIXED,
  SUMMARY_TEMPLATE_COMPLETION,
  SUMMARY_TEMPLATE_LAUNCH_FAILED,
  SUMMARY_TEMPLATE_MIXED,
  type CleanStatsBucket,
  type CleanStatsReportLike,
  type FacilityConsecutiveEntry,
  type CleanStatsTimetableLike,
  type MainTimetableLike,
} from "../report-mapping";

/**
 * ★未実施の述語(`aggregateCleanStatsByFacility()` の第3引数)★ —
 *   指示書AQ 1-1「★未実施 = 対応する `clean_task_reports` の行が無い★」。
 *
 * ★本実装(担当C。`lib/db/clean-log-mapping.ts`)の完成を待たずにテストを書けるよう、
 *   ★規則をここに写して注入する★★ — このテストが固定しているのは
 *   「★`aggregateCleanStatsByFacility()` が渡された述語に従うこと★」であって、
 *   ★述語そのものの正しさは担当C側のテストの責務★(規則を2箇所で判定しない。D-103 項1)。
 */
/** ★既存テストのレポート行は正常終了にしておく★(status を見るテスト以外。指示書AU 段階3-4) */
const COMPLETED = CLEAN_TASK_STATUS.completed;

const missingRowIsNotExecuted = (report: CleanStatsReportLike | undefined): boolean =>
  report === undefined;

/**
 * ★本清掃行に予定の終了(`end_at`)を足す★(結果待ちの判定用。D-173)。★開始の1時間後★にする。
 * 既存のテストの行は `MainTimetableLike`(終了なし)のまま書いてあるので、ここで足して渡す。
 */
function withEndAt(rows: readonly MainTimetableLike[]): CleanStatsTimetableLike[] {
  return rows.map((row) => ({ ...row, end_at: new Date(row.start_at.getTime() + 3_600_000) }));
}

/** 上の述語を差した `aggregateCleanStatsByFacility()`(テストの見通しのため) */
function aggregate(
  mainRows: readonly MainTimetableLike[],
  reportByTimetableId: ReadonlyMap<string, CleanStatsReportLike>,
  errorCountByMain: ReadonlyMap<string, number>,
  range: { from: string; to: string },
  now: Date
) {
  return aggregateCleanStatsByFacility(
    withEndAt(mainRows),
    reportByTimetableId,
    missingRowIsNotExecuted,
    errorCountByMain,
    range,
    now
  );
}

/** テスト用ヘルパー: ConsecutiveInput を1件作る */
function consecutiveRow(
  id: number,
  facilityId: number,
  date: string,
  completionRate: number | null,
  time = "09:00:00"
): ConsecutiveInput {
  return {
    id,
    facility_id: facilityId,
    scheduled_at: `${date}T${time}+09:00`,
    completion_rate: completionRate,
    // ★既定は「null = レポート行なし(未実施)」★。`departed` と `completion_rate` が食い違う行(防御)は
    //   表5 のテストで `departed` を明示して作る(【未決1】案A。D-156 / 指示書AU D3)
    departed: completionRate !== null,
    // ★レポートの連続日数は面積を見ない★(見るのは `completion_rate` だけ)。指示書AI 段階2
    clean_area: 0,
    task_area: 0,
  };
}

// ================================================================
// ★期待値表(実装より先に確定させたもの。指示書AQ §3 罠2 / CLAUDE.md §4.7)★
//
// (p) periodOfSummaryRow() — ★`month` と `week` の**両方**を見る★
//   p1: { month: 0, week: 0 }   → ★yearly★(年次。これを直すのが本題)
//   p2: { month: 9, week: 0 }   → monthly
//   p3: { month: 9, week: 2 }   → weekly
//   p4: { month: 1, week: 0 }   → monthly(★month の最小値。年次と1違い★)
//   p5: { month: 12, week: 0 }  → monthly
//   p6: { month: 9, week: 1 }   → weekly(第1週)
//   p7: { month: 8, week: 6 }   → weekly(★第6週は D-071 の下で実在する★)
//   p8: { month: 0, week: 2 }   → weekly(★week>=1 なら month=0 でも年次ではない★。
//                                  実データには無いはずの形だが、★年次と取り違えない★ことを固定する)
// ================================================================
describe("periodOfSummaryRow", () => {
  it("p1 ★month=0 かつ week=0 は yearly★(従来は monthly と誤判定していた。指示書AQ 罠2)", () => {
    expect(periodOfSummaryRow({ month: 0, week: 0 })).toBe("yearly");
  });
  it("p2 month=9 / week=0 は monthly", () => {
    expect(periodOfSummaryRow({ month: 9, week: 0 })).toBe("monthly");
  });
  it("p3 month=9 / week=2 は weekly", () => {
    expect(periodOfSummaryRow({ month: 9, week: 2 })).toBe("weekly");
  });
  it("p4 month=1 / week=0 は monthly(年次との境界)", () => {
    expect(periodOfSummaryRow({ month: 1, week: 0 })).toBe("monthly");
  });
  it("p5 month=12 / week=0 は monthly", () => {
    expect(periodOfSummaryRow({ month: 12, week: 0 })).toBe("monthly");
  });
  it("p6 week=1 は weekly", () => {
    expect(periodOfSummaryRow({ month: 9, week: 1 })).toBe("weekly");
  });
  it("p7 week=6 は weekly(第6週は D-071 の下では発生する)", () => {
    expect(periodOfSummaryRow({ month: 8, week: 6 })).toBe("weekly");
  });
  it("p8 month=0 でも week>=1 なら weekly(年次は week=0 との AND)", () => {
    expect(periodOfSummaryRow({ month: 0, week: 2 })).toBe("weekly");
  });
});

describe("periodMapKey", () => {
  it("period/year/month/week の形の文字列を作る", () => {
    expect(periodMapKey("weekly", 2026, 9, 2)).toBe("weekly/2026/9/2");
    expect(periodMapKey("monthly", 2026, 9, 0)).toBe("monthly/2026/9/0");
  });
});

/**
 * 指示書AH ★段階3★ の期待値表 #1〜#8(純粋関数側)。
 *
 * ★★何を守るためのテストか★★
 *  - ★複数顧客の行が混ざったら**止める**★(D-5。ユーザー判断 2026-09-14)—
 *    別会社の数字で運用担当に判断させるくらいなら画面が出ないほうがまし
 *  - ★★#5 が本体★★(★指示書AH 罠5★)— ★検知を「期間キーの衝突」だけで書くと、
 *    A社が当期の行だけ・B社が前期の行だけを持つケースを取り逃がす★。
 *    キーは衝突しないのに ★前期比だけが静かに汚染される★。
 *    ★だから検知は「全行の `customer_id` が1種類か」で行う★
 *  - ★`week=0`(月次の予約値)を壊さない★(#7。D-072項4)
 */
describe("customerSummariesByPeriodKey(指示書AH 段階3 / D-5)", () => {
  /** `customer_summaries` 1行(この関数が見るフィールドだけ) */
  function row(customerId: number, year: number, month: number, week: number) {
    return { customer_id: BigInt(customerId), year, month, week };
  }

  it("#1 空配列 → 空 Map(例外を投げない)", () => {
    expect(customerSummariesByPeriodKey([]).size).toBe(0);
  });

  it("#2 1顧客・1行 → 1件", () => {
    const rows = [row(1, 2026, 9, 2)];
    const map = customerSummariesByPeriodKey(rows);
    expect(map.size).toBe(1);
    expect(map.get("weekly/2026/9/2")).toBe(rows[0]);
  });

  it("#3 同一顧客の当期+前期 → 2件", () => {
    const rows = [row(1, 2026, 9, 2), row(1, 2026, 9, 1)];
    const map = customerSummariesByPeriodKey(rows);
    expect(map.size).toBe(2);
    expect(map.get("weekly/2026/9/2")).toBe(rows[0]);
    expect(map.get("weekly/2026/9/1")).toBe(rows[1]);
  });

  it("#4 2顧客・同じ期間キー → throw", () => {
    expect(() =>
      customerSummariesByPeriodKey([row(1, 2026, 9, 2), row(2, 2026, 9, 2)])
    ).toThrow(/複数の顧客の行が混ざっています/);
  });

  it("★★#5 2顧客・別々の期間キー(当期がA社・前期がB社)→ throw★★(★罠5の核心★)", () => {
    // ★★キーは衝突しない★★ — `weekly/2026/9/2` と `weekly/2026/9/1` で別物。
    //   ★「同じ期間キーが2回来たら例外」だけの実装はここを素通りし、
    //   前期比(=両社の差という無意味な数字)だけが静かに汚染される★
    const rows = [row(1, 2026, 9, 2), row(2, 2026, 9, 1)];
    const keys = rows.map((r) => periodMapKey("weekly", r.year, r.month, r.week));
    expect(new Set(keys).size).toBe(rows.length); // ★キーが衝突していないことを先に固定する★
    expect(() => customerSummariesByPeriodKey(rows)).toThrow(/複数の顧客の行が混ざっています/);
  });

  it("#6 同一顧客で同じ期間キーが2行 → throw(一意制約が無く後勝ちで不定になるため)", () => {
    expect(() =>
      customerSummariesByPeriodKey([row(1, 2026, 9, 2), row(1, 2026, 9, 2)])
    ).toThrow(/同じ期間キー(weekly\/2026\/9\/2)/);
  });

  it("#7 月次(week=0)の当月+前月 → 2件(`week=0` の予約値を壊さない)", () => {
    const rows = [row(1, 2026, 9, 0), row(1, 2026, 8, 0)];
    const map = customerSummariesByPeriodKey(rows);
    expect(map.size).toBe(2);
    expect([...map.keys()]).toEqual(["monthly/2026/9/0", "monthly/2026/8/0"]);
  });

  it("★#8 例外メッセージは `customer_id` を含み、PII(施設名等)を含まない★", () => {
    // ★施設名を持つ行を渡しても、メッセージには出ないこと★
    const withPii = [
      { ...row(1, 2026, 9, 2), facility_name: "バリューマート北町店" },
      { ...row(777, 2026, 9, 1), facility_name: "メガバリューマート緑野店" },
    ];
    let message = "";
    try {
      customerSummariesByPeriodKey(withPii);
    } catch (e) {
      message = (e as Error).message;
    }
    expect(message).toContain("customer_id=");
    expect(message).toContain("1");
    expect(message).toContain("777");
    for (const item of withPii) expect(message).not.toContain(item.facility_name);
    // ★モジュール名の前置(`lib/db/ds-status.ts` と同じ作法)★
    expect(message.startsWith("[report-mapping]")).toBe(true);
  });

  it("★#9 年次(month=0 / week=0)が混ざっても月次と衝突しない★(指示書AQ 罠2の再発防止)", () => {
    // ★`periodOfSummaryRow()` が `month` を見ないと、年次行のキーが `monthly/2026/0/0` になり、
    //   ★同じ顧客の月次行と並べたときに「月次が2つ」に見える★(重複検知で500。罠2)。
    //   ★月次と年次でキーの `period` が分かれていることを固定する★
    const rows = [row(1, 2026, 0, 0), row(1, 2026, 9, 0), row(1, 2026, 9, 2)];
    const map = customerSummariesByPeriodKey(rows);
    expect(map.size).toBe(3);
    expect([...map.keys()]).toEqual([
      "yearly/2026/0/0",
      "monthly/2026/9/0",
      "weekly/2026/9/2",
    ]);
  });

  it("#10 年次行が同じ顧客で2行あれば従来どおり throw(検知は残す)", () => {
    expect(() =>
      customerSummariesByPeriodKey([row(1, 2026, 0, 0), row(1, 2026, 0, 0)])
    ).toThrow(/同じ期間キー(yearly\/2026\/0\/0)/);
  });

  it("★`1n` と `1` を別の顧客として扱わない★(`String()` で正規化している)", () => {
    const map = customerSummariesByPeriodKey([
      { customer_id: BigInt(1), year: 2026, month: 9, week: 2 },
      { customer_id: 1, year: 2026, month: 9, week: 1 },
    ]);
    expect(map.size).toBe(2);
  });
});

describe("kpiFromSummaryRatios", () => {
  const current = {
    operation_ratio: 91.4,
    launch_ratio: 93.8,
    clean_ratio: 87.1,
    error_ratio: 4.6,
  };

  it("前期行があれば prev / diff が入る", () => {
    const prev = {
      operation_ratio: 89.6,
      launch_ratio: 94.2,
      clean_ratio: 84.9,
      error_ratio: 5.8,
    };
    const kpi = kpiFromSummaryRatios(current, prev);
    expect(kpi.operation_rate.value).toBe(91.4);
    expect(kpi.operation_rate.prev).toBe(89.6);
    expect(kpi.operation_rate.diff).toBeCloseTo(91.4 - 89.6, 10);
    expect(kpi.launch_rate.prev).toBe(94.2);
    expect(kpi.completion_rate.value).toBe(87.1);
    expect(kpi.error_rate.diff).toBeCloseTo(4.6 - 5.8, 10);
  });

  it("前期行が無ければ prev / diff は null(新規施設ケース)", () => {
    const kpi = kpiFromSummaryRatios(current, undefined);
    expect(kpi.operation_rate).toEqual({ value: 91.4, prev: null, diff: null });
    expect(kpi.error_rate).toEqual({ value: 4.6, prev: null, diff: null });
  });

  it("値は丸めずそのまま渡す(表示側で丸める。CLAUDE.md 4.3)", () => {
    const kpi = kpiFromSummaryRatios({ ...current, clean_ratio: 90.4 }, undefined);
    expect(kpi.completion_rate.value).toBe(90.4);
  });
});

// ================================================================
// ★期待値表(実装より先に確定させたもの。指示書AQ 決定4 / CLAUDE.md §4.2・§4.3)★
//
// 共通の前提: range = { from: "2026-09-06", to: "2026-09-12" } /
//   未実施の述語は ★「`clean_task_reports` の行が無ければ未実施」★(指示書AQ 1-1)を注入する。
//   ★この規則の実装は担当C(`lib/db/clean-log-mapping.ts`)★ — ここでは述語を注入して依存を切る。
//
// (w) 完了率の畳み方 — ★「清掃1回 = 1票」の単純平均★(面積加重にしない)
//   w1: 100% と 50% の2件                      → cleanRatioSum=150 / count=2 → 平均 75
//   w2: 100% の1件 + ★未実施(行なし)1件★   → count=1 → 平均 ★100★(未実施を0%として混ぜない)
//   w3: 全部未実施                              → count=0 → ★null★(0% ではない)
//   w4: ★89.5 の1件★                          → ★89.5★(丸めない。表示は90%でも判定は ○)
//   w5: ★大面積・低率(10%, 1000㎡) と 小面積・高率(90%, 10㎡)★
//                                               → ★50★(単純平均)。★面積加重なら 10.8 になる★
//   w6: ★述語が「行はあるが未実施」と答えた行★ → ratio は足さない/★面積は足す★(F-08)
//   w7: ★施設をまたいでも平均が壊れない★       → A(100,100) と B(0) の合算 → 平均 200/3
//   w8: 未来の予定は ratio にも面積にも入らない   → count=0 / taskArea=0
//   w9: 完了率0(面積 0/100)で実施した行        → ★count に入り、平均は 0★(★未実施と別物★)
//   w10: ★`clean_ratio` 列 12.3・面積 50/100★  → ★50★(★列ではなく面積から。D-155 / 指示書AS D6★)
//   w11: ★`task_area = 0` の行★               → ★実施1回・件数1・Σ に 0 を足す★(P-5=B。指示書AU 表3。旧: 件数に入れない)
//
//   ★1回ごとの完了率は面積から `runCompletionRate()` で決まる(D-155)★ — 下の行は
//   `clean_ratio` 列と面積比を揃えて書いてある(列は完了率に使っていない。指示書AS D6)
// ================================================================
describe("aggregateCleanStatsByFacility", () => {
  // ★指示書Y — `report-mapping.ts` の実装は `jstIsoFromDb(row.start_at).slice(0, 10)` で
  //   JST日付の桁を作る(`toJstIso()` 直呼びから変更)。`row.start_at` はDBのDATETIME列から
  //   出てきた値(Prismaのアダプタが `new Date(値+"Z")` でUTCとみなしたDate)なので、
  //   テストの入力も同じ形(末尾 `Z`)で書く。`jstIsoFromDb()` の性質により、この文字列の
  //   桁がそのまま出力の桁になる(`lib/format/__tests__/date.test.ts` で確認済み)。

  // ★`clean_task_reports` には `clean_error` 相当の列が無い(指示書AQ 1-1)★ので、
  //   ★入力は `clean_ratio` / `task_area` / `clean_area` の3つだけ★
  const reportMap = new Map<string, CleanStatsReportLike>([
    ["1", { clean_ratio: 87.5, task_area: 1000, clean_area: 875, status: COMPLETED }], // 実施
    // ★id=2 / id=3 は結果行なし(=未実施。指示書AQ 1-1)★
  ]);

  // ★既存2ケースの `now` は「全行が過去になる」十分先の日時にして、
  //   従来の期待値(未来行の除外なし)をそのまま維持する★
  const farFutureNow = new Date("2026-09-20T00:00:00+09:00");

  it("期間内の本清掃行を施設ごとに集計する(実施/未実施を混在させる)", () => {
    const mainRows = [
      // DB生値 2026-09-08(範囲内)
      { id: BigInt(1), facility_id: BigInt(10), start_at: new Date("2026-09-08T02:00:00Z") },
      { id: BigInt(2), facility_id: BigInt(10), start_at: new Date("2026-09-08T03:00:00Z") },
      // DB生値 2026-09-08、別施設。結果行なし
      { id: BigInt(3), facility_id: BigInt(20), start_at: new Date("2026-09-08T04:00:00Z") },
      // DB生値 2026-09-05(範囲外)。含めない
      { id: BigInt(4), facility_id: BigInt(10), start_at: new Date("2026-09-05T02:00:00Z") },
    ];
    const range = { from: "2026-09-06", to: "2026-09-12" };
    const errCounts = new Map<string, number>([["1", 2]]);

    const buckets = aggregateCleanStatsByFacility(
      withEndAt(mainRows),
      reportMap,
      missingRowIsNotExecuted,
      errCounts,
      range,
      farFutureNow
    );

    const facility10 = buckets.get(10);
    expect(facility10).toEqual<CleanStatsBucket>({
      planned: 2,
      executed: 1,
      completed: 1,
      taskArea: 1000,
      cleanArea: 875,
      errorCount: 2,
      cleanRatioSum: 87.5,
      cleanRatioCount: 1,
    });

    const facility20 = buckets.get(20);
    expect(facility20).toEqual<CleanStatsBucket>({
      planned: 1,
      executed: 0,
      completed: 0,
      taskArea: 0,
      cleanArea: 0,
      errorCount: 0,
      cleanRatioSum: 0,
      cleanRatioCount: 0,
    });

    // 範囲外の施設10の4件目は含まれないので、施設10のplannedは2のまま
    expect(buckets.size).toBe(2);
  });

  it("該当行が0件なら空のMapを返す", () => {
    const buckets = aggregateCleanStatsByFacility(
      [],
      new Map(),
      missingRowIsNotExecuted,
      new Map(),
      { from: "2026-09-06", to: "2026-09-12" },
      farFutureNow
    );
    expect(buckets.size).toBe(0);
  });

  // ★指示書V 段階2(不具合A・決定1)の期待値表★ — 共通の前提:
  //   range = { from: "2026-09-06", to: "2026-09-12" } / now = 2026-09-09T12:00+09:00 /
  //   結果行は clean_ratio=90 / task_area=100 / clean_area=90 / エラー件数は特記の無い限り0
  describe("未来の予定を分母から外す(決定1。now を必須引数にする)", () => {
    const range = { from: "2026-09-06", to: "2026-09-12" };
    const now = new Date("2026-09-09T12:00:00+09:00");
    const reportNormal = new Map<string, CleanStatsReportLike>([
      ["1", { clean_ratio: 90, task_area: 100, clean_area: 90, status: COMPLETED }],
    ]);

    it("#1 期間内・過去は通常どおり加算される", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-08T17:00:00Z") },
      ];
      const buckets = aggregate(rows, reportNormal, new Map(), range, now);
      expect(buckets.get(1)).toEqual<CleanStatsBucket>({
        planned: 1,
        executed: 1,
        completed: 1,
        taskArea: 100,
        cleanArea: 90,
        errorCount: 0,
        cleanRatioSum: 90,
        cleanRatioCount: 1,
      });
    });

    it("#2 期間内・未来はバケットができるが加算されない", () => {
      const rows = [
        { id: BigInt(2), facility_id: BigInt(1), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const buckets = aggregate(rows, new Map(), new Map(), range, now);
      expect(buckets.has(1)).toBe(true);
      expect(buckets.get(1)).toEqual(emptyCleanStatsBucket());
    });

    it("#3 未来の予定を足しても planned が増えない(本体の非対称性の解消)", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-08T17:00:00Z") },
        { id: BigInt(2), facility_id: BigInt(1), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const buckets = aggregate(rows, reportNormal, new Map(), range, now);
      const bucket = buckets.get(1);
      expect(bucket?.planned).toBe(1);
      expect(bucket?.executed).toBe(1);
    });

    it("#4 start_at === now は「来ていない」に入れない(境界は >)", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-09T12:00:00Z") },
      ];
      const buckets = aggregate(rows, reportNormal, new Map(), range, now);
      expect(buckets.get(1)?.planned).toBe(1);
    });

    it("#5 期間の左端ちょうど", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-06T00:00:00Z") },
      ];
      const buckets = aggregate(rows, reportNormal, new Map(), range, now);
      expect(buckets.get(1)?.planned).toBe(1);
    });

    it("#6 期間の右端ちょうど(now を後ろにずらして「未来ではない」状態を作る)", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-12T23:59:00Z") },
      ];
      const laterNow = new Date("2026-09-13T00:00:00+09:00");
      const buckets = aggregate(rows, reportNormal, new Map(), range, laterNow);
      expect(buckets.get(1)?.planned).toBe(1);
    });

    it("#7 期間の左端の前日は計上せず、バケットも作らない", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-05T17:00:00Z") },
      ];
      const buckets = aggregate(rows, reportNormal, new Map(), range, now);
      expect(buckets.has(1)).toBe(false);
    });

    it("#8 期間の右端の翌日も同様にバケットを作らない", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-13T17:00:00Z") },
      ];
      const buckets = aggregate(rows, reportNormal, new Map(), range, now);
      expect(buckets.has(1)).toBe(false);
    });

    it("#9 / w8 未来行に結果行が既にあってもバケットは作るが全項目0のまま", () => {
      const rows = [
        { id: BigInt(2), facility_id: BigInt(1), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const reportWithFuture = new Map<string, CleanStatsReportLike>([
        ["2", { clean_ratio: 90, task_area: 100, clean_area: 90, status: COMPLETED }],
      ]);
      const buckets = aggregate(rows, reportWithFuture, new Map(), range, now);
      // ★完了率の Σ・件数にも、面積にも入らない★
      expect(buckets.get(1)).toEqual(emptyCleanStatsBucket());
    });

    it("#10 未来行にエラーが紐づいていても errorCount は0のまま", () => {
      const rows = [
        { id: BigInt(2), facility_id: BigInt(1), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const errCounts = new Map([["2", 3]]);
      const buckets = aggregate(rows, new Map(), errCounts, range, now);
      expect(buckets.get(1)?.errorCount).toBe(0);
    });

    it("#11 未来行しか無い施設でもバケットは作られる(決定1の本体)", () => {
      const rows = [
        { id: BigInt(2), facility_id: BigInt(2), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const buckets = aggregate(rows, new Map(), new Map(), range, now);
      expect(buckets.has(2)).toBe(true);
      expect(buckets.get(2)).toEqual(emptyCleanStatsBucket());
    });

    it("#12 now を注入できる(同じ入力でも now 次第で答えが変わる)", () => {
      const rows = [
        { id: BigInt(2), facility_id: BigInt(1), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const reportWithRow2 = new Map<string, CleanStatsReportLike>([
        ["2", { clean_ratio: 90, task_area: 100, clean_area: 90, status: COMPLETED }],
      ]);
      const laterNow = new Date("2026-09-11T00:00:00+09:00");
      const buckets = aggregate(rows, reportWithRow2, new Map(), range, laterNow);
      expect(buckets.get(1)?.planned).toBe(1);
    });

    it("#13 複数施設をまとめて集計できる", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-08T17:00:00Z") },
        { id: BigInt(2), facility_id: BigInt(3), start_at: new Date("2026-09-07T17:00:00Z") },
        { id: BigInt(3), facility_id: BigInt(3), start_at: new Date("2026-09-08T17:00:00Z") },
        { id: BigInt(4), facility_id: BigInt(3), start_at: new Date("2026-09-10T17:00:00Z") },
      ];
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 90, task_area: 100, clean_area: 90, status: COMPLETED }],
        ["2", { clean_ratio: 90, task_area: 100, clean_area: 90, status: COMPLETED }],
        ["3", { clean_ratio: 90, task_area: 100, clean_area: 90, status: COMPLETED }],
      ]);
      const buckets = aggregate(rows, reports, new Map(), range, now);
      expect(buckets.get(1)?.planned).toBe(1);
      expect(buckets.get(3)?.planned).toBe(2);
      expect(buckets.size).toBe(2);
    });
  });

  // ★★完了率の畳み方(指示書AQ 決定4)— 期待値表 (w)★★
  describe("完了率は1回ごとの完了率の単純平均(期間の面積比は廃止。指示書AQ 決定4 / D-155)", () => {
    const range = { from: "2026-09-06", to: "2026-09-12" };
    const now = new Date("2026-09-20T00:00:00+09:00"); // 全行が過去
    /** 施設1の本清掃行をn件(id=1..n、すべて範囲内・過去) */
    function mainRows(n: number) {
      return Array.from({ length: n }, (_, i) => ({
        id: BigInt(i + 1),
        facility_id: BigInt(1),
        start_at: new Date(`2026-09-08T0${i}:00:00Z`),
      }));
    }

    it("w1 100% と 50% の2件 → Σ=150 / 件数=2 → 平均 75", () => {
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 100, task_area: 100, clean_area: 100, status: COMPLETED }],
        ["2", { clean_ratio: 50, task_area: 100, clean_area: 50, status: COMPLETED }],
      ]);
      const bucket = aggregate(mainRows(2), reports, new Map(), range, now).get(1);
      expect(bucket?.cleanRatioSum).toBe(150);
      expect(bucket?.cleanRatioCount).toBe(2);
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBe(75);
    });

    it("★w2 未実施(結果行なし)は完了率の分母に入れない★(稼働率で既に数えているため)", () => {
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 100, task_area: 100, clean_area: 100, status: COMPLETED }],
        // id=2 は結果行なし=未実施
      ]);
      const bucket = aggregate(mainRows(2), reports, new Map(), range, now).get(1);
      expect(bucket?.planned).toBe(2);
      expect(bucket?.executed).toBe(1);
      expect(bucket?.cleanRatioCount).toBe(1);
      // ★0% として混ぜたら 50 になる。★そうしない★
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBe(100);
    });

    it("w3 全部未実施なら完了率は null(0% ではない)", () => {
      const bucket = aggregate(mainRows(3), new Map(), new Map(), range, now).get(1);
      expect(bucket?.planned).toBe(3);
      expect(bucket?.executed).toBe(0);
      expect(bucket?.cleanRatioCount).toBe(0);
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBeNull();
    });

    it("★w4 丸めない生値を運ぶ(89.5 は 89.5。表示は90%でも判定は ○)★", () => {
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 89.5, task_area: 100, clean_area: 89.5, status: COMPLETED }],
      ]);
      const bucket = aggregate(mainRows(1), reports, new Map(), range, now).get(1);
      const raw = meanCleanRatio(bucket ?? emptyCleanStatsBucket());
      expect(raw).toBe(89.5);
      // ★CLAUDE.md §4.3 / §9.4 の境界: 丸めると90%だが、判定は生値なので ◎ にならない★
      expect(Math.round(raw ?? 0)).toBe(90);
      expect(judgeGrade(raw)).toBe("○");
      // ★90.4 は逆向きの境界(表示は90%でも判定は ◎)★
      const reports2 = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 90.4, task_area: 100, clean_area: 90.4, status: COMPLETED }],
      ]);
      const raw2 = meanCleanRatio(
        aggregate(mainRows(1), reports2, new Map(), range, now).get(1) ?? emptyCleanStatsBucket()
      );
      expect(raw2).toBe(90.4);
      expect(judgeGrade(raw2)).toBe("◎");
    });

    it("★★w5 面積加重ではない(大面積・低率が平均を引きずらない)★★", () => {
      // ★面積加重なら (1000*10 + 10*90)/1010 ≒ 10.8%。単純平均は 50%★
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 10, task_area: 1000, clean_area: 100, status: COMPLETED }],
        ["2", { clean_ratio: 90, task_area: 10, clean_area: 9, status: COMPLETED }],
      ]);
      const bucket = aggregate(mainRows(2), reports, new Map(), range, now).get(1);
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBe(50);
      // ★面積の合算自体は残る(総清掃面積 F-08。指示書AQ 1-3)★
      expect(bucket?.taskArea).toBe(1010);
      expect(bucket?.cleanArea).toBe(109);
    });

    it("w6 述語が「行はあるが未実施」と答えたら ratio は足さない(面積は足す)", () => {
      // ★未実施判定はこのファイルの外(担当C)の規則★ — ここでは「行の有無」以外の
      //   述語を渡しても、完了率の Σ・件数がその判定に従うことだけを固定する
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 100, task_area: 100, clean_area: 100, status: COMPLETED }],
      ]);
      const alwaysNotExecuted = () => true;
      const bucket = aggregateCleanStatsByFacility(
        withEndAt(mainRows(1)),
        reports,
        alwaysNotExecuted,
        new Map(),
        range,
        now
      ).get(1);
      expect(bucket?.executed).toBe(0);
      expect(bucket?.cleanRatioCount).toBe(0);
      expect(bucket?.taskArea).toBe(100); // ★F-08 のために面積は残す★
      expect(bucket?.cleanArea).toBe(100);
    });

    it("★w7 施設をまたいで合算しても平均が壊れない(Σと件数を持ち回るため)★", () => {
      const rows = [
        { id: BigInt(1), facility_id: BigInt(1), start_at: new Date("2026-09-08T00:00:00Z") },
        { id: BigInt(2), facility_id: BigInt(1), start_at: new Date("2026-09-08T01:00:00Z") },
        { id: BigInt(3), facility_id: BigInt(2), start_at: new Date("2026-09-08T02:00:00Z") },
      ];
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 100, task_area: 100, clean_area: 100, status: COMPLETED }],
        ["2", { clean_ratio: 100, task_area: 100, clean_area: 100, status: COMPLETED }],
        ["3", { clean_ratio: 0, task_area: 100, clean_area: 0, status: COMPLETED }],
      ]);
      const buckets = aggregate(rows, reports, new Map(), range, now);
      const total = sumCleanStatsBuckets(buckets.values());
      // ★清掃1回=1票なので 200/3 ≒ 66.7%。★施設ごとの平均の平均((100+0)/2=50)ではない★
      expect(meanCleanRatio(total)).toBeCloseTo(200 / 3, 10);
    });

    it("★w9 完了率0(面積 0/100)で実施した行は件数に入る(未実施とは別物)★", () => {
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 0, task_area: 100, clean_area: 0, status: COMPLETED }],
      ]);
      const bucket = aggregate(mainRows(1), reports, new Map(), range, now).get(1);
      expect(bucket?.executed).toBe(1);
      expect(bucket?.cleanRatioCount).toBe(1);
      // ★「0%で実施した」は 0(null にしない)★
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBe(0);
    });

    it("★w10 `clean_ratio` 列と面積比が食い違う行は、面積比のほうが使われる★(D-155 / 指示書AS D6)", () => {
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 12.3, task_area: 100, clean_area: 50, status: COMPLETED }],
        // ★`clean_area > task_area` は上限100で切る(表1 R3)★
        ["2", { clean_ratio: 61, task_area: 100, clean_area: 162.8, status: COMPLETED }],
      ]);
      const bucket = aggregate(mainRows(2), reports, new Map(), range, now).get(1);
      expect(bucket?.cleanRatioSum).toBe(150);
      expect(bucket?.cleanRatioCount).toBe(2);
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBe(75);
    });

    it("★w11 `task_area = 0` の行は実施1回・完了率の件数1・Σ に 0 を足す★(P-5=B。指示書AU 表3)", () => {
      const reports = new Map<string, CleanStatsReportLike>([
        ["1", { clean_ratio: 80, task_area: 0, clean_area: 10, status: COMPLETED }],
        ["2", { clean_ratio: 50, task_area: 100, clean_area: 50, status: COMPLETED }],
      ]);
      const bucket = aggregate(mainRows(2), reports, new Map(), range, now).get(1);
      expect(bucket?.planned).toBe(2);
      expect(bucket?.executed).toBe(2);
      // ★`task_area = 0` の行は 0 を足し、件数にも入る(id=2 の 50 と合わせて Σ=50・件数2)★
      expect(bucket?.cleanRatioSum).toBe(50);
      expect(bucket?.cleanRatioCount).toBe(2);
      expect(meanCleanRatio(bucket ?? emptyCleanStatsBucket())).toBe(25);
      // ★面積の合計は足す(総清掃面積の表示用)★
      expect(bucket?.taskArea).toBe(100);
      expect(bucket?.cleanArea).toBe(60);
    });
  });
});

describe("meanCleanRatio", () => {
  it("件数0は null(分母0は率を出さない。0%にしない)", () => {
    expect(meanCleanRatio(emptyCleanStatsBucket())).toBeNull();
  });

  it("Σ ÷ 件数(丸めない生値)", () => {
    expect(
      meanCleanRatio({ ...emptyCleanStatsBucket(), cleanRatioSum: 268.3, cleanRatioCount: 3 })
    ).toBeCloseTo(268.3 / 3, 10);
  });
});

describe("sumCleanStatsBuckets", () => {
  it("複数バケットを合算する", () => {
    const a: CleanStatsBucket = {
      planned: 2,
      executed: 1,
      completed: 1,
      taskArea: 1000,
      cleanArea: 900,
      errorCount: 2,
      cleanRatioSum: 87.5,
      cleanRatioCount: 1,
    };
    const b: CleanStatsBucket = {
      planned: 1,
      executed: 0,
      completed: 0,
      taskArea: 0,
      cleanArea: 0,
      errorCount: 0,
      cleanRatioSum: 0,
      cleanRatioCount: 0,
    };
    expect(sumCleanStatsBuckets([a, b])).toEqual<CleanStatsBucket>({
      planned: 3,
      executed: 1,
      completed: 1,
      taskArea: 1000,
      cleanArea: 900,
      errorCount: 2,
      cleanRatioSum: 87.5,
      cleanRatioCount: 1,
    });
  });

  it("空なら emptyCleanStatsBucket と同値", () => {
    expect(sumCleanStatsBuckets([])).toEqual(emptyCleanStatsBucket());
  });
});

describe("kpiFromCleanStats", () => {
  it("正常終了・開始・予定回数からKPIを計算する(4指標とも生値。丸めない。第11回 P-6)", () => {
    const bucket: CleanStatsBucket = {
      planned: 100,
      executed: 94,
      completed: 94,
      taskArea: 1000,
      cleanArea: 826,
      errorCount: 6,
      cleanRatioSum: 7764.4, // 94件・平均 82.6%
      cleanRatioCount: 94,
    };
    const kpi = kpiFromCleanStats(bucket, undefined);
    expect(kpi.operation_rate.value).toBe(94);
    expect(kpi.launch_rate.value).toBe(94);
    expect(kpi.completion_rate.value).toBeCloseTo(82.6, 10);
    // ★エラー発生率の分母は予定回数(P-6=B)★ — 6/100。旧: 6/94(実施回数)
    expect(kpi.error_rate.value).toBeCloseTo((6 / 100) * 100, 10);
  });

  it("★完了率は面積比ではない(面積が入っていても、渡された完了率の Σ ÷ 件数を返す)★", () => {
    // ★面積比なら 900/1000 = 90%。★採るのは渡された Σ ÷ 件数(70%)★(指示書AQ 決定4。D-155 以降、実データの Σ は1回ごとの完了率の合計)
    const kpi = kpiFromCleanStats(
      {
        planned: 1,
        executed: 1,
        completed: 1,
        taskArea: 1000,
        cleanArea: 900,
        errorCount: 0,
        cleanRatioSum: 70,
        cleanRatioCount: 1,
      },
      undefined
    );
    expect(kpi.completion_rate.value).toBe(70);
  });

  it("★境界値: planned=0(分母0)は null。0%ではない★", () => {
    const kpi = kpiFromCleanStats(emptyCleanStatsBucket(), undefined);
    expect(kpi.operation_rate.value).toBeNull();
    expect(kpi.launch_rate.value).toBeNull();
    expect(kpi.completion_rate.value).toBeNull();
    // ★planned=0 のときエラー発生率も null(分母は予定回数。第11回 P-6=B)★
    expect(kpi.error_rate.value).toBeNull();
  });

  it("★planned>0・executed=0(全部出発失敗)のエラー発生率は 0.0(null にしない。P-6=B)★", () => {
    const kpi = kpiFromCleanStats({ ...emptyCleanStatsBucket(), planned: 5 }, undefined);
    expect(kpi.operation_rate.value).toBe(0);
    expect(kpi.launch_rate.value).toBe(0);
    expect(kpi.error_rate.value).toBe(0);
  });

  // ★指示書V 段階2 期待値表 #14: 未来行しか無い施設のKPI(段階1とのつなぎ)★ —
  //   分母0(emptyCleanStatsBucket)は「判定なし」(no_data)になり、「要改善」(bad)にはならない
  it("#14 未来行しか無い施設のバケットは4指標すべてnull・statusはno_data", () => {
    const kpi = kpiFromCleanStats(emptyCleanStatsBucket(), undefined);
    expect(kpi.operation_rate.value).toBeNull();
    expect(kpi.launch_rate.value).toBeNull();
    expect(kpi.completion_rate.value).toBeNull();
    expect(kpi.error_rate.value).toBeNull();
    expect(judgeFacilityStatus(kpi.operation_rate.value)).toBe("no_data");
  });

  it("前期行(サマリの率)があれば prev / diff が入る", () => {
    const bucket: CleanStatsBucket = {
      planned: 100,
      executed: 90,
      completed: 90,
      taskArea: 1000,
      cleanArea: 900,
      errorCount: 9,
      cleanRatioSum: 8100,
      cleanRatioCount: 90,
    };
    const prev = {
      operation_ratio: 85.0,
      launch_ratio: 85.0,
      clean_ratio: 85.0,
      error_ratio: 8.0,
    };
    const kpi = kpiFromCleanStats(bucket, prev);
    expect(kpi.operation_rate.prev).toBe(85.0);
    expect(kpi.operation_rate.diff).toBe(5);
    // ★完了率の prev も サマリの `clean_ratio`(確定期間と当期で同じ定義になった)★
    expect(kpi.completion_rate.value).toBe(90);
    expect(kpi.completion_rate.prev).toBe(85.0);
    expect(kpi.completion_rate.diff).toBeCloseTo(5, 10);
  });
});

describe("consecutiveProblemDaysAtPeriodEnd", () => {
  const entries: FacilityConsecutiveEntry[] = [
    // 施設10: 期間内(9/6〜9/12)に2件。最終日(9/10)のidは102
    { id: 101, facility_id: 10, scheduled_at: "2026-09-08T09:00:00+09:00" },
    { id: 102, facility_id: 10, scheduled_at: "2026-09-10T09:00:00+09:00" },
    // 施設10: 期間外(9/13。範囲より後)。これが最終日に選ばれてはいけない
    { id: 103, facility_id: 10, scheduled_at: "2026-09-13T09:00:00+09:00" },
    // 施設20: 期間内に1件も無い
    { id: 201, facility_id: 20, scheduled_at: "2026-09-01T09:00:00+09:00" },
  ];
  const consecutiveById = new Map<number, number>([
    [101, 1],
    [102, 3],
    [103, 5],
    [201, 2],
  ]);
  const range = { from: "2026-09-06", to: "2026-09-12" };

  it("期間内の最終清掃ログ(最も遅いscheduled_at)の連続日数を採る", () => {
    expect(
      consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, 10, range)
    ).toBe(3); // id=102(9/10。期間内で最も遅い)
  });

  it("期間外の行(範囲より後)は無視する", () => {
    // id=103(9/13, 連続日数5)が混じっていても選ばれないことの確認(上のテストと同じ検証だが明示する)
    expect(
      consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, 10, range)
    ).not.toBe(5);
  });

  it("★期間内に清掃ログが1件も無ければ 0★(未算出のnullにはしない。着手前確認で不採用のC案)", () => {
    expect(
      consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, 20, range)
    ).toBe(0);
  });

  it("該当施設の行が1件も存在しない施設idでも 0", () => {
    expect(
      consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, 999, range)
    ).toBe(0);
  });
});

// ★指示書W 段階2 — consecutiveProblemDaysAtPeriodEnd() のループ部分を切り出した純粋関数★
//   (最終エントリを選ぶ規則の実体はここ1箇所。last_problem_on の算出にも使う)
describe("lastEntryInPeriod", () => {
  const range = { from: "2026-09-06", to: "2026-09-12" };

  it("#1 entries が空なら undefined", () => {
    expect(lastEntryInPeriod([], 10, range)).toBeUndefined();
  });

  it("#2 その施設の行が1件も無い(別施設の行だけ)なら undefined", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 20, scheduled_at: "2026-09-08T09:00:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)).toBeUndefined();
  });

  it("#3 その施設の行はあるが全部範囲外(fromの前日 / toの翌日)なら undefined", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 10, scheduled_at: "2026-09-05T23:00:00+09:00" },
      { id: 2, facility_id: 10, scheduled_at: "2026-09-13T00:00:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)).toBeUndefined();
  });

  it("#4 範囲内に3件(日付バラバラ)は scheduled_at が最大の1件", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 10, scheduled_at: "2026-09-08T09:00:00+09:00" },
      { id: 2, facility_id: 10, scheduled_at: "2026-09-10T09:00:00+09:00" },
      { id: 3, facility_id: 10, scheduled_at: "2026-09-06T09:00:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)?.id).toBe(2);
  });

  it("#5 同じ日に2件(時刻違い)は時刻が遅いほう", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 10, scheduled_at: "2026-09-08T09:00:00+09:00" },
      { id: 2, facility_id: 10, scheduled_at: "2026-09-08T17:00:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)?.id).toBe(2);
  });

  it("#6 境界: from ちょうどの行は範囲に含む", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 10, scheduled_at: "2026-09-06T00:00:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)?.id).toBe(1);
  });

  it("#7 境界: to ちょうどの行は範囲に含む", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 10, scheduled_at: "2026-09-12T23:59:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)?.id).toBe(1);
  });

  it("#8 範囲内と範囲外が混在(範囲外のほうが新しい)は範囲内の最新を拾う", () => {
    const entries: FacilityConsecutiveEntry[] = [
      { id: 1, facility_id: 10, scheduled_at: "2026-09-08T09:00:00+09:00" },
      { id: 2, facility_id: 10, scheduled_at: "2026-09-13T09:00:00+09:00" },
    ];
    expect(lastEntryInPeriod(entries, 10, range)?.id).toBe(1);
  });
});

// ★指示書W 段階3 — 連続の「内訳」を作る3本。いずれも lib/db/report-mapping.ts に置く★
//   (lib/db/clean-log-mapping.ts は触らない。ScheduleDayResult / foldProblemDaysByDate() の
//   戻り型を変えないため。禁止事項8)
describe("problemRunDates", () => {
  it("#1 facilityRows=[] / consecutiveDays=0 は []", () => {
    expect(problemRunDates([], "2026-09-10", 0)).toEqual([]);
  });

  it("#2 consecutiveDays=0(最終日が問題なし)は []", () => {
    const rows = [consecutiveRow(1, 10, "2026-09-10", 95)];
    expect(problemRunDates(rows, "2026-09-10", 0)).toEqual([]);
  });

  it("#3 1日だけ・問題あり / n=1", () => {
    const rows = [consecutiveRow(1, 10, "2026-09-10", 50)];
    expect(problemRunDates(rows, "2026-09-10", 1)).toEqual(["2026-09-10"]);
  });

  it("#4 09-08/09-09/09-10が全部問題 / endDate=09-10 / n=3", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-08", 50),
      consecutiveRow(2, 10, "2026-09-09", 40),
      consecutiveRow(3, 10, "2026-09-10", 30),
    ];
    expect(problemRunDates(rows, "2026-09-10", 3)).toEqual([
      "2026-09-08",
      "2026-09-09",
      "2026-09-10",
    ]);
  });

  it("#5 スケジュール非存在日を挟む(09-01/09-03/09-05のみ全部問題)/ n=3(D-026)", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-01", 50),
      consecutiveRow(2, 10, "2026-09-03", 40),
      consecutiveRow(3, 10, "2026-09-05", 30),
    ];
    expect(problemRunDates(rows, "2026-09-05", 3)).toEqual([
      "2026-09-01",
      "2026-09-03",
      "2026-09-05",
    ]);
  });

  it("#6 同一日に2行(1台が問題・1台が正常)はその日1日ぶんだけ(OR。D-109決定2)", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-10", 50, "08:00:00"),
      consecutiveRow(2, 10, "2026-09-10", 95, "09:00:00"),
    ];
    expect(problemRunDates(rows, "2026-09-10", 1)).toEqual(["2026-09-10"]);
  });

  it("#7 ランの前に問題なしの日がある(09-01正常/09-02・09-03問題)/ n=2は正常日を含めない", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-01", 95),
      consecutiveRow(2, 10, "2026-09-02", 50),
      consecutiveRow(3, 10, "2026-09-03", 40),
    ];
    expect(problemRunDates(rows, "2026-09-03", 2)).toEqual(["2026-09-02", "2026-09-03"]);
  });

  it("#8 endDateが行に存在しないなら [](例外にしない)", () => {
    const rows = [consecutiveRow(1, 10, "2026-09-10", 50)];
    expect(problemRunDates(rows, "2026-09-11", 1)).toEqual([]);
  });

  it("#9 nが行の日数より大きい(行は2日ぶんしか無いのにn=4)はMath.max(0,...)で先頭からendDateまで", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-09", 50),
      consecutiveRow(2, 10, "2026-09-10", 40),
    ];
    expect(problemRunDates(rows, "2026-09-10", 4)).toEqual(["2026-09-09", "2026-09-10"]);
  });

  it("#10 endDateより後の日にも行があるとき、endDateより後を含めない", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-09", 50),
      consecutiveRow(2, 10, "2026-09-10", 40),
      consecutiveRow(3, 10, "2026-09-11", 30),
    ];
    expect(problemRunDates(rows, "2026-09-10", 2)).toEqual(["2026-09-09", "2026-09-10"]);
  });
});

describe("launchFailedDaysInRun", () => {
  it("#1 runDates=[] は 0", () => {
    expect(launchFailedDaysInRun([], [])).toBe(0);
  });

  it("#2 ラン3日・全日 completion_rate===null は 3", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-08", null),
      consecutiveRow(2, 10, "2026-09-09", null),
      consecutiveRow(3, 10, "2026-09-10", null),
    ];
    const runDates = ["2026-09-08", "2026-09-09", "2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(3);
  });

  it("#3 ラン3日・nullが1日も無い(40/55/62)は 0", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-08", 40),
      consecutiveRow(2, 10, "2026-09-09", 55),
      consecutiveRow(3, 10, "2026-09-10", 62),
    ];
    const runDates = ["2026-09-08", "2026-09-09", "2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(0);
  });

  it("#4 混在(3日中1日がnull)は 1", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-08", null),
      consecutiveRow(2, 10, "2026-09-09", 55),
      consecutiveRow(3, 10, "2026-09-10", 62),
    ];
    const runDates = ["2026-09-08", "2026-09-09", "2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(1);
  });

  it("#5 同じ日に「nullの行」と「完了率60の行」があってもその日は1(2回数えない)", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-10", null, "08:00:00"),
      consecutiveRow(2, 10, "2026-09-10", 60, "09:00:00"),
    ];
    const runDates = ["2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(1);
  });

  it("#6 ランの外の日にnullがあっても数えない", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-05", null),
      consecutiveRow(2, 10, "2026-09-10", 50),
    ];
    const runDates = ["2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(0);
  });

  it("#7 完了率 69.9(△=問題日)でも出発失敗ではないので0", () => {
    // ★D-139 で 70.0 は ○(問題なし)になったため、△の代表値を 69.9 にした★
    const rows = [consecutiveRow(1, 10, "2026-09-10", 69.9)];
    const runDates = ["2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(0);
  });

  it("#8 ラン2日・両方null(m===n)は 2", () => {
    const rows = [
      consecutiveRow(1, 10, "2026-09-09", null),
      consecutiveRow(2, 10, "2026-09-10", null),
    ];
    const runDates = ["2026-09-09", "2026-09-10"];
    expect(launchFailedDaysInRun(rows, runDates)).toBe(2);
  });
});

// ★★表5(指示書AS §4。【未決1】案A。D-156)— 出発失敗の日は `departed` で数える★★
//   | #  | 清掃                        | departed | completion_rate | 出発失敗の日 |
//   | U1 | レポートの行が無い           | false    | null            | 数える        |
//   | U2 | ★`departed` と `completion_rate` が食い違う行(防御)★ | ★true★ | ★null★ | ★数えない★ |
//   | U3 | 行がある・50/100             | true     | 50.0            | 数えない      |
//   | U4 | 行がある・100/100            | true     | 100.0           | 数えない      |
//   | U5 | 同じ施設・同じ日に U1 と U4  | —        | —               | 1日(OR)     |
describe("launchFailedDaysInRun — 表5(未実施は departed で数える。指示書AS 段階3-4)", () => {
  /** `departed` を明示して1行作る(`consecutiveRow()` の既定は「null = 行なし」) */
  function row(
    id: number,
    date: string,
    departed: boolean,
    rate: number | null,
    time = "09:00:00"
  ): ConsecutiveInput {
    return { ...consecutiveRow(id, 10, date, rate, time), departed };
  }

  it.each<[string, ConsecutiveInput, number]>([
    ["U1 レポートの行が無い → 数える", row(1, "2026-09-10", false, null), 1],
    ["★U2 departed と completion_rate が食い違う行(防御。departed true・null)→ 数えない★", row(1, "2026-09-10", true, null), 0],
    ["U3 行がある・50/100 → 数えない", row(1, "2026-09-10", true, 50), 0],
    ["U4 行がある・100/100 → 数えない", row(1, "2026-09-10", true, 100), 0],
  ])("%s", (_name, input, expected) => {
    expect(launchFailedDaysInRun([input], ["2026-09-10"])).toBe(expected);
  });

  it("U5 同じ施設・同じ日に U1 と U4 → その日を1日数える(OR)", () => {
    const rows = [
      row(1, "2026-09-10", false, null, "08:00:00"),
      row(2, "2026-09-10", true, 100, "09:00:00"),
    ];
    expect(launchFailedDaysInRun(rows, ["2026-09-10"])).toBe(1);
  });
});

describe("continuousProblemSummary", () => {
  it("#1 n=1, m=0 は完了率テンプレート", () => {
    expect(continuousProblemSummary(1, 0)).toBe(SUMMARY_TEMPLATE_COMPLETION);
  });
  it("#2 n=1, m=1 は出発失敗テンプレート", () => {
    expect(continuousProblemSummary(1, 1)).toBe(SUMMARY_TEMPLATE_LAUNCH_FAILED);
  });
  it("#3 n=2, m=0 は完了率テンプレート", () => {
    expect(continuousProblemSummary(2, 0)).toBe(SUMMARY_TEMPLATE_COMPLETION);
  });
  it("#4 n=2, m=1 は混在テンプレート(実データの施設5)", () => {
    expect(continuousProblemSummary(2, 1)).toBe(SUMMARY_TEMPLATE_MIXED);
  });
  it("#5 n=2, m=2 は出発失敗テンプレート", () => {
    expect(continuousProblemSummary(2, 2)).toBe(SUMMARY_TEMPLATE_LAUNCH_FAILED);
  });
  it("#6 n=4, m=3 は混在テンプレート(実データの施設6)", () => {
    expect(continuousProblemSummary(4, 3)).toBe(SUMMARY_TEMPLATE_MIXED);
  });
  it("#7 n=6, m=0 は完了率テンプレート(実データの施設9)", () => {
    expect(continuousProblemSummary(6, 0)).toBe(SUMMARY_TEMPLATE_COMPLETION);
  });
  it("#8 n=4, m=4 は出発失敗テンプレート", () => {
    expect(continuousProblemSummary(4, 4)).toBe(SUMMARY_TEMPLATE_LAUNCH_FAILED);
  });

  it("テンプレート文字列には数値が埋まっていない({n} / {m} のまま。方式A)", () => {
    expect(SUMMARY_TEMPLATE_COMPLETION).toContain("{n}");
    expect(SUMMARY_TEMPLATE_LAUNCH_FAILED).toContain("{n}");
    expect(SUMMARY_TEMPLATE_MIXED).toContain("{n}");
    expect(SUMMARY_TEMPLATE_MIXED).toContain("{m}");
  });
});

// ★指示書AF 段階5 — 連続日数の表示上限★
//   上限は★表示のためだけ★で、L1〜L3 の判定(`escalation_level`)には一切かからない
//   (指示書AF 禁止事項9 / `CLAUDE.md` §4.3)。
describe("capContinuousProblems", () => {
  /** テスト用ヘルパー: ContinuousProblem を1件作る */
  function problem(
    facilityId: number,
    days: number,
    launchFailedDays: number,
    summary: string,
    level: ContinuousProblem["escalation_level"] = "L3"
  ): ContinuousProblem {
    return {
      facility_id: facilityId,
      facility_name: `施設${facilityId}`,
      consecutive_problem_days: days,
      escalation_level: level,
      last_problem_on: "2026-09-12",
      summary,
      launch_failed_days: launchFailedDays,
    };
  }

  it("#1 上限−1(29 / 上限30)はそのまま。capped は false で launch_failed_days も残る", () => {
    const [item] = capContinuousProblems(
      [problem(1, 29, 0, SUMMARY_TEMPLATE_COMPLETION)],
      30
    );
    expect(item.consecutive_problem_days).toBe(29);
    expect(item.consecutive_problem_days_capped).toBe(false);
    expect(item.summary).toBe(SUMMARY_TEMPLATE_COMPLETION);
    expect(item.launch_failed_days).toBe(0);
  });

  it("#2 ★上限ちょうど(30 / 上限30)は capped:false★(「以上」にしない)", () => {
    const [item] = capContinuousProblems(
      [problem(1, 30, 0, SUMMARY_TEMPLATE_COMPLETION)],
      30
    );
    expect(item.consecutive_problem_days).toBe(30);
    expect(item.consecutive_problem_days_capped).toBe(false);
    expect(item.summary).toBe(SUMMARY_TEMPLATE_COMPLETION);
    expect(item.launch_failed_days).toBe(0);
  });

  it("#3 ★上限+1(31 / 上限30)は 30 / capped:true / 上限用テンプレート / launch_failed_days が無い★", () => {
    const [item] = capContinuousProblems(
      [problem(1, 31, 0, SUMMARY_TEMPLATE_COMPLETION)],
      30
    );
    expect(item.consecutive_problem_days).toBe(30);
    expect(item.consecutive_problem_days_capped).toBe(true);
    expect(item.summary).toBe(SUMMARY_TEMPLATE_CAPPED_COMPLETION);
    // ★キーごと落ちていること★(undefined ではなく「無い」。0 <= m <= n が壊れるため)
    expect("launch_failed_days" in item).toBe(false);
  });

  it("#4 出発失敗テンプレートは上限用の出発失敗テンプレートに差し替わる", () => {
    const [item] = capContinuousProblems(
      [problem(1, 31, 31, SUMMARY_TEMPLATE_LAUNCH_FAILED)],
      30
    );
    expect(item.consecutive_problem_days).toBe(30);
    expect(item.consecutive_problem_days_capped).toBe(true);
    expect(item.summary).toBe(SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED);
    expect("launch_failed_days" in item).toBe(false);
  });

  it("#5 ★混在は {m} を持たない上限用テンプレートになり launch_failed_days が落ちる★", () => {
    const [item] = capContinuousProblems(
      [problem(1, 31, 12, SUMMARY_TEMPLATE_MIXED)],
      30
    );
    expect(item.consecutive_problem_days).toBe(30);
    expect(item.consecutive_problem_days_capped).toBe(true);
    expect(item.summary).toBe(SUMMARY_TEMPLATE_CAPPED_MIXED);
    expect(SUMMARY_TEMPLATE_CAPPED_MIXED).not.toContain("{m}");
    expect("launch_failed_days" in item).toBe(false);
  });

  it("#6 ★下限(L3の日数)を上限にしても効く★(5日 / 上限3)", () => {
    // ★入力側の下限は `ESCALATION_THRESHOLDS.level3Days` から取る(3 と直書きしない)★
    const [item] = capContinuousProblems(
      [problem(1, 5, 2, SUMMARY_TEMPLATE_MIXED)],
      ESCALATION_THRESHOLDS.level3Days
    );
    expect(item.consecutive_problem_days).toBe(ESCALATION_THRESHOLDS.level3Days);
    expect(item.consecutive_problem_days_capped).toBe(true);
    expect(item.summary).toBe(SUMMARY_TEMPLATE_CAPPED_MIXED);
  });

  it("#7 ★★escalation_level は L3 のまま★★(判定は上限をかける前の生値。禁止事項9)", () => {
    const [item] = capContinuousProblems(
      [problem(1, 45, 40, SUMMARY_TEMPLATE_MIXED, "L3")],
      ESCALATION_THRESHOLDS.level3Days
    );
    expect(item.consecutive_problem_days_capped).toBe(true);
    expect(item.escalation_level).toBe("L3");
    // ★生値で判定した結果と一致すること★(頭打ちした値で判定し直していない)
    expect(judgeEscalationLevel(45)).toBe("L3");
  });

  it("#8 ★並べ替え済みの配列の順序を変えない★(45 / 31 / 30 / 5 → 30 / 30 / 30 / 5)", () => {
    const items = capContinuousProblems(
      [
        problem(1, 45, 0, SUMMARY_TEMPLATE_COMPLETION),
        problem(2, 31, 0, SUMMARY_TEMPLATE_COMPLETION),
        problem(3, 30, 0, SUMMARY_TEMPLATE_COMPLETION),
        problem(4, 5, 0, SUMMARY_TEMPLATE_COMPLETION, "L3"),
      ],
      30
    );
    expect(items.map((i) => i.facility_id)).toEqual([1, 2, 3, 4]);
    expect(items.map((i) => i.consecutive_problem_days)).toEqual([30, 30, 30, 5]);
    expect(items.map((i) => i.consecutive_problem_days_capped)).toEqual([
      true,
      true,
      false,
      false,
    ]);
  });

  it("#9 ★上限用テンプレート3本が lib/i18n.ts のキーとして登録されている★", () => {
    // ★1文字でも違うと translate() が未登録キーとして日本語のまま返す(fail-silent)★
    for (const template of [
      SUMMARY_TEMPLATE_CAPPED_COMPLETION,
      SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED,
      SUMMARY_TEMPLATE_CAPPED_MIXED,
    ]) {
      expect(translate("en", template)).not.toBe(template);
      expect(translate("ja", template)).toBe(template);
      expect(template).toContain("{n}");
    }
    // 既存3本(指示書W)も同じ規律で守られていること
    for (const template of [
      SUMMARY_TEMPLATE_COMPLETION,
      SUMMARY_TEMPLATE_LAUNCH_FAILED,
      SUMMARY_TEMPLATE_MIXED,
    ]) {
      expect(translate("en", template)).not.toBe(template);
    }
    // バッジのキー(`ReportsScreen.tsx`)も同様
    expect(translate("en", "{n}日")).not.toBe("{n}日");
    expect(translate("en", "{n}日以上")).not.toBe("{n}日以上");
  });

  it("空配列は空配列(例外にしない)", () => {
    expect(capContinuousProblems([], 30)).toEqual([]);
  });
});

// ★指示書W 段階4 — 清掃ログの日付から期間キー(週次+月次)を列挙する★
//   (facilityPerformance() はサマリ行から列挙しているが、continuousProblems() に
//   対応するサマリは無いので詳細行の日付から作る)
describe("reportPeriodKeysFromDates", () => {
  it("#1 空配列は []", () => {
    expect(reportPeriodKeysFromDates([])).toEqual([]);
  });

  it("#2 1日ぶんは月次1件+週次1件の2件(週番号はweekOfMonth()を呼んで期待値にする)", () => {
    const w = weekOfMonth("2026-09-10");
    const keys = reportPeriodKeysFromDates(["2026-09-10"]);
    expect(keys).toEqual(
      expect.arrayContaining([
        { period: "monthly", year: 2026, month: 9, week: 0 },
        { period: "weekly", year: w.year, month: w.month, week: w.week },
      ])
    );
    expect(keys).toHaveLength(2);
  });

  it("#3 同じ週の2日は重複しない(2件のまま)", () => {
    // weekOfMonth()で同じ週になる2日を実測して使う
    const w1 = weekOfMonth("2026-09-10");
    // 同じ週内の翌日(月をまたがない前提。9/10と9/11は同じ週のはず)
    const w2 = weekOfMonth("2026-09-11");
    expect(w1).toEqual(w2); // 前提の確認(崩れたら別の日付ペアに直す)
    const keys = reportPeriodKeysFromDates(["2026-09-10", "2026-09-11"]);
    expect(keys).toHaveLength(2);
  });

  it("#4 同じ月・別の週の2日は月次1件+週次2件の3件", () => {
    const wA = weekOfMonth("2026-09-01");
    const wB = weekOfMonth("2026-09-10");
    expect(wA.week).not.toBe(wB.week); // 前提の確認
    const keys = reportPeriodKeysFromDates(["2026-09-01", "2026-09-10"]);
    expect(keys).toHaveLength(3);
    expect(keys.filter((k) => k.period === "monthly")).toHaveLength(1);
    expect(keys.filter((k) => k.period === "weekly")).toHaveLength(2);
  });

  it("#5 月跨ぎ(8/31・9/1)は月次2件+週次2件の4件(D-071。同じ日曜〜土曜でも月境界で別の週)", () => {
    const keys = reportPeriodKeysFromDates(["2026-08-31", "2026-09-01"]);
    expect(keys).toHaveLength(4);
    expect(keys.filter((k) => k.period === "monthly")).toHaveLength(2);
    expect(keys.filter((k) => k.period === "weekly")).toHaveLength(2);
  });

  it("#6 年跨ぎは年が分かれて4件", () => {
    const keys = reportPeriodKeysFromDates(["2025-12-31", "2026-01-01"]);
    expect(keys).toHaveLength(4);
    const years = new Set(keys.map((k) => k.year));
    expect(years).toEqual(new Set([2025, 2026]));
  });

  it("#7 並び順はyear→month→weekの昇順で決定的(week:0の月次が先頭)", () => {
    const keys = reportPeriodKeysFromDates(["2026-09-10", "2026-08-31", "2026-09-01"]);
    const actual = keys.map((k) => [k.year, k.month, k.week]);
    const expected = [...actual].sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
    expect(actual).toEqual(expected);
    // 同じ年月なら week:0(月次)が先頭
    const sameMonthKeys = keys.filter((k) => k.year === 2026 && k.month === 9);
    expect(sameMonthKeys[0].week).toBe(0);
  });

  it("#8 年次(month:0, week:0)は作らない", () => {
    const keys = reportPeriodKeysFromDates(["2026-09-10", "2026-08-31", "2025-12-31"]);
    expect(keys.some((k) => k.month === 0 && k.week === 0)).toBe(false);
  });
});

// ★指示書W 段階5 — 1期間ぶんの ContinuousProblem[] を組み立てる純粋関数★
//   (「連続0日は行を作らない」「ロボット0台の施設も出す」「L1も返す」はDBを読む
//   continuousProblems() の中では確認できないため、純粋関数として切り出す。禁止事項17)
describe("continuousProblemsInPeriod", () => {
  const range = { from: "2026-09-06", to: "2026-09-12" };

  it("#1 entriesが空なら []", () => {
    expect(continuousProblemsInPeriod([], new Map(), new Map([[1, "施設A"]]), range)).toEqual([]);
  });

  it("#2 期間内にその施設の清掃ログが無いなら行を作らない", () => {
    const entries = [consecutiveRow(1, 1, "2026-09-01", 50)]; // 範囲外(rangeより前)
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[1, "施設A"]]);
    expect(continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range)).toEqual(
      []
    );
  });

  it("#3 連続0日(期間内の最終日が問題なし)なら行を作らない(as / !! / ?? \"L1\" を使わない)", () => {
    const entries = [consecutiveRow(1, 1, "2026-09-10", 95)]; // 範囲内だが正常
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[1, "施設A"]]);
    expect(continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range)).toEqual(
      []
    );
  });

  it("#4 n=1(L1)は行を作る(絞り込みはlib/reports/query.tsの仕事。決定4)", () => {
    const entries = [consecutiveRow(1, 1, "2026-09-10", 50)];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[1, "施設A"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result).toHaveLength(1);
    expect(result[0].consecutive_problem_days).toBe(1);
    expect(result[0].escalation_level).toBe("L1");
  });

  it("#5 n=2/3/6 は L2/L3/L3(judgeEscalationLevel()経由。閾値を直書きしない)", () => {
    const entries = [
      // 施設2: 2日連続
      consecutiveRow(1, 2, "2026-09-09", 50),
      consecutiveRow(2, 2, "2026-09-10", 50),
      // 施設3: 3日連続
      consecutiveRow(3, 3, "2026-09-08", 50),
      consecutiveRow(4, 3, "2026-09-09", 50),
      consecutiveRow(5, 3, "2026-09-10", 50),
      // 施設6: 6日連続(期間の前から続く)
      consecutiveRow(6, 6, "2026-09-05", 50),
      consecutiveRow(7, 6, "2026-09-06", 50),
      consecutiveRow(8, 6, "2026-09-07", 50),
      consecutiveRow(9, 6, "2026-09-08", 50),
      consecutiveRow(10, 6, "2026-09-09", 50),
      consecutiveRow(11, 6, "2026-09-10", 50),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([
      [2, "施設2"],
      [3, "施設3"],
      [6, "施設6"],
    ]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    const byFacility = new Map(result.map((r) => [r.facility_id, r]));
    expect(byFacility.get(2)?.escalation_level).toBe("L2");
    expect(byFacility.get(3)?.escalation_level).toBe("L3");
    expect(byFacility.get(6)?.escalation_level).toBe("L3");
    expect(byFacility.get(6)?.consecutive_problem_days).toBe(6);
  });

  it("#6 facilityNameByIdにロボット0台の施設が入っていればその施設の行が出る(決定5。robotsをjoinしないので除外機構が無い)", () => {
    const entries = [consecutiveRow(1, 12, "2026-09-10", 50)];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[12, "ロボット0台の施設"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result).toHaveLength(1);
    expect(result[0].facility_name).toBe("ロボット0台の施設");
  });

  it("#7 entriesにあるがfacilityNameByIdに無い施設idは行を作らない(名前を捏造しない)", () => {
    const entries = [
      consecutiveRow(1, 77, "2026-09-10", 50), // facilityNameByIdに無い
      consecutiveRow(2, 1, "2026-09-10", 50),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[1, "施設A"]]); // 77 を含まない
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result.map((r) => r.facility_id)).toEqual([1]);
  });

  it("#8 混在ラン(3日中1日が未実施)は混在テンプレート・launch_failed_days=1", () => {
    const entries = [
      consecutiveRow(1, 8, "2026-09-08", null),
      consecutiveRow(2, 8, "2026-09-09", 60),
      consecutiveRow(3, 8, "2026-09-10", 55),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[8, "施設8"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result[0].consecutive_problem_days).toBe(3);
    expect(result[0].launch_failed_days).toBe(1);
    expect(result[0].summary).toBe(SUMMARY_TEMPLATE_MIXED);
  });

  it("#9 全日が出発失敗は出発失敗テンプレート・launch_failed_days=n", () => {
    const entries = [
      consecutiveRow(1, 9, "2026-09-08", null),
      consecutiveRow(2, 9, "2026-09-09", null),
      consecutiveRow(3, 9, "2026-09-10", null),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[9, "施設9"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result[0].consecutive_problem_days).toBe(3);
    expect(result[0].launch_failed_days).toBe(3);
    expect(result[0].summary).toBe(SUMMARY_TEMPLATE_LAUNCH_FAILED);
  });

  it("#10 全日が完了率低下は完了率テンプレート・launch_failed_days=0", () => {
    const entries = [
      consecutiveRow(1, 10, "2026-09-08", 40),
      consecutiveRow(2, 10, "2026-09-09", 50),
      consecutiveRow(3, 10, "2026-09-10", 60),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[10, "施設10"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result[0].launch_failed_days).toBe(0);
    expect(result[0].summary).toBe(SUMMARY_TEMPLATE_COMPLETION);
  });

  it("#11 last_problem_on は期間内最終エントリの日付(YYYY-MM-DD)", () => {
    const entries = [consecutiveRow(1, 1, "2026-09-10", 50)];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[1, "施設A"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result[0].last_problem_on).toBe("2026-09-10");
  });

  it("#12 完了率 69.9(△)が最終日は問題あり(行が出る)・launch_failed_daysに数えない", () => {
    // ★D-139 で 70.0 は ○(問題なし)になったため、△の代表値を 69.9 にした★
    const entries = [consecutiveRow(1, 12, "2026-09-10", 69.9)];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[12, "施設12"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result).toHaveLength(1);
    expect(result[0].consecutive_problem_days).toBe(1);
    expect(result[0].launch_failed_days).toBe(0);
  });

  it("★#12b 完了率ちょうど70.0(○)の日は問題日ではないので行が出ない(D-139)★", () => {
    const entries = [consecutiveRow(1, 12, "2026-09-10", 70.0)];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[12, "施設12"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result).toHaveLength(0);
  });

  it("#13 同一日に複数台(1台だけ問題)はその日1日(D-109決定2)", () => {
    const entries = [
      consecutiveRow(1, 13, "2026-09-10", 50, "08:00:00"),
      consecutiveRow(2, 13, "2026-09-10", 95, "09:00:00"),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[13, "施設13"]]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result[0].consecutive_problem_days).toBe(1);
  });

  it("#14 ランが期間の前から続いている(前月から連続4日、期間内は2日)は全期間ぶんで数える", () => {
    // 施設14: 08-30/08-31/09-01/09-02 の4日連続。期間は09-01〜09-05(期間内は09-01・09-02の2日だけ)
    const narrowRange = { from: "2026-09-01", to: "2026-09-05" };
    const entries = [
      consecutiveRow(1, 14, "2026-08-30", null),
      consecutiveRow(2, 14, "2026-08-31", 50),
      consecutiveRow(3, 14, "2026-09-01", 55),
      consecutiveRow(4, 14, "2026-09-02", 60),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    const facilityNameById = new Map([[14, "施設14"]]);
    const result = continuousProblemsInPeriod(
      entries,
      consecutiveById,
      facilityNameById,
      narrowRange
    );
    expect(result).toHaveLength(1);
    // 連続日数は全期間走査の4のまま(期間内の2日に切り詰めない)
    expect(result[0].consecutive_problem_days).toBe(4);
    // {m} も4日ぶんで数える(ランの前半、期間外の08-30がnullであることを拾えている)
    expect(result[0].launch_failed_days).toBe(1);
    expect(result[0].summary).toBe(SUMMARY_TEMPLATE_MIXED);
  });

  it("#15 複数施設は戻りの順序がfacilityNameByIdの並び順で決定的(並べ替えはquery.tsの仕事)", () => {
    const entries = [
      consecutiveRow(1, 30, "2026-09-10", 50),
      consecutiveRow(2, 10, "2026-09-10", 50),
      consecutiveRow(3, 20, "2026-09-10", 50),
    ];
    const consecutiveById = computeConsecutiveProblemDays(entries);
    // facility_id の数値順とは違う順でMapに入れる
    const facilityNameById = new Map([
      [30, "施設C"],
      [10, "施設A"],
      [20, "施設B"],
    ]);
    const result = continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range);
    expect(result.map((r) => r.facility_id)).toEqual([30, 10, 20]);
  });
});

describe("errorBreakdownItemsFromCounts", () => {
  it("★rateの合計が100になる(丸めない生値)★", () => {
    const items = errorBreakdownItemsFromCounts(
      new Map([
        ["EmergencyStop", 18],
        ["LostLocalization", 12],
        ["", 1],
      ])
    );
    const totalRate = items.reduce((sum, item) => sum + item.rate, 0);
    expect(totalRate).toBeCloseTo(100, 10);
  });

  it("★typeとlabelの両方に同じ生値を入れる(D-077/D-088。変換しない)★", () => {
    const items = errorBreakdownItemsFromCounts(new Map([["EmergencyStop", 5]]));
    expect(items).toEqual([{ type: "EmergencyStop", label: "EmergencyStop", count: 5, rate: 100 }]);
  });

  it("空文字のtypeもそのまま通す(画面が落ちないことの確認対象。7分類に寄せない)", () => {
    const items = errorBreakdownItemsFromCounts(new Map([["", 3]]));
    expect(items[0].type).toBe("");
    expect(items[0].label).toBe("");
  });

  it("該当0件なら空配列", () => {
    expect(errorBreakdownItemsFromCounts(new Map())).toEqual([]);
  });
});

describe("errorBreakdownRowsFromSummary", () => {
  it("同一期間・同一error_typeを全施設ぶん足し上げる(facility版の合算)", () => {
    const rows = errorBreakdownRowsFromSummary([
      { year: 2026, month: 9, week: 1, error_type: "EmergencyStop", error_count: 3 },
      { year: 2026, month: 9, week: 1, error_type: "EmergencyStop", error_count: 5 }, // 別施設
      { year: 2026, month: 9, week: 1, error_type: "LostLocalization", error_count: 2 },
    ]);
    expect(rows).toHaveLength(1);
    expect(rows[0].period).toBe("weekly");
    expect(rows[0].year).toBe(2026);
    expect(rows[0].week).toBe(1);
    const emergencyStop = rows[0].items.find((i) => i.type === "EmergencyStop");
    expect(emergencyStop?.count).toBe(8);
    const total = rows[0].items.reduce((sum, i) => sum + i.count, 0);
    expect(total).toBe(10);
    const totalRate = rows[0].items.reduce((sum, i) => sum + i.rate, 0);
    expect(totalRate).toBeCloseTo(100, 10);
  });

  it("期間が違えば別の行になる(月次week=0と週次を混同しない)", () => {
    const rows = errorBreakdownRowsFromSummary([
      { year: 2026, month: 9, week: 1, error_type: "EmergencyStop", error_count: 1 },
      { year: 2026, month: 9, week: 0, error_type: "EmergencyStop", error_count: 1 },
    ]);
    expect(rows).toHaveLength(2);
    const periods = rows.map((r) => r.period).sort();
    expect(periods).toEqual(["monthly", "weekly"]);
  });

  it("該当行が0件なら空配列", () => {
    expect(errorBreakdownRowsFromSummary([])).toEqual([]);
  });
});

// ================================================================
// 指示書AB 群3 — エラー率の分子を本清掃側にする(TeamWorks 第8回 A7。2026-09-11)
//
// ★期待値表(実装より先に確定させたもの。指示書AB 段階3-2 2-1)★
//
// (a) mainTimetableStartAtById() — 本清掃行 → id(文字列) → start_at
//   a1: 本清掃行2件(id=1001,1002)           → Map size===2
//   a2: 空配列                               → size===0
//   a3: BigInt の id が★文字列キー★になる   → has("1001")===true / has(1001)===false
//
// (b) errorCountByMainTimetable() — ★分子の本体★
//   b1: 本清掃行を指す(1001)                 → Map { "1001" => 1 }
//   b2: ★事前チェック行を指す(101001)★      → size===0(数えない)
//   b3: ★clean_timetable_id が null★        → size===0(数えない。決定3)
//   b4: ★dangling(999999)★                  → size===0(例外を投げない)
//   b5: 同じ本清掃に2件                      → Map { "1001" => 2 }
//   b6: 本清掃と事前チェックが混在            → Map { "1001" => 1, "1002" => 1 }
//   b7: 0件                                   → size===0
//   b8: 本清掃マップが空                      → size===0
//
// (c) countMainErrorTypesInRange() — 当期の内訳。★期間帰属は本清掃の start_at★(決定7)
//   c1: 本清掃を指し start_at が範囲内        → 数える
//   c2: 事前チェック行を指す                  → 数えない
//   c3: null                                  → 数えない
//   c4: dangling                              → 数えない・例外を投げない
//   c5: start_at が range.from ちょうど       → 数える
//   c6: start_at が range.to ちょうど         → 数える
//   c7: start_at が range.from の前日         → 数えない
//   c8: start_at が range.to の翌日           → 数えない
//   c9: ★errored_at は範囲外・start_at は範囲内★ → ★数える★(決定7 の証拠)
//   c10: 同じ error_type が2件                → Map { type => 2 }
//   c11: 0件                                   → size===0
//
// (d) kpiFromCleanStats() — 分母0と0件(★既存の挙動。変えない★)
//   d1: executed=0  / errorCount=0            → error_rate.value === null
//   d2: executed=10 / errorCount=0            → 0
//   d3: executed=10 / errorCount=3            → 30(丸めない生値)
// ================================================================

describe("mainTimetableStartAtById", () => {
  // ★DBのDATETIMEは日本時間の壁時計値(指示書Y)。テストの入力はDBの生値の形(末尾 Z)で書く★
  const mainRows = [
    { id: BigInt(1001), facility_id: BigInt(1), start_at: new Date("2026-09-08T02:00:00Z") },
    { id: BigInt(1002), facility_id: BigInt(1), start_at: new Date("2026-09-09T02:00:00Z") },
  ];

  it("a1: 本清掃行2件から id(文字列) → start_at の対応表を作る", () => {
    const map = mainTimetableStartAtById(mainRows);
    expect(map.size).toBe(2);
    expect(map.get("1001")).toEqual(new Date("2026-09-08T02:00:00Z"));
    expect(map.get("1002")).toEqual(new Date("2026-09-09T02:00:00Z"));
  });

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

  it("a3: BigInt の id は文字列キーになる(=== でキー比較できないため)", () => {
    const map = mainTimetableStartAtById(mainRows);
    expect(map.has("1001")).toBe(true);
    expect(map.has(1001 as never)).toBe(false);
  });
});

describe("errorCountByMainTimetable", () => {
  // ★本清掃id = 1001 / 1002、事前チェックid = 101001(本清掃 1001 の60分前行)★
  const mainStartAtById = mainTimetableStartAtById([
    { id: BigInt(1001), facility_id: BigInt(1), start_at: new Date("2026-09-08T02:00:00Z") },
    { id: BigInt(1002), facility_id: BigInt(1), start_at: new Date("2026-09-09T02:00:00Z") },
  ]);

  it("b1: 本清掃行を指すエラーを数える", () => {
    const counts = errorCountByMainTimetable(
      [{ clean_timetable_id: BigInt(1001) }],
      mainStartAtById
    );
    expect(counts.get("1001")).toBe(1);
    expect(counts.size).toBe(1);
  });

  it("b2: ★事前チェック行を指すエラーは数えない★(TeamWorks 第8回 A7)", () => {
    const counts = errorCountByMainTimetable(
      [{ clean_timetable_id: BigInt(101_001) }],
      mainStartAtById
    );
    expect(counts.size).toBe(0);
  });

  it("b3: clean_timetable_id が null のエラーは数えない(決定3)", () => {
    const counts = errorCountByMainTimetable([{ clean_timetable_id: null }], mainStartAtById);
    expect(counts.size).toBe(0);
  });

  it("b4: dangling(存在しない id)は例外を投げずに捨てる", () => {
    const counts = errorCountByMainTimetable(
      [{ clean_timetable_id: BigInt(999_999) }],
      mainStartAtById
    );
    expect(counts.size).toBe(0);
  });

  it("b5: 同じ本清掃に2件なら2", () => {
    const counts = errorCountByMainTimetable(
      [{ clean_timetable_id: BigInt(1001) }, { clean_timetable_id: BigInt(1001) }],
      mainStartAtById
    );
    expect(counts.get("1001")).toBe(2);
  });

  it("b6: 本清掃と事前チェックが混在しても本清掃ぶんだけ数える", () => {
    const counts = errorCountByMainTimetable(
      [
        { clean_timetable_id: BigInt(1001) },
        { clean_timetable_id: BigInt(101_001) },
        { clean_timetable_id: BigInt(1002) },
      ],
      mainStartAtById
    );
    expect(counts.get("1001")).toBe(1);
    expect(counts.get("1002")).toBe(1);
    expect(counts.size).toBe(2);
  });

  it("b7: エラー0件なら空のMap", () => {
    expect(errorCountByMainTimetable([], mainStartAtById).size).toBe(0);
  });

  it("b8: 本清掃マップが空なら何も数えない", () => {
    const counts = errorCountByMainTimetable(
      [
        { clean_timetable_id: BigInt(1001) },
        { clean_timetable_id: BigInt(1002) },
        { clean_timetable_id: null },
      ],
      new Map()
    );
    expect(counts.size).toBe(0);
  });
});

describe("countMainErrorTypesInRange", () => {
  const range = { from: "2026-09-06", to: "2026-09-12" };
  // ★本清掃 1001 = 範囲内 / 1002 = range.from ちょうど / 1003 = range.to ちょうど
  //   1004 = range.from の前日 / 1005 = range.to の翌日
  //   1006 = ★深夜清掃(02:00)。errored_at は翌日になる(c9 用)★
  const mainStartAtById = mainTimetableStartAtById([
    { id: BigInt(1001), facility_id: BigInt(1), start_at: new Date("2026-09-08T02:00:00Z") },
    { id: BigInt(1002), facility_id: BigInt(1), start_at: new Date("2026-09-06T02:00:00Z") },
    { id: BigInt(1003), facility_id: BigInt(1), start_at: new Date("2026-09-12T02:00:00Z") },
    { id: BigInt(1004), facility_id: BigInt(1), start_at: new Date("2026-09-05T02:00:00Z") },
    { id: BigInt(1005), facility_id: BigInt(1), start_at: new Date("2026-09-13T02:00:00Z") },
    { id: BigInt(1006), facility_id: BigInt(1), start_at: new Date("2026-09-12T02:00:00Z") },
  ]);

  /** テスト用ヘルパー: エラー行を1件作る */
  function errorRow(cleanTimetableId: number | null, errorType: string, erroredAt: string) {
    return {
      clean_timetable_id: cleanTimetableId === null ? null : BigInt(cleanTimetableId),
      error_type: errorType,
      errored_at: new Date(erroredAt),
      created_at: new Date(erroredAt),
    };
  }

  it("c1: 本清掃を指し、その start_at が範囲内なら数える", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(1001, "EmergencyStop", "2026-09-08T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.get("EmergencyStop")).toBe(1);
  });

  it("c2: ★事前チェック行を指すエラーは数えない★", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(101_001, "EmergencyStop", "2026-09-08T01:00:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.size).toBe(0);
  });

  it("c3: clean_timetable_id が null なら数えない(決定3)", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(null, "EmergencyStop", "2026-09-08T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.size).toBe(0);
  });

  it("c4: dangling は例外を投げずに数えない", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(999_999, "EmergencyStop", "2026-09-08T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.size).toBe(0);
  });

  it("c5: 本清掃の start_at が range.from ちょうどなら数える(両端含む)", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(1002, "LostLocalization", "2026-09-06T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.get("LostLocalization")).toBe(1);
  });

  it("c6: 本清掃の start_at が range.to ちょうどなら数える(両端含む)", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(1003, "LostLocalization", "2026-09-12T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.get("LostLocalization")).toBe(1);
  });

  it("c7: 本清掃の start_at が range.from の前日なら数えない", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(1004, "EmergencyStop", "2026-09-05T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.size).toBe(0);
  });

  it("c8: 本清掃の start_at が range.to の翌日なら数えない", () => {
    const counts = countMainErrorTypesInRange(
      [errorRow(1005, "EmergencyStop", "2026-09-13T02:20:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.size).toBe(0);
  });

  it("c9: ★errored_at は範囲外でも、本清掃の start_at が範囲内なら数える★(決定7)", () => {
    // ★深夜清掃(09-12 02:00 開始)の異常が日をまたいで 09-13 に検知された、という形★。
    //   ★旧実装の `errored_at ?? created_at` 基準ならこの行は落ちる★(決定7 が効いている証拠)。
    const counts = countMainErrorTypesInRange(
      [errorRow(1006, "RobotHardwareErrorToAdmin", "2026-09-13T01:00:00Z")],
      mainStartAtById,
      range
    );
    expect(counts.get("RobotHardwareErrorToAdmin")).toBe(1);
  });

  it("c10: 同じ error_type が2件なら2", () => {
    const counts = countMainErrorTypesInRange(
      [
        errorRow(1001, "EmergencyStop", "2026-09-08T02:20:00Z"),
        errorRow(1002, "EmergencyStop", "2026-09-06T02:20:00Z"),
      ],
      mainStartAtById,
      range
    );
    expect(counts.get("EmergencyStop")).toBe(2);
    expect(counts.size).toBe(1);
  });

  it("c11: 0件なら空のMap", () => {
    expect(countMainErrorTypesInRange([], mainStartAtById, range).size).toBe(0);
  });
});

describe("kpiFromCleanStats の error_rate(分子の作り方を変えても分母0の扱いは変えない)", () => {
  it("d1: planned=0 / errorCount=0 は null(分母0は率を出さない。0%にしない)", () => {
    const kpi = kpiFromCleanStats(
      {
        planned: 0,
        executed: 0,
        completed: 0,
        taskArea: 0,
        cleanArea: 0,
        errorCount: 0,
        cleanRatioSum: 0,
        cleanRatioCount: 0,
      },
      undefined
    );
    expect(kpi.error_rate.value).toBeNull();
  });

  it("d2: planned=10 / errorCount=0 は 0", () => {
    const kpi = kpiFromCleanStats(
      {
        planned: 10,
        executed: 10,
        completed: 10,
        taskArea: 1000,
        cleanArea: 900,
        errorCount: 0,
        cleanRatioSum: 900,
        cleanRatioCount: 10,
      },
      undefined
    );
    expect(kpi.error_rate.value).toBe(0);
  });

  it("d3: planned=10 / errorCount=3 は 30(丸めない生値。分母は予定回数。P-6=B)", () => {
    const kpi = kpiFromCleanStats(
      {
        planned: 10,
        executed: 10,
        completed: 10,
        taskArea: 1000,
        cleanArea: 900,
        errorCount: 3,
        cleanRatioSum: 900,
        cleanRatioCount: 10,
      },
      undefined
    );
    expect(kpi.error_rate.value).toBe(30);
  });
});

/**
 * ★表2 — 当期 KPI(第11回 P-6。D-158)★(`docs/handoff/指示書AU_第11回の回答を反映する_20261005.md` §3)。
 *
 * ★★本書 §3 の表2 が正。変えないこと★★(`CLAUDE.md` §0-2・§6-2・§9.4)。表とテストが食い違ったら、
 *   テストを表に合わせる。
 *
 * 式(サマリと同じ。どれも分母は予定回数):
 *   稼働率 = 正常終了(status = 4)の回数 ÷ 予定 / 出発成功率 = 開始できた回数(レポート行がある回数)÷ 予定 /
 *   エラー発生率 = エラー件数 ÷ 予定(★上限で切らない★)
 *
 * | # | 予定 | 開始 | うち status=4 | エラー件数 | 稼働率 | 出発成功率 | エラー発生率 |
 * |---|---|---|---|---|---|---|---|
 * | K1 | 10 | 9 | 7 | 2 | 70.0 | 90.0 | 20.0 |
 * | K2 | 0 | 0 | 0 | 0 | null | null | null |
 * | K3 | 5 | 0 | 0 | 0 | 0.0 | 0.0 | 0.0 |
 * | K4 | 4 | 4 | 4 | 0 | 100.0 | 100.0 | 0.0 |
 * | K5 | 3 | 3 | 2(残り1行は status=5) | 3 | 66.666… | 100.0 | 100.0 |
 * | K6 | 2 | 2 | 1(残り1行は status=6) | 0 | 50.0 | 100.0 | 0.0 |
 * | K7 | 2 + 予定外1行(status=4・予定 ID NULL) | 2 | 2 | 0 | 100.0 | 100.0 | 0.0(予定外は数えない) |
 * | K8 | 1 | 1 | 1(status=4・clean_area=0) | 0 | 100.0 | 100.0 | 0.0 |
 *
 * 書き方(本書 §3 の注意):
 *   - `latestReportByTimetableId()` → `aggregateCleanStatsByFacility()` → `sumCleanStatsBuckets()` →
 *     `kpiFromCleanStats()` を通す(★自前で割り算を書かない★)。未実施の述語は本物の `notExecutedFromReport()`
 *   - 予定はすべて期間内・過去。1施設。前期は `undefined`
 *   - K1 の status=4 以外の2行は 5 と 2。面積は 100/100(K8 だけ 0/100)
 *   - エラーは `errorCountByMainTimetable()` を通す。K1 は2件を別々の予定に、K5 は3件を1つの予定に。
 *     K7 には予定 ID が NULL のエラー行を1件混ぜる(落ちること)
 */
describe("表2 — 当期 KPI はサマリと同じ式(第11回 P-6。D-158)", () => {
  const range = { from: "2026-10-05", to: "2026-10-11" };
  // ★すべての予定が過去になる時刻★
  const now = new Date("2026-10-20T00:00:00+09:00");
  const facilityId = BigInt(1);

  interface KCase {
    name: string;
    planned: number;
    /** 開始した予定(先頭から順に)のレポート行の status */
    startedStatuses: readonly number[];
    /** エラー行が指す予定の添字(同じ添字を並べれば1つの予定に寄せる)。null は予定 ID が NULL の行 */
    errorTargets: readonly (number | null)[];
    /** 予定外のタスク(`clean_timetable_id` が NULL)のレポート行の status */
    unscheduledStatuses?: readonly number[];
    /** 開始した行の清掃面積(既定 100。task_area は常に 100) */
    cleanArea?: number;
    expected: { operation: number | null; launch: number | null; error: number | null };
  }

  const completed = CLEAN_TASK_STATUS.completed;
  const cases: readonly KCase[] = [
    {
      name: "K1",
      planned: 10,
      startedStatuses: [...Array<number>(7).fill(completed), 5, 2],
      errorTargets: [0, 1],
      expected: { operation: 70.0, launch: 90.0, error: 20.0 },
    },
    {
      name: "K2 予定が1件も無い",
      planned: 0,
      startedStatuses: [],
      errorTargets: [],
      expected: { operation: null, launch: null, error: null },
    },
    {
      name: "K3 予定はあったが全部出発失敗",
      planned: 5,
      startedStatuses: [],
      errorTargets: [],
      expected: { operation: 0.0, launch: 0.0, error: 0.0 },
    },
    {
      name: "K4",
      planned: 4,
      startedStatuses: Array<number>(4).fill(completed),
      errorTargets: [],
      expected: { operation: 100.0, launch: 100.0, error: 0.0 },
    },
    {
      name: "K5 status=5 を含む・エラー3件を1つの予定に",
      planned: 3,
      startedStatuses: [completed, completed, 5],
      errorTargets: [2, 2, 2],
      expected: { operation: 200 / 3, launch: 100.0, error: 100.0 },
    },
    {
      name: "K6 status=6 は正常終了に数えない",
      planned: 2,
      startedStatuses: [completed, 6],
      errorTargets: [],
      expected: { operation: 50.0, launch: 100.0, error: 0.0 },
    },
    {
      name: "K7 予定外のタスクは数えない",
      planned: 2,
      startedStatuses: [completed, completed],
      errorTargets: [null],
      unscheduledStatuses: [completed],
      expected: { operation: 100.0, launch: 100.0, error: 0.0 },
    },
    {
      name: "K8 正常終了は status で数える(面積は見ない)",
      planned: 1,
      startedStatuses: [completed],
      errorTargets: [],
      cleanArea: 0,
      expected: { operation: 100.0, launch: 100.0, error: 0.0 },
    },
  ];

  function kpiOf(c: KCase) {
    const mainRows: MainTimetableLike[] = Array.from({ length: c.planned }, (_, i) => ({
      id: BigInt(i + 1),
      facility_id: facilityId,
      // DB生値 2026-10-06 01:i(範囲内・過去)
      start_at: new Date(Date.UTC(2026, 9, 6, 1, i)),
    }));
    const reportRow = (clean_timetable_id: bigint | null, startedAt: Date, status: number) => ({
      clean_timetable_id,
      started_at: startedAt,
      ended_at: new Date(startedAt.getTime() + 30 * 60 * 1000),
      clean_ratio: 0,
      task_area: 100,
      clean_area: c.cleanArea ?? 100,
      status,
    });
    const reports = [
      ...c.startedStatuses.map((status, i) =>
        reportRow(mainRows[i].id, mainRows[i].start_at, status)
      ),
      ...(c.unscheduledStatuses ?? []).map((status) =>
        reportRow(null, new Date(Date.UTC(2026, 9, 6, 3, 0)), status)
      ),
    ];
    const reportMap = latestReportByTimetableId(reports);
    const errorRows = c.errorTargets.map((i) => ({
      clean_timetable_id: i === null ? null : mainRows[i].id,
    }));
    const errorCount = errorCountByMainTimetable(errorRows, mainTimetableStartAtById(mainRows));
    const buckets = aggregateCleanStatsByFacility(
      withEndAt(mainRows),
      reportMap,
      notExecutedFromReport,
      errorCount,
      range,
      now
    );
    return { kpi: kpiFromCleanStats(sumCleanStatsBuckets(buckets.values()), undefined), reportMap };
  }

  it.each(cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
    const { kpi } = kpiOf(c);
    if (c.name.startsWith("K5")) {
      expect(kpi.operation_rate.value).toBeCloseTo(200 / 3, 10);
    } else {
      expect(kpi.operation_rate.value).toBe(c.expected.operation);
    }
    expect(kpi.launch_rate.value).toBe(c.expected.launch);
    expect(kpi.error_rate.value).toBe(c.expected.error);
  });

  it("K7: 予定外(`clean_timetable_id` が NULL)のレポート行は Map に入らない", () => {
    const k7 = cases.find((c) => c.name.startsWith("K7"));
    if (k7 === undefined) throw new Error("K7 が無い");
    expect(kpiOf(k7).reportMap.size).toBe(2);
  });

  it("★K1〜K8 のすべてで 稼働率 ≤ 出発成功率(正常終了は開始の部分集合)★", () => {
    for (const c of cases) {
      const { kpi } = kpiOf(c);
      const op = kpi.operation_rate.value;
      const launch = kpi.launch_rate.value;
      if (op === null || launch === null) {
        expect(op).toBeNull();
        expect(launch).toBeNull();
        continue;
      }
      expect(op).toBeLessThanOrEqual(launch);
    }
  });

  it("★エラー発生率は上限で切らない(D9)★: 予定3・エラー4 → 133.33…", () => {
    const { kpi } = kpiOf({
      name: "上限なし",
      planned: 3,
      startedStatuses: [completed, completed, completed],
      errorTargets: [0, 0, 1, 2],
      expected: { operation: 100, launch: 100, error: 400 / 3 },
    });
    expect(kpi.error_rate.value).toBeCloseTo(400 / 3, 10);
  });
});

// ★★結果待ち(D-173。ユーザー判断 2026-10-07)— 表2 C11★★
//   予定 02:00〜03:00(JST)、W=120。★結果待ちは未来の予定と同じく数えない(予定回数にも入れない)★。
//   ★バケットは作る(決定1。行ごと捨てない)★
describe("aggregateCleanStatsByFacility — 結果待ちは当期の率に数えない(表2 C11。D-173)", () => {
  const range = { from: "2026-10-01", to: "2026-10-07" };
  // ★DB の壁時計値(末尾 Z)で書く★(このファイルの他のテストと同じ形)
  const row = (id: number, facilityId: number): CleanStatsTimetableLike => ({
    id: BigInt(id),
    facility_id: BigInt(facilityId),
    start_at: new Date("2026-10-07T02:00:00Z"),
    end_at: new Date("2026-10-07T03:00:00Z"),
  });
  const reports = new Map<string, CleanStatsReportLike>([
    ["2", { clean_ratio: 100, task_area: 100, clean_area: 100, status: COMPLETED }],
  ]);
  const run = (nowJst: string) =>
    aggregateCleanStatsByFacility(
      [row(1, 1), row(2, 1), row(3, 2)],
      reports,
      missingRowIsNotExecuted,
      new Map(),
      range,
      new Date(nowJst)
    );

  it("now=04:59: レポートなしの行は結果待ち → 予定回数にも入れない(バケットは残る)", () => {
    const buckets = run("2026-10-07T04:59:00+09:00");
    expect(buckets.get(1)).toMatchObject({ planned: 1, executed: 1, completed: 1 });
    // ★施設2は結果待ちの行しか無いが、一覧から消えないようバケットは作る(決定1)★
    expect(buckets.get(2)).toEqual<CleanStatsBucket>({
      planned: 0,
      executed: 0,
      completed: 0,
      taskArea: 0,
      cleanArea: 0,
      errorCount: 0,
      cleanRatioSum: 0,
      cleanRatioCount: 0,
    });
  });

  it("now=05:00(終了+120分ちょうど): 確定 → 未実施として予定回数に入る(表1 P6)", () => {
    const buckets = run("2026-10-07T05:00:00+09:00");
    expect(buckets.get(1)).toMatchObject({ planned: 2, executed: 1, completed: 1 });
    expect(buckets.get(2)).toMatchObject({ planned: 1, executed: 0 });
  });
});