Newer
Older
client_dwatch / lib / judge / __tests__ / rescue.test.ts
import { describe, expect, it } from "vitest";
import { COMPLETION_DROP, RESCUE_THRESHOLDS } from "@/lib/config";
import {
  countNotExecutedStreak,
  facilityDayRate,
  foldRescueDays,
  isEscalationL3,
  judgeCompletionDrop,
  judgeRescue,
  type RescueRowInput,
} from "../rescue";

/**
 * 要レスキュー店舗の判定(指示書AI 段階3 / ユーザー指示 2026-09-15)
 *
 * ★★このファイルは実装より先に書かれた★★(`CLAUDE.md` §6-2 / §9.4
 * 「期待値表を先に作り、それをテストで固定する」)。表の番号は指示書AI 表1〜表5 に対応する。
 *
 * 3条件:
 *   R-1 未実施が3日以上連続(=出発失敗と同義。★`departed === false` の連続★。【未決1】案A。D-156)
 *   R-2 連続問題レベル3(★既存の `judgeEscalationLevel()` をそのまま使う★。数え直さない)
 *   R-3 完了率が前回比20%以上の急落(★差ではなく比★。ユーザー確定「比で」)
 */

/**
 * 1行(=1清掃エリアの1回の清掃)を作る。★★は未実施=`departed: false` / `completion_rate: null`★。
 * ★`departed` の既定は「`completion_rate` が null でなければ出発した」★ — `departed` と `completion_rate` が
 *   食い違う行(防御。P-5=B 以降 DB からは来ない)は表5 で `departed` を明示して作る(【未決1】案A。D-156 / 指示書AU D3)。
 */
function row(
  date: string,
  completionRate: number | null,
  time = "02:00:00",
  departed = completionRate !== null
): RescueRowInput {
  return {
    scheduled_at: `${date}T${time}+09:00`,
    completion_rate: completionRate,
    departed,
  };
}

/** 未実施の1行(`clean_task_reports` の行が無い清掃。指示書AQ 1-1) */
function notExecuted(date: string, time = "02:00:00"): RescueRowInput {
  return row(date, null, time);
}

/** 正常に終わった1行(★`rate` は1回ごとの完了率=`runCompletionRate()` の戻り。D-155★) */
function done(date: string, rate: number, time = "02:00:00"): RescueRowInput {
  return row(date, rate, time);
}

/**
 * ★面積つきの1行★(表3 / 表6 用)。
 *
 * ★実際の呼び出し側は `ConsecutiveInput`(`lib/db/clean-log-mapping.ts`)をそのまま渡すので、
 *   `clean_area` / `task_area` が**付いたまま**届く★。★判定がそれを読まないこと★を固定するために使う。
 *   (★オブジェクトリテラルを直接 `RescueRowInput` に代入すると余分なキーで型エラーになる★ので、
 *   一度変数に受けてから返す)
 */
function doneWithArea(
  date: string,
  rate: number,
  cleanArea: number,
  taskArea: number,
  time = "02:00:00"
): RescueRowInput {
  const withArea = {
    scheduled_at: `${date}T${time}+09:00`,
    completion_rate: rate,
    departed: true,
    clean_area: cleanArea,
    task_area: taskArea,
  };
  return withArea;
}

// ---------------------------------------------------------------- 表1: R-1

