Newer
Older
client_dwatch / lib / judge / __tests__ / kpi.test.ts
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");
  });
});