Newer
Older
client_dwatch / lib / judge / __tests__ / run-completion-rate.test.ts
/**
 * 1回ごとの完了率 `runCompletionRate()` と、その平均の期待値表(指示書AS §4 表1・表2。D-155 / D-156。
 * ★R9 の差し替えと R12 は指示書AU §3 表1。D-158★)。
 *
 * ★★本書(`docs/handoff/指示書AS_完了率を先方の式に合わせる_20261002.md`)§4 と
 *   `docs/handoff/指示書AU_第11回の回答を反映する_20261005.md` §3 が正。変えないこと★★
 *   (`CLAUDE.md` §0-2・§6-2・§9.4)。表とテストが食い違ったら、テストを表に合わせる。
 *   ★実装の都合で表を直さない★。
 *
 * 式(D-155 項1。TeamWorks 第10回 N-3 のサマリと同じ。★第11回で確定。D-158★):
 *   - 1回ごとの完了率 = `min(clean_area ÷ task_area × 100, 100)` を ★小数第1位まで切り捨て★
 *   - 複数回の完了率 = 1回ごとの完了率の ★単純平均★(丸めない。判定は生値、表示だけ整数に丸める)
 *   - `task_area = 0` は 0(P-5=B。第11回)。旧: null(分子にも分母にも入れない)
 *
 * ### 表1 — 1回ごとの完了率 `runCompletionRate`
 *
 * | # | clean_area | task_area | 期待値 | 確かめること |
 * |---|---|---|---|---|
 * | R1 | 50 | 100 | 50.0 | 基本 |
 * | R2 | 100 | 100 | 100.0 | ちょうど100 |
 * | R3 | 150 | 100 | 100.0 | 上限で100に切る |
 * | R4 | 89.99 | 100 | 89.9 | 四捨五入ではなく切り捨て |
 * | R5 | 89.95 | 100 | 89.9 | 切り捨て(四捨五入なら90.0) |
 * | R6 | 0.57 | 1 | 57.0 | 浮動小数の誤差(56.99999…)で56.9にしない |
 * | R7 | 0.7 | 1 | 70.0 | 同上 |
 * | R8 | 0 | 100 | 0.0 | 清掃面積0 |
 * | R9 | 10 | 0 | 0.0 | 対象面積0は 0%(P-5=B)。旧: null |
 * | R10 | 1377.39502 | 1377.39502 | 100.0 | 実データの桁 |
 * | R11 | 898.892509 | 1377.39502 | 65.2 | 65.26…を切り捨て |
 * | R12 | 0 | 0 | 0.0 | 両方0も 0% |
 *
 * ### 表2 — 平均(判定は生値の平均で行う。表示は Math.round)
 *
 * | # | 1回ごとの値 | 平均(生値) | 表示 | 判定 |
 * |---|---|---|---|---|
 * | M1 | [100.0, 80.0] | 90.0 | 90% | ◎ |
 * | M2 | [89.9, 90.0] | 89.95 | 90% | ○(表示は90だが判定は○。§4.3) |
 * | M3 | [100.0, 40.0] | 70.0 | 70% | ○ |
 * | M4 | [70.0, 69.9] | 69.95 | 70% | △ |
 * | M5 | [10.0] | 10.0 | 10% | △ |
 * | M6 | [9.9] | 9.9 | 10% | × |
 * | M7 | [null, 50.0] | 50.0 | 50% | △(nullは数えない) |
 * | M8 | [null] / [] | null | — | 未実施と同じ扱い(×・出発失敗の表示は既存どおり) |
 * | M9 | [150/100→100.0, 150/100→100.0, 0/100→0.0] | 66.666… | 67% | △ |
 *
 * 書き方(本書 §4 の注意):
 *   - 平均は ★`meanCleanRatio()` に Σ と件数を渡して出す★(テストの中で自前の割り算を書かない)。
 *     null は Σ にも件数にも入れない
 *   - 「表示」は `displayRate()`。★M8 の「—」は `displayRate(null)` の `"-"`★
 *   - 「判定」は `judgeGrade(平均の生値)`。★M8 は `judgeGrade(null)` = `×`★
 *   - M9 は ★面積から `runCompletionRate()` を通して1回ごとの値を作ってから★平均する
 *   - 生値の比較は `toBe`。M9 だけ `toBeCloseTo(200 / 3, 10)`
 */
import { describe, expect, it } from "vitest";
import {
  displayRate,
  judgeGrade,
  meanCleanRatio,
  runCompletionRate,
  type CleanRatioTally,
} from "@/lib/judge";
import type { Grade } from "@/lib/types";

