import { describe, expect, it } from "vitest";
import {
displayRate,
errorRate,
isCompletedTask,
judgeFacilityStatus,
judgeGrade,
launchRate,
meanCleanRatio,
operationRate,
toKpiValue,
} from "@/lib/judge";
import { CLEAN_TASK_STATUS } from "@/lib/config";
/**
* KPI4指標の期待値表テスト(CLAUDE.md 4.2)
*
* 稼働率 = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100 ★第11回 P-6=A★
* 出発成功率 = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100 ★時刻の条件なし(P-6=A)★
* 清掃完了率 = Σ 1回ごとの完了率 ÷ 件数 ★1回ごとの完了率の単純平均(清掃1回=1票)★
* (★期間の面積比は廃止。指示書AQ 決定4 / §5-1。D-155 で材料が `clean_ratio` 列から
* `runCompletionRate()` に変わった★)
* エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100 ★分母は予定回数(P-6=B)。上限で切らない★
*
* ★3つの率はサマリ(`operation_ratio` / `launch_ratio` / `error_ratio`)と同じ式(第11回 P-6。D-158)★。
* 旧: 稼働率 = 実施回数 ÷ 予定・出発成功率 = 定刻に出発した回数 ÷ 予定・エラー発生率 = エラー ÷ 実施回数
*/
describe("operationRate(稼働率)", () => {
it("正常終了の回数 ÷ 清掃予定回数(P-6=A)", () => {
expect(operationRate(141, 145)).toBeCloseTo(97.2413, 3);
expect(operationRate(100, 100)).toBe(100);
expect(operationRate(0, 100)).toBe(0);
});
it("分母が0なら null(0%ではない)", () => {
// 「予定が1件もない期間」と「予定はあったが全部失敗」を区別する
expect(operationRate(0, 0)).toBeNull();
});
});
describe("launchRate(出発成功率)", () => {
it("開始できた回数 ÷ 清掃予定回数(時刻の条件なし。P-6=A)", () => {
// 予定100件・開始できた(レポート行がある)85件 → 85/100
expect(launchRate(85, 100)).toBe(85);
});
it("分母が0なら null", () => {
expect(launchRate(0, 0)).toBeNull();
});
});
/**
* ★清掃完了率の畳み方(meanCleanRatio)の期待値表★(指示書AQ 決定4 / §5-1)
*
* | # | 入力(Σ1回ごとの完了率 / 件数) | 期待 | 何を固定するか |
* |---|---|---|---|
* | m-1 | 87 / 1 | 87 | 1件はその値のまま |
* | m-2 | 150 / 2(100% と 50%) | 75 | 単純平均 |
* | m-3 | 90.4 / 1 | 90.4(表示90% だが判定は ◎) | ★丸めない★(§4.3) |
* | m-4 | 89.5 / 1 | 89.5(表示90% だが判定は ○) | ★丸めない★(§9.4) |
* | m-5 | 0 / 1 | 0 | 「0%で清掃した」は 0(null にしない) |
* | m-6 | 0 / 0 | null | ★件数0は率を出さない(0% にしない)★ |
* | m-7 | 180 / 2 | 90(★900 にしない★) | ★`× 100` しない(1回ごとの完了率は既に百分率)★ |
*/
describe("meanCleanRatio(清掃完了率=1回ごとの完了率の単純平均)", () => {
const mean = (sum: number, count: number) =>
meanCleanRatio({ cleanRatioSum: sum, cleanRatioCount: count });
it("m-1: 1件はその値のまま", () => {
expect(mean(87, 1)).toBe(87);
});
it("m-2: 100% と 50% の2件 → 75(清掃1回=1票)", () => {
expect(mean(150, 2)).toBe(75);
});
it("★m-3: 90.4 は生値のまま(表示すると90%だが、判定は◎)★", () => {
const r = mean(90.4, 1);
expect(r).toBe(90.4);
expect(displayRate(r)).toBe("90%");
expect(judgeGrade(r)).toBe("◎");
});
it("★m-4: 89.5 は生値のまま(表示すると90%だが、判定は○)★", () => {
const r = mean(89.5, 1);
expect(r).toBe(89.5);
expect(displayRate(r)).toBe("90%");
expect(judgeGrade(r)).toBe("○");
});
it("m-5: 0% で実施した1件は 0(null にしない)", () => {
expect(mean(0, 1)).toBe(0);
});
it("★m-6: 件数0は null(0%ではない)★", () => {
expect(mean(0, 0)).toBeNull();
});
it("★m-7: 百分率をもう一度100倍しない(180/2 は 90。900 ではない)★", () => {
expect(mean(180, 2)).toBe(90);
});
});
describe("errorRate(エラー発生率)", () => {
it("★分母は清掃予定回数(P-6=B)★", () => {
// 予定100件・開始90件・エラー9件 → 9/100 = 9% であって 9/90 = 10% ではない
expect(errorRate(9, 100)).toBe(9);
});
it("予定が0なら null", () => {
expect(errorRate(0, 0)).toBeNull();
});
it("★上限で切らない(D9)★: 予定3・エラー4 → 133.33…", () => {
expect(errorRate(4, 3)).toBeCloseTo(400 / 3, 10);
});
});
describe("isCompletedTask(正常終了か。稼働率の分子。P-6=A)", () => {
it("レポート行が無い(undefined)は false", () => {
expect(isCompletedTask(undefined)).toBe(false);
});
it("status が正常終了なら true", () => {
expect(isCompletedTask({ status: CLEAN_TASK_STATUS.completed })).toBe(true);
});
it.each([0, 1, 2, 3, 5, 6, 255])("status %d は false", (status) => {
expect(isCompletedTask({ status })).toBe(false);
});
});
describe("toKpiValue(前期比)", () => {
it("差分を計算する", () => {
const v = toKpiValue(91.2, 89.5);
expect(v.value).toBe(91.2);
expect(v.prev).toBe(89.5);
expect(v.diff).toBeCloseTo(1.7, 5);
});
it("悪化しているケースは負の差分", () => {
expect(toKpiValue(85.0, 90.0).diff).toBe(-5);
});
it("前期データなし(新規施設)は prev / diff とも null", () => {
const v = toKpiValue(86.0, null);
expect(v.prev).toBeNull();
expect(v.diff).toBeNull();
});
});
describe("judgeFacilityStatus(良好/注意/要改善/判定なし)", () => {
it("稼働率ベースの暫定判定", () => {
expect(judgeFacilityStatus(96.3)).toBe("good");
expect(judgeFacilityStatus(90.0)).toBe("good"); // 境界(以上)
expect(judgeFacilityStatus(89.9)).toBe("warning");
expect(judgeFacilityStatus(75.0)).toBe("warning"); // 境界(以上)
expect(judgeFacilityStatus(74.9)).toBe("bad");
});
it("0%は「判定なし」ではない(実施予定があって1回も実施できていない=要改善)", () => {
expect(judgeFacilityStatus(0)).toBe("bad");
});
// 決定1(2026-09-11 ユーザー確定。docs/decisions.md D-109): 分母0(=その期間に
// 清掃予定が1件もない。週の初め・月の初めに常時発生する。D-106)は「要改善」ではなく
// 「判定なし」に変えた。以前は null を "bad" に寄せていたため、週の初めに全施設が
// 赤い「要改善」バッジになる不具合があった。
it("null(清掃予定が1件もない期間)は判定なしにする(決定1)", () => {
expect(judgeFacilityStatus(null)).toBe("no_data");
});
});