Newer
Older
client_dwatch / lib / fixtures / __tests__ / clean-logs.test.ts
import { describe, expect, it } from "vitest";
import { boundaryCases, cleanLogs } from "@/lib/fixtures";
import { PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { isProblem, judgeEscalationLevel, judgeGrade } from "@/lib/judge";

/**
 * 清掃詳細ログのサンプルデータ(docs/fixtures.md §2.1 / §2.2)が
 * ★判定ロジックと食い違っていないか★ を固定するテスト。
 *
 * ★なぜ要るか★ —
 *   `grade` と `escalation_level` は `row()` が本番の判定関数から導出するので
 *   自動で追従するが、★`consecutive_problem_days` だけは手書き★である。
 *   D-139(境界を「以上」に変更)のとき、完了率 70.0 の行が △ → ○ に変わったのに
 *   連続日数が `1` のまま残り、★`USE_FIXTURES` の切替で挙動が変わる★状態になっていた
 *   (判定 ○ なのに「連続1日・L1」と出る)。★例外もテスト失敗も出なかった★ため
 *   ここで固定する(指示書AM 段階1)。
 */

describe("fixtures: cleanLogs", () => {
  it("★完了率70.0の境界行は 判定○ かつ 連続0 かつ エスカレーションなし★(D-139)", () => {
    const row = boundaryCases.find((r) => r.completion_rate === 70.0);
    expect(row, "完了率70.0の境界行が boundaryCases に無い").toBeDefined();
    expect(row?.grade).toBe("○");
    expect(row?.consecutive_problem_days).toBe(0);
    expect(row?.escalation_level).toBeNull();
  });

  it.each([
    { rate: 90.1, grade: "◎" },
    { rate: 90.0, grade: "◎" },
    { rate: 89.5, grade: "○" },
    { rate: 70.1, grade: "○" },
    { rate: 70.0, grade: "○" },
    { rate: 10.1, grade: "△" },
    { rate: 10.0, grade: "△" },
    { rate: 9.9, grade: "×" },
    { rate: 0, grade: "×" },
    { rate: null, grade: "×" },
  ])("境界値: 完了率 $rate の行は $grade", ({ rate, grade }) => {
    const row = boundaryCases.find((r) => r.completion_rate === rate);
    expect(row, `完了率${rate}の境界行が boundaryCases に無い`).toBeDefined();
    expect(row?.grade).toBe(grade);
  });

  it("★手書きの連続日数が判定と矛盾していない★(問題ありなら1以上、問題なしなら0)", () => {
    const mismatched = cleanLogs.filter((r) => {
      // ★結果待ち(D-173)は問題日に数えない(連続日数の入力から外す。表2 C10)ので 0★
      if (r.result_pending) return r.consecutive_problem_days !== 0;
      const problem = isProblem({
        departed: r.departed,
        completionRate: r.completion_rate,
      });
      return problem
        ? r.consecutive_problem_days < 1
        : r.consecutive_problem_days !== 0;
    });
    expect(
      mismatched.map((r) => ({
        id: r.id,
        facility_id: r.facility_id,
        scheduled_at: r.scheduled_at,
        completion_rate: r.completion_rate,
        grade: r.grade,
        consecutive_problem_days: r.consecutive_problem_days,
      }))
    ).toEqual([]);
  });

  it("★結果待ち(D-173。表2 C13)★: 施設11 今日 03:30 の1行だけが true。値は出発失敗のまま", () => {
    const pending = cleanLogs.filter((r) => r.result_pending);
    expect(pending).toHaveLength(1);
    const [r] = pending;
    expect(r.facility_id).toBe(11);
    expect(r.scheduled_at.slice(11, 16)).toBe("03:30");
    // ★不変条件(`departed === false` ⟺ `completion_rate === null`)を壊さない★
    expect(r).toMatchObject({ departed: false, task_status: null, completion_rate: null, grade: "×" });
    // ★既定は false(`departed: true` の行・確定した出発失敗)★
    for (const other of cleanLogs) {
      if (other !== r) expect(other.result_pending).toBe(false);
    }
    // ★施設12 03:30(§2.1 の「未実施(null)」)は確定した出発失敗のまま(監督判断 2026-10-07)★
    const confirmed = cleanLogs.find(
      (x) => x.facility_id === 12 && x.scheduled_at === r.scheduled_at
    );
    expect(confirmed).toMatchObject({ departed: false, result_pending: false, consecutive_problem_days: 1 });
  });

  it("grade / escalation_level は本番の判定関数と一致する(fixture専用の判定を書かない。D-044)", () => {
    for (const r of cleanLogs) {
      expect(r.grade).toBe(judgeGrade(r.completion_rate));
      expect(r.escalation_level).toBe(
        judgeEscalationLevel(r.consecutive_problem_days)
      );
    }
  });

  // ★事前チェックの無い施設の清掃(status_1h / status_30m が null)★(ユーザー指示 2026-10-07。D-171)
  //   ★null は「事前チェックの予定が無い」★。画面は「-」、CSV は空のセル。
  //   ★既定値「待機中」は変えない★(fixtures は事前チェックがある前提の行)ので、null の行は明示した行だけ
  it("★事前チェックの無い施設(川下店)の行は status_1h / status_30m が null★(D-171)", () => {
    const noPrecheck = cleanLogs.filter((r) => r.facility_name === "バリューマート川下店");
    expect(noPrecheck.length).toBeGreaterThan(0);
    for (const r of noPrecheck) {
      expect(r.status_1h).toBeNull();
      expect(r.status_30m).toBeNull();
    }
  });

  it("★null の行はその施設だけ★(ほかの行は文字列。既定値「待機中」のまま。★事前チェック1回だけの1行(D-178)は2回目だけ null★)", () => {
    const others = cleanLogs.filter((r) => r.facility_name !== "バリューマート川下店");
    for (const r of others) {
      expect(typeof r.status_1h).toBe("string");
      if (r.id === onlyOnePrecheck().id) continue;
      expect(typeof r.status_30m).toBe("string");
    }
  });

  // ★事前チェックの分数(D-178。ユーザー判断 2026-10-16「案1」)★ — 列は分数ではなく順番で割り当てる
  /** 施設7 ロボット0177 の昨日 02:00(事前チェックが30分前の1回だけ) */
  function onlyOnePrecheck() {
    const r = cleanLogs.find((x) => x.serial_number === "8111R5628060177" && x.scheduled_at.slice(11, 16) === "02:00");
    if (!r) throw new Error("事前チェック1回だけの行(施設7 ロボット0177 02:00)が無い");
    return r;
  }

  it("★D-178: 60分前・10分前の行(施設7 ロボット0100 01:30)は 60 / 10★", () => {
    const r = cleanLogs.find((x) => x.serial_number === "8111R5628060100" && x.scheduled_at.slice(11, 16) === "01:30");
    expect(r).toMatchObject({ status_1h_minutes: 60, status_30m_minutes: 10 });
    expect(typeof r?.status_30m).toBe("string");
  });

  it("★D-178: 事前チェックが30分前の1回だけの行は 1回目 30・2回目 null★", () => {
    expect(onlyOnePrecheck()).toMatchObject({
      status_1h: "待機中",
      status_1h_minutes: PRECHECK_MINUTES_BEFORE[1],
      status_30m: null,
      status_30m_minutes: null,
    });
  });

  it("★不変条件: status が null のときだけ分数も null(全行)★(D-178)", () => {
    for (const r of cleanLogs) {
      expect(r.status_1h === null).toBe(r.status_1h_minutes === null);
      expect(r.status_30m === null).toBe(r.status_30m_minutes === null);
    }
  });

  it("★D-178 の2行以外は、分数が設定どおり(60 / 30)★(既存の行の見え方を変えていない)", () => {
    const changed = new Set(["8111R5628060100|01:30", "8111R5628060177|02:00"]);
    const daysBack1 = onlyOnePrecheck().scheduled_at.slice(0, 10);
    for (const r of cleanLogs) {
      const key = `${r.serial_number}|${r.scheduled_at.slice(11, 16)}`;
      if (changed.has(key) && r.scheduled_at.slice(0, 10) === daysBack1) continue;
      if (r.status_1h !== null) expect(r.status_1h_minutes).toBe(PRECHECK_MINUTES_BEFORE[0]);
      if (r.status_30m !== null) expect(r.status_30m_minutes).toBe(PRECHECK_MINUTES_BEFORE[1]);
    }
  });
});