import { describe, expect, it } from "vitest";
import {
CLEAN_TASK_REPORT_ID_DIGITS,
CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH,
CLEAN_TASK_REPORT_STATUS_DISTRIBUTION,
cleanTaskReportCleanArea,
cleanTaskReportId,
cleanTaskReportNullTimetableLogIds,
cleanTaskReportStatus,
cleanTaskReportTaskArea,
cleanTaskReports,
} from "@/lib/fixtures/clean-task-reports";
import { runCompletionRate } from "@/lib/judge";
import { boundaryCases, cleanLogs, escalationCases, statusCases } from "@/lib/fixtures/clean-logs";
/**
* `clean_task_reports` のサンプルデータ(docs/fixtures.md §2.6)が
* ★狙ったケースになっているか★ を固定するテスト。
*
* ★「落ちない」ことと「狙った数字になっている」ことは別★なのでここで固定する
* (`lib/fixtures/__tests__/areas.test.ts` と同じ考え方)。
*/
describe("fixtures: cleanTaskReports", () => {
it("未実施(completion_rate が null)の清掃には行を作らない(指示書AQ 1-1)", () => {
const notExecuted = cleanLogs.filter((log) => log.completion_rate === null);
// ★未実施のケースが fixtures から消えていないこと★(消えるとこのテストが無意味になる)
expect(notExecuted.length).toBeGreaterThan(0);
const reportedLogIds = new Set(
cleanTaskReports.map((report) => report.clean_timetable_id)
);
for (const log of notExecuted) {
expect(reportedLogIds.has(log.id), `未実施の id=${log.id} に行がある`).toBe(false);
}
});
it("実施済みの清掃はすべて1行持つ(clean_timetable_id を NULL にした行も含む)", () => {
const executed = cleanLogs.filter((log) => log.completion_rate !== null);
expect(cleanTaskReports).toHaveLength(executed.length);
});
it("report_id は19桁の数値文字列で重複しない(varchar(20))", () => {
for (const report of cleanTaskReports) {
expect(report.report_id).toMatch(/^\d+$/);
expect(report.report_id).toHaveLength(CLEAN_TASK_REPORT_ID_DIGITS);
}
expect(new Set(cleanTaskReports.map((r) => r.report_id)).size).toBe(
cleanTaskReports.length
);
});
it("clean_ratio は対応する清掃ログの completion_rate と同じ(境界値10ケースを壊さない)", () => {
const byId = new Map(cleanLogs.map((log) => [log.id, log]));
for (const report of cleanTaskReports) {
// ★NULL にした行は clean_timetable_id から引けない★ので report_id 側から引く
const logId = report.clean_timetable_id;
if (logId === null) continue;
expect(report.clean_ratio).toBe(byId.get(logId)?.completion_rate);
}
});
it("docs/fixtures.md §2.1 の境界値がすべて clean_ratio として存在する", () => {
const ratios = new Set(cleanTaskReports.map((r) => r.clean_ratio));
// ★未実施(null) は「行が無い」ことで表すので、ここには出てこない(上の1件目のテストが担保)★
for (const value of [90.1, 90.0, 89.5, 70.1, 70.0, 10.1, 10.0, 9.9, 0]) {
expect(ratios.has(value), `完了率 ${value} の行が無い`).toBe(true);
}
});
it("status は実測の4種(3 / 4 / 5 / 6)がすべて出る", () => {
const seen = new Set(cleanTaskReports.map((r) => r.status));
for (const { status } of CLEAN_TASK_REPORT_STATUS_DISTRIBUTION) {
expect(seen.has(status), `status=${status} の行が無い`).toBe(true);
}
expect([...seen].sort((a, b) => a - b)).toEqual([3, 4, 5, 6]);
});
it("status の巡回列はCSV実測189行の分布そのもの(3:2 / 4:161 / 5:4 / 6:22)", () => {
expect(CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH).toBe(189);
const counts: Record<number, number> = {};
for (let i = 0; i < CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH; i += 1) {
const status = cleanTaskReportStatus(i);
counts[status] = (counts[status] ?? 0) + 1;
}
for (const { status, count } of CLEAN_TASK_REPORT_STATUS_DISTRIBUTION) {
expect(counts[status], `status=${status}`).toBe(count);
}
});
it("status の巡回列は塊にならず散っている(先頭189件が全部 4 にならない)", () => {
const firstTwenty = Array.from({ length: 20 }, (_, i) => cleanTaskReportStatus(i));
expect(new Set(firstTwenty).size).toBeGreaterThan(1);
});
it("ended_at が null の行がある(落とし穴3。順序キーは ended_at ?? started_at)", () => {
const nullEnded = cleanTaskReports.filter((r) => r.ended_at === null);
expect(nullEnded.length).toBeGreaterThan(0);
// ★ended_at が null でも clean_ratio は入っている★(判定は壊れない)
for (const report of nullEnded) {
expect(report.clean_ratio).not.toBeNull();
// ★終了時刻が無い行の clean_time は 0★
expect(report.clean_time).toBe(0);
}
});
it("clean_timetable_id が null の行がある(落とし穴1。fail-silent の検証用)", () => {
const nullTimetable = cleanTaskReports.filter((r) => r.clean_timetable_id === null);
expect(nullTimetable.length).toBeGreaterThan(0);
expect(nullTimetable).toHaveLength(cleanTaskReportNullTimetableLogIds.size);
});
it("★境界値・エスカレーション・ステータスのケースには clean_timetable_id の NULL を当てない★", () => {
// NULL にすると「未実施」として集計され、§2.1 / §2.2 のケースが画面で確認できなくなる
const protectedIds = [...boundaryCases, ...escalationCases, ...statusCases].map(
(log) => log.id
);
for (const id of protectedIds) {
expect(
cleanTaskReportNullTimetableLogIds.has(id),
`id=${id}(境界値/エスカレーション/ステータスのケース)が NULL 対象になっている`
).toBe(false);
}
});
it("clean_time が 0 の行がある(実測に 0 の行がある)", () => {
expect(cleanTaskReports.some((r) => r.clean_time === 0)).toBe(true);
expect(cleanTaskReports.some((r) => r.clean_time > 0)).toBe(true);
});
it("task_area は清掃エリア(section_id)ごとに一定", () => {
const bySection = new Map<number, Set<number>>();
for (const report of cleanTaskReports) {
const set = bySection.get(report.section_id) ?? new Set<number>();
set.add(report.task_area);
bySection.set(report.section_id, set);
}
expect(bySection.size).toBeGreaterThan(1);
for (const [sectionId, values] of bySection) {
expect(values.size, `section_id=${sectionId} の task_area が一定でない`).toBe(1);
expect([...values][0]).toBe(cleanTaskReportTaskArea(sectionId));
}
});
it("task_area は清掃エリアごとに違う値になる(全エリア一律にしない)", () => {
const areas = new Set(cleanTaskReports.map((r) => r.task_area));
expect(areas.size).toBeGreaterThan(1);
});
// ★★D-155(指示書AS 段階6-1): 完了率は面積から `runCompletionRate()` で決まる★★ —
// ★旧: 「過半数の行で task_area を超える(実測 68.3%)」「clean_ratio は面積比と一致しない」
// 「面積比100%超で clean_ratio が低い行がある」の3件は、いまの仕様と逆なので削除した★
it("★全行で runCompletionRate(row) が対応する清掃ログの completion_rate と一致する★(D-155)", () => {
// ★`clean_timetable_id` を NULL にした行も含めるため `report_id`(元の清掃 id から作る)で引く★
const byReportId = new Map(cleanLogs.map((log) => [cleanTaskReportId(log.id), log]));
expect(cleanTaskReports.length).toBeGreaterThan(0);
for (const report of cleanTaskReports) {
const log = byReportId.get(report.report_id);
expect(log, `report_id=${report.report_id} の清掃ログが無い`).toBeDefined();
expect(runCompletionRate(report), `report_id=${report.report_id}`).toBe(log?.completion_rate);
}
});
it("clean_area は変動する(エリア・完了率ごとに違う値)", () => {
expect(new Set(cleanTaskReports.map((r) => r.clean_area)).size).toBeGreaterThan(1);
});
it("★完了率100の清掃は task_area を超える面積になる(上限100の確認)★", () => {
// ★fixtures の清掃ログには完了率100が無い(最大 99.5)ので、関数を直接確かめる★
const taskArea = 878.524992;
for (const id of [1, 2, 3, 101, 999]) {
const cleanArea = cleanTaskReportCleanArea(taskArea, 100, id);
expect(cleanArea).toBeGreaterThanOrEqual(taskArea);
expect(cleanArea).toBeLessThanOrEqual(taskArea * 1.4 + 1e-6);
expect(runCompletionRate({ clean_area: cleanArea, task_area: taskArea })).toBe(100);
}
});
it("started_at / ended_at は JST の ISO 8601(+09:00)", () => {
for (const report of cleanTaskReports) {
expect(report.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$/);
if (report.ended_at !== null) {
expect(report.ended_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$/);
expect(new Date(report.ended_at).getTime()).toBeGreaterThan(
new Date(report.started_at).getTime()
);
}
}
});
it("section_id と robot_id は同値(P1は1清掃エリア=1ロボット)", () => {
for (const report of cleanTaskReports) {
expect(report.section_id).toBe(report.robot_id);
}
});
});