describe("表1: R-1(未実施が3日以上連続)", () => {
  /** 指示書AI 表1 は「当該日時点の連続数」で判定する。★当該日=最後の要素★ */
  const streak = (rows: readonly RescueRowInput[]) =>
    countNotExecutedStreak(foldRescueDays(rows));
  const hit = (rows: readonly RescueRowInput[]) =>
    streak(rows) >= RESCUE_THRESHOLDS.notExecutedDays;

  it("1-1: [○] は false(1日・正常)", () => {
    expect(hit([done("2026-09-01", 95)])).toBe(false);
    expect(streak([done("2026-09-01", 95)])).toBe(0);
  });

  it("1-2: [★] は false(未実施1日)", () => {
    expect(hit([notExecuted("2026-09-01")])).toBe(false);
    expect(streak([notExecuted("2026-09-01")])).toBe(1);
  });

  it("1-3: [★, ★] は false(2日)", () => {
    const rows = [notExecuted("2026-09-01"), notExecuted("2026-09-02")];
    expect(streak(rows)).toBe(2);
    expect(hit(rows)).toBe(false);
  });

  it("★1-4: [★, ★, ★] は true(3日。★境界★)", () => {
    const rows = [
      notExecuted("2026-09-01"),
      notExecuted("2026-09-02"),
      notExecuted("2026-09-03"),
    ];
    expect(streak(rows)).toBe(3);
    expect(hit(rows)).toBe(true);
  });

  it("1-5: [★, ★, ★, ★] は true(4日以上も拾う。「3日以上」)", () => {
    const rows = [
      notExecuted("2026-09-01"),
      notExecuted("2026-09-02"),
      notExecuted("2026-09-03"),
      notExecuted("2026-09-04"),
    ];
    expect(streak(rows)).toBe(4);
    expect(hit(rows)).toBe(true);
  });

  it("1-6: [★, ○, ★, ★] は false(途中で切れて2日)", () => {
    const rows = [
      notExecuted("2026-09-01"),
      done("2026-09-02", 95),
      notExecuted("2026-09-03"),
      notExecuted("2026-09-04"),
    ];
    expect(streak(rows)).toBe(2);
    expect(hit(rows)).toBe(false);
  });

  it("★1-7: [★, ★, ★, ○] は false(当該日が正常なら、過去に3日連続があっても拾わない)★", () => {
    const rows = [
      notExecuted("2026-09-01"),
      notExecuted("2026-09-02"),
      notExecuted("2026-09-03"),
      done("2026-09-04", 95),
    ];
    expect(streak(rows)).toBe(0);
    expect(hit(rows)).toBe(false);
  });

  it("1-8: [] は false(行なし)", () => {
    expect(streak([])).toBe(0);
    expect(hit([])).toBe(false);
  });

  it("★1-9: 予定のない日を挟んでも数える(カレンダー日ではなくスケジュール存在日。D-026)★", () => {
    // 9/8 は予定なし。カレンダー日で数えると 9/9〜9/10 の2日に切れて取りこぼす
    const rows = [
      notExecuted("2026-09-07"),
      notExecuted("2026-09-09"),
      notExecuted("2026-09-10"),
    ];
    expect(streak(rows)).toBe(3);
    expect(hit(rows)).toBe(true);
  });

  it("★1-10: 同一日に2台、片方だけ未実施ならその日は「未実施の日」(OR。D-109 A案)★", () => {
    const rows = [notExecuted("2026-09-01"), done("2026-09-01", 90, "03:00:00")];
    // ★未実施の行は完了率の件数に入れない★(完了率は実施した台だけで出す)
    expect(foldRescueDays(rows)).toEqual([
      { date: "2026-09-01", not_executed: true, clean_ratio_sum: 90, clean_ratio_count: 1 },
    ]);
    // ★3日ぶん重ねれば拾う★
    const threeDays = [
      ...rows,
      notExecuted("2026-09-02"),
      done("2026-09-02", 90, "03:00:00"),
      notExecuted("2026-09-03"),
      done("2026-09-03", 90, "03:00:00"),
    ];
    expect(streak(threeDays)).toBe(3);
    expect(hit(threeDays)).toBe(true);
  });

  it("1-11: 同一日に同一ロボットが2回清掃、片方だけ未実施でも1日に畳む(決定2)", () => {
    // ★この入力は 1-10 と構造が同じ★ — 「1日に何行あるか」だけが問題で、
    //   どの台の行かは畳み込みに影響しない(D-026 は日単位であって回数単位ではない)
    const rows = [
      done("2026-09-01", 90, "02:00:00"),
      notExecuted("2026-09-01", "22:00:00"),
    ];
    expect(foldRescueDays(rows)).toHaveLength(1);
    expect(foldRescueDays(rows)[0].not_executed).toBe(true);
    expect(streak(rows)).toBe(1);
  });
});

// ---------------------------------------------------------------- 表2: R-2

