import { describe, expect, it } from "vitest";
import {
compareCleanLogItems,
consecutiveProblemDaysByFacilityDate,
facilityDateKey,
isUnscheduledReport,
scheduledCleanLogRowKey,
unscheduledCleanLogItem,
unscheduledCleanLogRowKey,
type UnscheduledReportLike,
type UnscheduledRowContext,
} from "@/lib/db/clean-log-mapping";
import {
unscheduledCleanLogRowsFromReports,
unscheduledCleanLogWhere,
type UnscheduledReportRow,
} from "@/lib/db/unscheduled-clean-log-source";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import type { CleanLogItem } from "@/lib/types";
/**
* 清掃詳細ログの予定外タスクの行(指示書BG §1・§2-1 / 表U1)— 純粋関数の部分。
* ルートを通した確認は `app/api/clean-logs/__tests__/route.test.ts` の「予定外タスクの行(指示書BG 表U1)」。
*
* 前提(表U1): 今 = 2026-10-29 10:00 JST。施設 F1・清掃エリア S1。
*
* | # | 行 | 期待 |
* |---|---|---|
* | U1-1 | 予定外・`started_at` 10-28 21:00・完了率 95.9 | `unscheduled` true・`id` null・`row_key` "rp-…"・`departed` true・◎・事前チェック null |
* | U1-2 | 予定外・`clean_area` 0 | 完了率 0・× ・出発 true(未実施ではない) |
* | U1-3 | `clean_timetable_id` = 0 と NULL | どちらも予定外として同じ扱い |
* | U1-4 | 予定外・`task_area` 0 | 完了率 0(今の `runCompletionRate()` と同じ) |
* | U1-5 | 予定外・削除済みの施設 | 出さない・数えない(SQL の `where` の可視性。ルートのテストで行も確かめる) |
* | U1-6 | 予定のある行 | 今と同じ(`unscheduled` false・`row_key` "tt-<id>") |
*/
const F1: UnscheduledRowContext = {
facility_id: 1,
facility_name: "テスト施設F1",
model_name: "PUDU CC1",
serial_number: "S1-ROBOT",
};
function report(overrides: Partial<UnscheduledReportLike> = {}): UnscheduledReportLike {
return {
report_id: "9200000000000000001",
clean_timetable_id: null,
status: 4,
task_area: 1000,
clean_area: 959,
clean_ratio: 95.9,
started_at_iso: "2026-10-28T21:00:00+09:00",
...overrides,
};
}
describe("unscheduledCleanLogItem — 表U1", () => {
it("U1-1: 予定外・started_at 10-28 21:00・完了率 95.9 → 1行の中身", () => {
expect(unscheduledCleanLogItem(report(), F1, 0)).toEqual<CleanLogItem>({
id: null,
row_key: "rp-9200000000000000001",
report_id: "9200000000000000001",
unscheduled: true,
facility_id: 1,
facility_name: "テスト施設F1",
model_name: "PUDU CC1",
serial_number: "S1-ROBOT",
scheduled_at: "2026-10-28T21:00:00+09:00",
status_1h: null,
status_1h_minutes: null,
status_30m: null,
status_30m_minutes: null,
departed: true,
task_status: 4,
result_pending: false,
completion_rate: 95.9,
grade: "◎",
consecutive_problem_days: 0,
escalation_level: null,
});
});
it("U1-2: clean_area 0 → 完了率 0・×・出発 true(未実施ではない)", () => {
const item = unscheduledCleanLogItem(report({ clean_area: 0, status: 6 }), F1, 1);
expect([item.completion_rate, item.grade, item.departed, item.task_status]).toEqual([0, "×", true, 6]);
expect(item.escalation_level).toBe("L1");
});
it("U1-3: clean_timetable_id が 0 と NULL は同じ扱い(どちらも予定外)", () => {
const fromNull = unscheduledCleanLogItem(report({ clean_timetable_id: null }), F1, 0);
const fromZero = unscheduledCleanLogItem(report({ clean_timetable_id: BigInt(0) }), F1, 0);
const fromNumberZero = unscheduledCleanLogItem(report({ clean_timetable_id: 0 }), F1, 0);
expect(fromZero).toEqual(fromNull);
expect(fromNumberZero).toEqual(fromNull);
expect(isUnscheduledReport({ clean_timetable_id: null })).toBe(true);
expect(isUnscheduledReport({ clean_timetable_id: BigInt(0) })).toBe(true);
expect(isUnscheduledReport({ clean_timetable_id: BigInt(103) })).toBe(false);
});
it("U1-3: 予定の id を持つレポートを渡したら例外(予定外の行に化けさせない)", () => {
expect(() => unscheduledCleanLogItem(report({ clean_timetable_id: BigInt(103) }), F1, 0)).toThrow();
});
it("U1-4: task_area 0 → 完了率 0", () => {
const item = unscheduledCleanLogItem(report({ task_area: 0, clean_area: 300 }), F1, 0);
expect([item.completion_rate, item.grade, item.departed]).toEqual([0, "×", true]);
});
it("U1-6: 予定のある行のキーは tt-<id>、予定外は rp-<report_id>(同じ数字でも重ならない)", () => {
expect(scheduledCleanLogRowKey(103)).toBe("tt-103");
expect(scheduledCleanLogRowKey(BigInt(103))).toBe("tt-103");
expect(unscheduledCleanLogRowKey("103")).toBe("rp-103");
});
});
describe("unscheduledCleanLogWhere — 予定外の行の SQL の条件", () => {
it("予定外(0・NULL)・削除済みを除く・started_at の期間(JST の壁時計値)・施設", () => {
const where = unscheduledCleanLogWhere({
id: null,
date_from: "2026-10-28",
date_to: "2026-10-28",
facility_ids: [1, 2],
});
expect(where).toEqual({
OR: [{ clean_timetable_id: null }, { clean_timetable_id: BigInt(0) }],
...FACILITY_SECTION_VISIBLE_WHERE,
// ★壁時計値(`Z` を付けた JST の 00:00)★ — `dbDateRange()` と同じ
started_at: {
gte: new Date("2026-10-28T00:00:00Z"),
lt: new Date("2026-10-29T00:00:00Z"),
},
facility_id: { in: [BigInt(1), BigInt(2)] },
});
});
it("U1-5: 削除済みの施設・清掃エリアを除く条件が必ず付く(期間・施設の指定が無くても)", () => {
const where = unscheduledCleanLogWhere({ id: null, date_from: null, date_to: null, facility_ids: null });
expect(where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
expect(where).not.toHaveProperty("started_at");
expect(where).not.toHaveProperty("facility_id");
});
it("★罠1★ facility_ids が [] なら in: [](0件)。null と混同しない", () => {
const where = unscheduledCleanLogWhere({ id: null, date_from: null, date_to: null, facility_ids: [] });
expect(where.facility_id).toEqual({ in: [] });
});
});
describe("unscheduledCleanLogRowsFromReports — 読んだ行の組み立て", () => {
function row(overrides: Partial<UnscheduledReportRow> = {}): UnscheduledReportRow {
return {
report_id: "9200000000000000001",
facility_id: BigInt(1),
section_id: BigInt(10),
clean_timetable_id: null,
status: 4,
task_area: 1000,
clean_area: 959,
clean_ratio: 95.9,
// ★DB の壁時計値(JST 21:00 を `Z` で持つ)★
started_at: new Date("2026-10-28T21:00:00Z"),
facility: { facility_name: "テスト施設F1" },
section: { robots: [{ serial_number: "S1-ROBOT", device: { maker_id: 1, model_name: "CC1" } }] },
...overrides,
};
}
it("started_at は jstIsoFromDb() で JST の ISO8601(9時間を足さない)・機種はメーカー名つき", () => {
const [r] = unscheduledCleanLogRowsFromReports([row()]);
expect(r.report.started_at_iso).toBe("2026-10-28T21:00:00+09:00");
expect(r.context).toEqual(F1);
expect(r.robot_count).toBe(1);
});
it("予定の id を持つ行は落とす(SQL の写しが外れていても混ぜない)・0 と NULL は残す", () => {
const rows = unscheduledCleanLogRowsFromReports([
row({ report_id: "a", clean_timetable_id: null }),
row({ report_id: "b", clean_timetable_id: BigInt(0) }),
row({ report_id: "c", clean_timetable_id: BigInt(103) }),
]);
expect(rows.map((r) => r.report.report_id)).toEqual(["a", "b"]);
});
it("清掃エリアに有効なロボットがいなければ機種・シリアルは null(予定のある行と同じ)", () => {
const [r] = unscheduledCleanLogRowsFromReports([row({ section: { robots: [] } })]);
expect([r.context.model_name, r.context.serial_number, r.robot_count]).toEqual([null, null, 0]);
});
});
describe("compareCleanLogItems — 予定のある行と予定外の行を混ぜて並べる", () => {
const base = unscheduledCleanLogItem(report(), F1, 0);
const scheduled = (id: number, at: string): CleanLogItem => ({
...base,
id,
row_key: scheduledCleanLogRowKey(id),
report_id: null,
unscheduled: false,
scheduled_at: at,
});
const unscheduled = (reportId: string, at: string): CleanLogItem => ({
...base,
row_key: unscheduledCleanLogRowKey(reportId),
report_id: reportId,
scheduled_at: at,
});
it("scheduled_at の新しい順。同着は予定のある行(id 昇順)→ 予定外(row_key 昇順)", () => {
const rows = [
unscheduled("2", "2026-10-28T21:00:00+09:00"),
scheduled(5, "2026-10-28T21:00:00+09:00"),
unscheduled("1", "2026-10-28T21:00:00+09:00"),
scheduled(3, "2026-10-28T21:00:00+09:00"),
unscheduled("9", "2026-10-28T23:00:00+09:00"),
scheduled(1, "2026-10-27T21:00:00+09:00"),
];
expect(rows.sort(compareCleanLogItems).map((r) => r.row_key)).toEqual([
"rp-9",
"tt-3",
"tt-5",
"rp-1",
"rp-2",
"tt-1",
]);
});
});
describe("consecutiveProblemDaysByFacilityDate — 予定外の行の連続日数を施設 × 日付で引く", () => {
const input = (
id: number,
facilityId: number,
date: string,
rate: number | null
) => ({
id,
unscheduled: false,
facility_id: facilityId,
scheduled_at: `${date}T21:00:00+09:00`,
completion_rate: rate,
departed: rate !== null,
clean_area: 0,
task_area: 0,
});
it("同じ施設の同じ日は全行同じ値(id で引く値と一致する)。施設ごとに独立", () => {
const rows = [
input(1, 1, "2026-10-26", 5),
input(2, 1, "2026-10-27", 50),
input(3, 1, "2026-10-28", 95),
input(4, 1, "2026-10-28", 30),
input(5, 2, "2026-10-28", 95),
];
const map = consecutiveProblemDaysByFacilityDate(rows);
expect(map.get(facilityDateKey(1, "2026-10-28T23:00:00+09:00"))).toBe(3);
expect(map.get(facilityDateKey(1, "2026-10-27"))).toBe(2);
expect(map.get(facilityDateKey(2, "2026-10-28"))).toBe(0);
expect(map.has(facilityDateKey(1, "2026-10-29"))).toBe(false);
});
});