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]);
}
});
});