describe("表2: R-2(連続問題レベル3)", () => {
  // ★★既存の `judgeEscalationLevel()` をそのまま使う。新しく数え直さない★★
  //   (D-109 の再発防止「連続日数の算出は1本に集約する」。指示書AI §13-6)
  it("2-1: 2日は false(L2)", () => {
    expect(isEscalationL3(2)).toBe(false);
  });

  it("★2-2: 3日は true(L3。★境界★)", () => {
    expect(isEscalationL3(3)).toBe(true);
  });

  it("2-3: 10日は true", () => {
    expect(isEscalationL3(10)).toBe(true);
  });

  it("2-4: 0日は false", () => {
    expect(isEscalationL3(0)).toBe(false);
  });

  it("1日は false(L1)— 境界の下側", () => {
    expect(isEscalationL3(1)).toBe(false);
  });
});

// ---------------------------------------------------------------- 表5(指示書AS): 未実施の数え方

/**
 * ★★指示書AS §4 表5 — 未実施の数え方(【未決1】案A。D-156)★★
 *
 * | #  | 清掃                        | departed | completion_rate | R-1 の未実施日 |
 * |----|-----------------------------|----------|-----------------|----------------|
 * | 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("表5(指示書AS): R-1 の未実施は departed で数える", () => {
  const D = "2026-09-01";
  const notExecutedOf = (rows: readonly RescueRowInput[]) =>
    foldRescueDays(rows).map((d) => d.not_executed);

  it("U1 レポートの行が無い → 未実施の日", () => {
    expect(notExecutedOf([row(D, null, "02:00:00", false)])).toEqual([true]);
  });

  it("★U2 `departed` と `completion_rate` が食い違う行(防御。departed true・completion_rate null)→ 未実施の日に数えない★", () => {
    const days = foldRescueDays([row(D, null, "02:00:00", true)]);
    expect(days.map((d) => d.not_executed)).toEqual([false]);
    // ★完了率の Σ と件数には入らない(null は数えない)★
    expect(days[0].clean_ratio_count).toBe(0);
    expect(facilityDayRate(days)).toBeNull();
  });

  it("U3 行がある・50/100 → 数えない", () => {
    expect(notExecutedOf([row(D, 50, "02:00:00", true)])).toEqual([false]);
  });

  it("U4 行がある・100/100 → 数えない", () => {
    expect(notExecutedOf([row(D, 100, "02:00:00", true)])).toEqual([false]);
  });

  it("U5 同じ日に U1 と U4 → その日を1日数える(OR)", () => {
    const days = foldRescueDays([
      row(D, null, "02:00:00", false),
      row(D, 100, "03:00:00", true),
    ]);
    expect(days.map((d) => d.not_executed)).toEqual([true]);
    expect(countNotExecutedStreak(days)).toBe(1);
  });

  it("★U2 が3日続いても R-1 にならない(出発しているので現場に誤った連絡を飛ばさない)★", () => {
    const rows = ["2026-09-01", "2026-09-02", "2026-09-03"].map((d) =>
      row(d, null, "02:00:00", true)
    );
    expect(countNotExecutedStreak(foldRescueDays(rows))).toBe(0);
  });
});

// ---------------------------------------------------------------- 表3: 施設日完了率

/**
 * ★表3: 施設日完了率(facilityDayRate)= その日の1回ごとの完了率の単純平均★
 *   (指示書AQ 決定4 / §5-1・D-155。★2026-09-30 に面積加重から変更★)
 *
 * ★★判定は面積を読まず、渡された `completion_rate` だけを使う★★ — 1回ごとの完了率を面積から作るのは
 *   `runCompletionRate()`(データ層の `completionRateFromReport()` 経由)の仕事で、ここではない。
 *   3-8・3-9 は「面積が付いたまま届いても判定がそれを読まない」ことの確認として残す。
 *
 * | # | その日の行(1回ごとの完了率 / 面積) | 期待 | 何を固定するか |
 * |---|---|---|---|
 * | 3-1 | [90] | 90 | 1行はその値 |
 * | 3-2 | [90, 10] | 50 | 単純平均 |
 * | ★3-3 | [90(900/1000㎡), 10(10/100㎡)] | ★50★(面積加重なら 82.7…) | ★面積で重み付けしない★ |
 * | 3-4 | [0] | 0 | 0%で実施した(`null` ではない) |
 * | ★3-5 | [未実施] | null | ★0除算を捏造しない★ |
 * | 3-6 | [] | null | 行なし |
 * | 3-7 | [未実施, 90] | 90 | ★未実施行は分母に入れない★(0% として混ぜない) |
 * | ★3-8 | [61.0(1628/1000㎡ = 面積比162.8%)] | ★61★ | ★判定は面積を読まない★(渡された `completion_rate` だけ) |
 * | ★3-9 | [100(138/1000㎡ = 面積比13.8%)] | ★100★ | 同上(逆向き) |
 * | 3-10 | [89.5] | 89.5 | ★丸めない★(§4.3) |
 */
