/**
* 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」