/** null を数えずに Σ と件数を積む(★割り算は `meanCleanRatio()` に任せる★) */
function tallyOf(rates: ReadonlyArray<number | null>): CleanRatioTally {
  let cleanRatioSum = 0;
  let cleanRatioCount = 0;
  for (const r of rates) {
    if (r === null) continue;
    cleanRatioSum += r;
    cleanRatioCount += 1;
  }
  return { cleanRatioSum, cleanRatioCount };
}

describe("表1 — runCompletionRate(1回ごとの完了率)", () => {
  it.each<[string, number, number, number]>([
    ["R1 基本", 50, 100, 50.0],
    ["R2 ちょうど100", 100, 100, 100.0],
    ["R3 上限で100に切る", 150, 100, 100.0],
    ["R4 四捨五入ではなく切り捨て", 89.99, 100, 89.9],
    ["R5 切り捨て(四捨五入なら90.0)", 89.95, 100, 89.9],
    ["R6 浮動小数の誤差(56.99999…)で56.9にしない", 0.57, 1, 57.0],
    ["R7 同上", 0.7, 1, 70.0],
    ["R8 清掃面積0", 0, 100, 0.0],
    ["R9 対象面積0は 0%(P-5=B)", 10, 0, 0.0],
    ["R10 実データの桁", 1377.39502, 1377.39502, 100.0],
    ["R11 65.26…を切り捨て", 898.892509, 1377.39502, 65.2],
    ["R12 両方0も 0%", 0, 0, 0.0],
  ])("%s: clean_area=%d / task_area=%d → %s", (_name, cleanArea, taskArea, expected) => {
    expect(runCompletionRate({ clean_area: cleanArea, task_area: taskArea })).toBe(expected);
  });

  it("★`clean_ratio` 列を渡しても読まない★(行をそのまま渡す作法。D6)", () => {
    const row = { clean_area: 50, task_area: 100, clean_ratio: 12.3 };
    expect(runCompletionRate(row)).toBe(50.0);
  });
});

describe("表2 — 平均(判定は生値、表示は Math.round)", () => {
  it.each<[string, ReadonlyArray<number | null>, number | null, string, Grade]>([
    ["M1", [100.0, 80.0], 90.0, "90%", "◎"],
    ["M2 表示は90だが判定は○(§4.3)", [89.9, 90.0], 89.95, "90%", "○"],
    ["M3", [100.0, 40.0], 70.0, "70%", "○"],
    ["M4", [70.0, 69.9], 69.95, "70%", "△"],
    ["M5", [10.0], 10.0, "10%", "△"],
    ["M6", [9.9], 9.9, "10%", "×"],
    ["M7 nullは数えない", [null, 50.0], 50.0, "50%", "△"],
    ["M8 [null] 未実施と同じ扱い", [null], null, "-", "×"],
    ["M8 [] 未実施と同じ扱い", [], null, "-", "×"],
  ])("%s: %j → 平均 %s / 表示 %s / 判定 %s", (_name, rates, mean, display, grade) => {
    const actual = meanCleanRatio(tallyOf(rates));
    expect(actual).toBe(mean);
    expect(displayRate(actual)).toBe(display);
    expect(judgeGrade(actual)).toBe(grade);
  });

  it("M9: 面積から runCompletionRate() を通して作った [100.0, 100.0, 0.0] → 66.666… / 67% / △", () => {
    const runs = [
      runCompletionRate({ clean_area: 150, task_area: 100 }),
      runCompletionRate({ clean_area: 150, task_area: 100 }),
      runCompletionRate({ clean_area: 0, task_area: 100 }),
    ];
    expect(runs).toEqual([100.0, 100.0, 0.0]);
    const actual = meanCleanRatio(tallyOf(runs));
    expect(actual).toBeCloseTo(200 / 3, 10);
    expect(displayRate(actual)).toBe("67%");
    expect(judgeGrade(actual)).toBe("△");
  });
});

// ★表4(総清掃面積の施設1件ぶんの集計)は段階4 で書いた★:
//   `lib/db/__tests__/clean-area-source.test.ts`「表4(指示書AS)」(`cleanAreaStatsFromRows()` → `aggregateCleanArea()`)

// ★表5(未実施の数え方。【未決1】案A)は段階3-4 で書いた★:
//   R-1 … `lib/judge/__tests__/rescue.test.ts`「表5(指示書AS)」
//   出発失敗の日 … `lib/db/__tests__/report-mapping.test.ts`「launchFailedDaysInRun — 表5」
//   `departed` の詰め方 … `lib/db/__tests__/consecutive-source.test.ts`「表5 U1〜U4」
//   連続日数の問題日(`isProblemCompletion()` は変えない)… `lib/db/__tests__/clean-log-mapping.test.ts`
//     「isProblemCompletion — 表5」