describe("表3: 施設日完了率(facilityDayRate)= 1回ごとの完了率の単純平均", () => {
  /** ★同じ日の行を畳んでから率を出す★(`judgeRescue()` と同じ経路) */
  const dayRate = (rows: readonly RescueRowInput[]) => facilityDayRate(foldRescueDays(rows));
  const D = "2026-09-01";

  it("3-1: [90] → 90", () => {
    expect(dayRate([done(D, 90)])).toBe(90);
  });

  it("3-2: [90, 10] → 50", () => {
    expect(dayRate([done(D, 90), done(D, 10, "03:00:00")])).toBe(50);
  });

  it("★3-3: [90(900/1000㎡), 10(10/100㎡)] → 50(★面積加重なら 82.7…★)★", () => {
    // ★★この1本が「面積で重み付けしないこと」を固定する★★ — 3-1/3-2 は面積加重でも通る
    const actual = dayRate([
      doneWithArea(D, 90, 900, 1000),
      doneWithArea(D, 10, 10, 100, "03:00:00"),
    ]);
    expect(actual).toBe(50);
    expect(actual).not.toBeCloseTo((910 / 1100) * 100, 5);
  });

  it("3-4: [0] → 0(★0%で実施した。`null` ではない★)", () => {
    expect(dayRate([done(D, 0)])).toBe(0);
  });

  it("★3-5: [未実施] → null(0除算を捏造しない)★", () => {
    expect(dayRate([notExecuted(D)])).toBeNull();
  });

  it("3-6: [] → null", () => {
    expect(facilityDayRate([])).toBeNull();
    expect(dayRate([])).toBeNull();
  });

  it("★3-7: [未実施, 90] → 90(未実施行は分母に入れない。0% として混ぜない)★", () => {
    expect(dayRate([notExecuted(D), done(D, 90, "03:00:00")])).toBe(90);
  });

  it("★3-8: 面積比162.8%の面積が付いていても、渡された completion_rate の 61 を使う(判定は面積を読まない)★", () => {
    expect(dayRate([doneWithArea(D, 61, 1628, 1000)])).toBe(61);
  });

  it("★3-9: 面積比13.8%の面積が付いていても、渡された completion_rate の 100 を使う(判定は面積を読まない)★", () => {
    expect(dayRate([doneWithArea(D, 100, 138, 1000)])).toBe(100);
  });

  it("3-10: [89.5] → 89.5(丸めない)", () => {
    expect(dayRate([done(D, 89.5)])).toBe(89.5);
  });
});

// ---------------------------------------------------------------- 表4: R-3

describe("表4: R-3(急落。dropRatio = 0.2)", () => {
  // ★閾値は設定値から読む(ハードコード禁止。`CLAUDE.md` §7)★
  it("前提: 既定の閾値は 0.2(比。★20 ではない★)", () => {
    expect(COMPLETION_DROP.dropRatio).toBe(0.2);
  });

  const dropped = (prev: number | null, cur: number | null) =>
    judgeCompletionDrop(prev, cur).dropped;

  it("★4-1: 100 → 80(ちょうど20%低下)は true(境界は「以上」)★", () => {
    expect(dropped(100, 80)).toBe(true);
  });

  it("4-2: 100 → 80.1(19.9%低下)は false(★境界の反対側★)", () => {
    expect(dropped(100, 80.1)).toBe(false);
  });

  it("4-3: 100 → 79.9(20.1%低下)は true", () => {
    expect(dropped(100, 79.9)).toBe(true);
  });

  it("★4-4: 30 → 18(40%低下)は true(差なら12ポイントで拾えない。比を選んだ意味)★", () => {
    expect(dropped(30, 18)).toBe(true);
    expect(judgeCompletionDrop(30, 18).drop_ratio).toBeCloseTo(0.4, 10);
  });

  it("4-5: 80 → 60(25%低下)は true", () => {
    expect(dropped(80, 60)).toBe(true);
  });

  it("★4-6: 60 → 80(上昇)は false(決定E。絶対値で見ない)★", () => {
    expect(dropped(60, 80)).toBe(false);
  });

  it("★4-7: 0 → 0 は false(決定B。0で割らない)★", () => {
    expect(dropped(0, 0)).toBe(false);
    expect(judgeCompletionDrop(0, 0).drop_ratio).toBeNull();
  });

  it("★4-8: 0 → 50 は false(決定B)★", () => {
    expect(dropped(0, 50)).toBe(false);
    expect(judgeCompletionDrop(0, 50).drop_ratio).toBeNull();
  });

  it("★4-9: null → 50(前回が未実施)は false(決定C。比べる相手が無い)★", () => {
    expect(dropped(null, 50)).toBe(false);
    expect(judgeCompletionDrop(null, 50).drop_ratio).toBeNull();
  });

  it("★4-10: 50 → null(当日が未実施)は false(決定D。R-1 で拾う)★", () => {
    expect(dropped(50, null)).toBe(false);
    expect(judgeCompletionDrop(50, null).drop_ratio).toBeNull();
  });

  it("4-11: null → null は false", () => {
    expect(dropped(null, null)).toBe(false);
  });

  it("★4-12: 前回が存在しない(初日)は false(比べる相手が無い)★", () => {
    // 初日は `judgeRescue()` から見ると「前回の日が無い」= previous が null
    const judgement = judgeRescue({
      rows: [done("2026-09-01", 10)],
      consecutive_problem_days: 0,
    });
    expect(judgement.previous_completion_rate).toBeNull();
    expect(judgement.drop_ratio).toBeNull();
    expect(judgement.reasons).not.toContain("completion_drop");
  });

  it("★★4-13: 予定のない日を挟んでも「前回のスケジュール存在日」と比べる(決定A)★★", () => {
    // 施設7 の実データ: 09-07 が 94.7% → 09-09 が 72.5%(09-08 は予定なし)。
    // ★カレンダー前日(09-08)と比べる実装だと算出不可になり、拾えない★
    const judgement = judgeRescue({
      rows: [done("2026-09-07", 94.7), done("2026-09-09", 72.5)],
      consecutive_problem_days: 0,
    });
    expect(judgement.previous_completion_rate).toBeCloseTo(94.7, 10);
    expect(judgement.completion_rate).toBeCloseTo(72.5, 10);
    expect(judgement.drop_ratio).toBeCloseTo((94.7 - 72.5) / 94.7, 10);
    expect(judgement.reasons).toContain("completion_drop");
    expect(judgement.required).toBe(true);
  });
});

// ---------------------------------------------------------------- 表5: 合成

describe("表5: 3条件の合成(judgeRescue)", () => {
  /** R-1 だけを立てる行(3日連続で未実施) */
  const notExecuted3Days = [
    notExecuted("2026-09-01"),
    notExecuted("2026-09-02"),
    notExecuted("2026-09-03"),
  ];
  /** どの条件にも当たらない行 */
  const healthy = [done("2026-09-01", 95), done("2026-09-02", 95)];
  /** R-3 だけを立てる行(100% → 79% = 21%低下) */
  const droppedRows = [done("2026-09-01", 100), done("2026-09-02", 79)];

  it("5-1: 3つとも false なら要レスキューでない", () => {
    const j = judgeRescue({ rows: healthy, consecutive_problem_days: 0 });
    expect(j.required).toBe(false);
    expect(j.reasons).toEqual([]);
  });

  it("5-2: R-1 だけ → 要レスキュー(理由: 未実施3日以上)", () => {
    // ★実データでは未実施日は問題日でもあるので R-2 も同時に立つ★。
    //   ここは合成そのものを確かめるため、連続問題日数を 0 として切り離している
    const j = judgeRescue({ rows: notExecuted3Days, consecutive_problem_days: 0 });
    expect(j.required).toBe(true);
    expect(j.reasons).toEqual(["not_executed_streak"]);
    expect(j.not_executed_days).toBe(3);
  });

  it("5-3: R-2 だけ → 要レスキュー(理由: L3)", () => {
    const j = judgeRescue({ rows: healthy, consecutive_problem_days: 3 });
    expect(j.required).toBe(true);
    expect(j.reasons).toEqual(["escalation_l3"]);
    expect(j.consecutive_problem_days).toBe(3);
  });

  it("5-4: R-3 だけ → 要レスキュー(理由: 急落)", () => {
    const j = judgeRescue({ rows: droppedRows, consecutive_problem_days: 0 });
    expect(j.required).toBe(true);
    expect(j.reasons).toEqual(["completion_drop"]);
    expect(j.drop_ratio).toBeCloseTo(0.21, 10);
  });

  it("★5-5: 3つとも true → 理由を3つとも返す(1つに絞らない)★", () => {
    // ★R-1 と R-3 が同時に立つのは「同じ日に未実施の台と実施した台が混在する」とき★
    //   (1-10 の OR と 3-7 の「未実施行は分母に入れない」が同時に効く)。実データにありうる形
    const rows = [
      notExecuted("2026-09-01"),
      done("2026-09-01", 90, "03:00:00"),
      notExecuted("2026-09-02"),
      done("2026-09-02", 90, "03:00:00"),
      notExecuted("2026-09-03"),
      done("2026-09-03", 50, "03:00:00"),
    ];
    const j = judgeRescue({ rows, consecutive_problem_days: 4 });
    expect(j.required).toBe(true);
    expect(j.reasons).toEqual([
      "not_executed_streak",
      "escalation_l3",
      "completion_drop",
    ]);
    expect(j.not_executed_days).toBe(3);
    expect(j.consecutive_problem_days).toBe(4);
    expect(j.completion_rate).toBe(50);
    expect(j.previous_completion_rate).toBe(90);
    expect(j.drop_ratio).toBeCloseTo((90 - 50) / 90, 10);
  });

  it("★理由は識別子で返す(日本語を作らない。D-111)★", () => {
    const j = judgeRescue({ rows: notExecuted3Days, consecutive_problem_days: 5 });
    for (const reason of j.reasons) {
      // ★和文が混ざっていないこと★ — 混ざると EN 切替で日本語が残る
      expect(reason).toMatch(/^[a-z0-9_]+$/);
    }
  });

  it("★空配列なら required は false(型の約束。指示書AI §5-3)★", () => {
    const j = judgeRescue({ rows: [], consecutive_problem_days: 0 });
    expect(j.reasons).toEqual([]);
    expect(j.required).toBe(false);
    expect(j.completion_rate).toBeNull();
    expect(j.previous_completion_rate).toBeNull();
    expect(j.drop_ratio).toBeNull();
    expect(j.not_executed_days).toBe(0);
  });

  it("★付帯情報は丸めない生値(`CLAUDE.md` §4.3 / §9.3)★", () => {
    // 89.5% は表示すると 90% だが、判定にも付帯情報にも丸めた値を使わない
    const j = judgeRescue({
      rows: [done("2026-09-01", 100), done("2026-09-02", 89.5)],
      consecutive_problem_days: 0,
    });
    expect(j.completion_rate).toBe(89.5);
    expect(j.completion_rate).not.toBe(90);
  });

  it("★行は日付昇順でなくてよい(内部で並べ替える)★", () => {
    const shuffled = [
      done("2026-09-09", 72.5),
      done("2026-09-07", 94.7),
    ];
    const j = judgeRescue({ rows: shuffled, consecutive_problem_days: 0 });
    expect(j.completion_rate).toBeCloseTo(72.5, 10);
    expect(j.previous_completion_rate).toBeCloseTo(94.7, 10);
  });

  it("★DBアクセスを混ぜない純粋関数(同じ入力で必ず同じ結果)★", () => {
    const input = { rows: droppedRows, consecutive_problem_days: 3 };
    expect(judgeRescue(input)).toEqual(judgeRescue(input));
  });
});

// ---------------------------------------------------------------- 表6: 面積加重(旧)→ 1回ごとの完了率の平均(新)で結果が変わるケース

/**
 * ★表6: R-3 の判定が面積加重(旧)と1回ごとの完了率の単純平均(新)で変わるケース★
 *   (指示書AQ 決定4 / §5-1。2026-09-30)。
 * ★表の「clean_ratio」は判定に渡す1回ごとの完了率(`completion_rate`)のこと★(D-155 以降はデータ層が
 *   面積から `runCompletionRate()` で作る)。★判定は面積を読まず、渡された `completion_rate` だけを使う★ —
 *   面積は「付いたまま届いても読まない」ことの確認のために付けてある。
 *
 * ★★「判定結果が変わったこと」を仕様として固定する★★ — 面積比に戻すと、ここが落ちる。
 * ★数値の出所★: 6-1 / 6-2 は 3-8 / 3-9 と同じくユーザー提供CSV `clean_task_reports.csv`(189行)の
 *   個別行の乖離例(面積比 13.8% ↔ `clean_ratio` 100.0% / 面積比 162.8% ↔ `clean_ratio` 61.0%)を借りた作り値。
 *   6-3 / 6-4 は2台構成の施設を想定した作り値。
 *
 * | # | 前回(clean_ratio / 面積) | 当日(clean_ratio / 面積) | 旧: 面積比 | 新: clean_ratio 平均 |
 * |---|---|---|---|---|
 * | 6-1 | 80 / 1628÷1000 | 80 / 1000÷1000 | 162.8 → 100 = 38.6%低下 ★急落★ | 80 → 80 = 0% ★急落しない★ |
 * | 6-2 | 100 / 138÷1000 | 61 / 1628÷1000 | 13.8 → 162.8 = 上昇 ★急落しない★ | 100 → 61 = 39%低下 ★急落★ |
 * | 6-3 | 90 / 900÷1000 | 90 / 900÷1000 + 50 / 10÷100 | 90 → 82.7 = 8.1%低下 ★急落しない★ | 90 → 70 = 22.2%低下 ★急落★ |
 * | 6-4 | 90 / 900÷1000 | 60 / 600÷1000 + 100 / 100÷100 | 90 → 63.6 = 29.3%低下 ★急落★ | 90 → 80 = 11.1%低下 ★急落しない★ |
 */
describe("表6: 面積比と1回ごとの完了率の平均で R-3 の結果が変わるケース", () => {
  const PREV = "2026-09-01";
  const TODAY = "2026-09-02";
  const judge = (rows: readonly RescueRowInput[]) =>
    judgeRescue({ rows, consecutive_problem_days: 0 });

  it("★6-1: 面積比では急落(162.8→100)だが、clean_ratio は 80→80 なので急落しない★", () => {
    const j = judge([
      doneWithArea(PREV, 80, 1628, 1000),
      doneWithArea(TODAY, 80, 1000, 1000),
    ]);
    expect(j.previous_completion_rate).toBe(80);
    expect(j.completion_rate).toBe(80);
    expect(j.drop_ratio).toBe(0);
    expect(j.reasons).not.toContain("completion_drop");
    expect(j.required).toBe(false);
  });

  it("★6-2: 面積比では上昇(13.8→162.8)だが、clean_ratio は 100→61 で急落★", () => {
    const j = judge([
      doneWithArea(PREV, 100, 138, 1000),
      doneWithArea(TODAY, 61, 1628, 1000),
    ]);
    expect(j.previous_completion_rate).toBe(100);
    expect(j.completion_rate).toBe(61);
    expect(j.drop_ratio).toBeCloseTo(0.39, 10);
    expect(j.reasons).toEqual(["completion_drop"]);
  });

  it("★6-3: 小面積の台が低率 — 面積加重では薄まって急落しないが、単純平均では急落★", () => {
    const j = judge([
      doneWithArea(PREV, 90, 900, 1000),
      doneWithArea(TODAY, 90, 900, 1000),
      doneWithArea(TODAY, 50, 10, 100, "03:00:00"),
    ]);
    expect(j.completion_rate).toBe(70);
    expect(j.drop_ratio).toBeCloseTo((90 - 70) / 90, 10);
    expect(j.reasons).toEqual(["completion_drop"]);
  });

  it("★6-4: 大面積の台が低率 — 面積加重では急落だが、単純平均では急落しない★", () => {
    const j = judge([
      doneWithArea(PREV, 90, 900, 1000),
      doneWithArea(TODAY, 60, 600, 1000),
      doneWithArea(TODAY, 100, 100, 100, "03:00:00"),
    ]);
    expect(j.completion_rate).toBe(80);
    expect(j.drop_ratio).toBeCloseTo((90 - 80) / 90, 10);
    // ★閾値は設定値から読む(ハードコードしない)★
    expect(j.drop_ratio as number).toBeLessThan(COMPLETION_DROP.dropRatio);
    expect(j.reasons).not.toContain("completion_drop");
  });
});