import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
// 最初の1本だけがモジュール読み込みを丸ごと抱え込み、フルスイート実行時に既定の
// タイムアウトを踏んで不定期に落ちる(`app/api/clean-logs/__tests__/route.test.ts` の実測)。
import { GET } from "@/app/api/prechecks/route";
import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { filterFixturePrechecks } from "@/lib/db/fixture-prechecks";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { logEvent } from "@/lib/logger";
import {
filterPrechecksByDate,
parsePrecheckQuery,
type PrecheckQuery,
type PrecheckSummary,
} from "@/lib/prechecks/query";
import type { PrecheckDbQuery, PrecheckItem, SummaryListResponse } from "@/lib/types";
/**
* 指示書AC ★段階4★ の期待値表(#1〜#5)。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* `prechecks()` が返すのは ★事前チェック異常の行(`severity: "precheck"`)だけ★(ユーザー判断 2026-10-29)。
* ★期間の基準は**親の本清掃行**の `start_at`★(`PrecheckDbQuery` の型コメント)。
* ★#4 で「親が期間外・エラー行が期間内」★ を固定する。
* ★出発失敗(レポートの無い過去の本清掃)は行にならない★(旧: `severity: "launch_failed"`)—
* 「出発失敗はアラートに出ない」の describe と、清掃詳細ログ側(`app/api/clean-logs/__tests__/route.test.ts`)で固定する。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
* `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
* ★`prismaSource` の経路★ を走らせる(段階2 のテストと同じ作法)。
*
* ★★合成データを使う理由★★: ローカルDB(`dwatch_local`)では
* - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★(C-6)
* - ★事前チェック行の親が日をまたぐケース(#4)が1件も無い★
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface StubTimetable {
id: bigint;
facility_id: bigint;
section_id: bigint;
parent_id: bigint;
start_at: Date;
/** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */
end_at: Date;
precheck: number;
facility: { facility_name: string };
section: { robots: { serial_number: string }[] };
}
/**
* `clean_task_reports` の1行(★旧: `clean_status_histories`。指示書AQ 決定2 / 決定3★)。
* ★`clean_error` 列は無い★(`prisma/schema.prisma` の `CleanTaskReport`)。
* ★`ended_at` は nullable、`started_at` は NOT NULL★(順序キー。指示書AQ §1-4)。
*/
interface StubReport {
clean_timetable_id: bigint | null;
clean_ratio: number;
/** ★完了率は面積から出す★(`runCompletionRate()`。D-155)。`main_run_succeeded`(D-171)の判定に使う */
clean_area: number;
task_area: number;
started_at: Date;
ended_at: Date | null;
}
interface StubErrorReport {
error_id: string;
clean_timetable_id: bigint | null;
error_type: string;
errored_at: Date | null;
created_at: Date;
serial_number: string;
facility_id: bigint;
/** ★`deleted_at` は可視性の判定用(指示書BB U1。`ROBOT_ERROR_PRECHECK_ROW_SELECT` が読む)★ */
facility: { facility_name: string; deleted_at: Date | null };
section: { deleted_at: Date | null };
status: number;
responded_at: Date | null;
resolved_at: Date | null;
comment: string | null;
/** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
robot_id?: bigint | null;
}
interface StubData {
timetables: StubTimetable[];
/** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */
historyTimetables?: StubTimetable[];
reports: StubReport[];
errorReports: StubErrorReport[];
}
/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */
const calls: {
cleanTimetable: Record<string, unknown>[];
cleanTimetableHistory: Record<string, unknown>[];
cleanTaskReport: Record<string, unknown>[];
robotErrorReport: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [] };
let data: StubData;
/**
* ★true のあいだ、エラー行の `where` を無視して全行を返す★(指示書BB 表3 S2 / 表1 V7)—
* SQL の写し(`status IN`・可視性)が外れても、純粋関数の側(正)で外れることを確かめる
*/
let ignoreErrorWhere = false;
/** BigInt / number / null をまたいで id を比較する(`===` では比較できない) */
function sameId(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) return false;
return String(a) === String(b);
}
/**
* Prisma の `where` のうち、★本作業が使う分だけ★ を再現する。
*
* ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★(★罠1★)。
* ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。
*/
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
if (!where) return true;
for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
const value = row[field];
if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
const c = cond as Record<string, unknown>;
if ("in" in c) {
if (c.in === undefined) {
throw new Error(
`[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
);
}
if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
}
if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
} else if (typeof cond === "bigint" || typeof value === "bigint") {
if (!sameId(value, cond)) return false;
} else if (value !== cond) {
return false;
}
}
return true;
}
function project(row: Record<string, unknown>, select: unknown): Record<string, unknown> {
if (!select) return row;
const out: Record<string, unknown> = {};
for (const key of Object.keys(select as Record<string, unknown>)) out[key] = row[key];
return out;
}
function findMany(
bucket: keyof typeof calls,
rows: Record<string, unknown>[],
args: Record<string, unknown> | undefined
) {
calls[bucket].push(args ?? {});
return rows
.filter((row) => matchesWhere(row, args?.where))
.map((row) => project(row, args?.select));
}
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
// ★`data.anomaly`(指示書BB U2・U3)を見るため `logEvent` だけ差し替える(`withRequestId` はそのまま)★
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
vi.mock("@/lib/db/prisma", () => ({
getPrisma: async () => ({
cleanTimetable: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
},
// ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★
cleanTimetableHistory: {
findMany: async (args?: Record<string, unknown>) =>
findMany(
"cleanTimetableHistory",
(data.historyTimetables ?? []) as unknown as Record<string, unknown>[],
args
),
},
// ★清掃結果の取得元は `clean_task_reports`★(旧: `cleanStatusHistory`。指示書AQ 決定2 / 決定3)
cleanTaskReport: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
},
robotErrorReport: {
findMany: async (args?: Record<string, unknown>) => {
// ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★
guardNullRobotId(data.errorReports as unknown as Record<string, unknown>[], args);
if (ignoreErrorWhere) {
// ★`clean_timetable_id IN` だけ残し、それ以外(`status IN`・可視性)を無視する★
const where = (args?.where ?? {}) as Record<string, unknown>;
return findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], {
...args,
where: { clean_timetable_id: where.clean_timetable_id },
});
}
return findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], args);
},
},
}),
}));
// ---------------------------------------------------------------- 合成データ
/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
function dbWallClock(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
/** ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
const FACILITY_1 = "バリューマート北町店";
const FACILITY_2 = "メガバリューマート緑野店";
const [FIRST_ROUND_MINUTES, SECOND_ROUND_MINUTES] = PRECHECK_MINUTES_BEFORE;
function timetable(
id: number,
facilityId: number,
facilityName: string,
jstIso: string,
precheck: number = CLEAN_TIMETABLE_PRECHECK.main,
parentId = 0
): StubTimetable {
return {
id: BigInt(id),
facility_id: BigInt(facilityId),
section_id: BigInt(facilityId * 10),
parent_id: BigInt(parentId),
start_at: dbWallClock(jstIso),
end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
precheck,
facility: { facility_name: facilityName },
section: { robots: [{ serial_number: `8111R56280600${facilityId}` }] },
};
}
/**
* ★レポートの行がある = 実施済み★(指示書AQ §1-1。★行が無いときだけ未実施=出発失敗★)。
* ★`status` は持たせない★ — 値の意味は受領済み(第10回 N-2)だが、判定には使わない(D-155 項2。指示書AQ §1-2)。
*/
function report(
timetableId: number,
startedJst: string,
endedJst: string | null,
// ★既定は 80%(○)★ — 判定が成功になる値(D-171 の `main_run_succeeded`)
cleanArea = 80,
taskArea = 100
): StubReport {
return {
clean_timetable_id: BigInt(timetableId),
clean_ratio: 80,
clean_area: cleanArea,
task_area: taskArea,
started_at: dbWallClock(startedJst),
ended_at: endedJst === null ? null : dbWallClock(endedJst),
};
}
function errorReport(
errorId: string,
timetableId: number,
facilityId: number,
facilityName: string,
erroredAtJst: string
): StubErrorReport {
return {
error_id: errorId,
clean_timetable_id: BigInt(timetableId),
error_type: "EmergencyStop",
errored_at: dbWallClock(erroredAtJst),
created_at: dbWallClock(erroredAtJst),
serial_number: `8111R56280600${facilityId}`,
facility_id: BigInt(facilityId),
facility: { facility_name: facilityName, deleted_at: null },
section: { deleted_at: null },
// ★0 = 未解消★(`resolutionStatusFromRobotErrorStatus()`)
status: 0,
responded_at: null,
resolved_at: null,
comment: null,
};
}
/**
* ★★#4 の肝★★: `104` は ★9/11 00:30★ の本清掃で、その事前チェック行 `1041` と
* そのエラー行は ★9/10 23:30 / 23:35★(= ★日をまたぐ★)。
* ★★`errored_at` や事前チェック行の `start_at` で絞ると 9/10 に現れてしまう★★。
*/
function buildData(): StubData {
return {
timetables: [
// --- 本清掃行 ---
// ★レポートなし(=出発失敗)★ — ★アラートには出ない★(2026-10-29。清掃詳細ログには出発失敗として出る)
timetable(101, 1, FACILITY_1, "2026-09-08T02:00:00+09:00"),
timetable(102, 1, FACILITY_1, "2026-09-09T02:00:00+09:00"), // 正常
// ★レポートなし(=出発失敗)★ — アラートには出ない
timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"),
timetable(104, 1, FACILITY_1, "2026-09-11T00:30:00+09:00"), // 正常(#4 の親)
// ★★壁時計 20:00 の清掃★★(C-6。★ローカルDBには1件も無い時間帯★)。レポートなし(出発失敗)だが、事前チェックのエラー E5 の親
timetable(301, 2, FACILITY_2, "2026-09-10T20:00:00+09:00"),
// ★未来の予定★(レポートなし)— 旧: `isFutureSchedule()` を外すと出発失敗として並んだ。いまは出発失敗そのものを出さない
timetable(901, 1, FACILITY_1, "2026-10-05T02:00:00+09:00"),
// --- 事前チェック行(本清掃の子) ---
timetable(1021, 1, FACILITY_1, "2026-09-09T01:00:00+09:00", FIRST_ROUND_MINUTES, 102),
timetable(1022, 1, FACILITY_1, "2026-09-09T01:30:00+09:00", SECOND_ROUND_MINUTES, 102),
// ★★#4: 親(104)は 9/11、この行は 9/10★★
timetable(1041, 1, FACILITY_1, "2026-09-10T23:30:00+09:00", FIRST_ROUND_MINUTES, 104),
// ★壁時計 20:00 の清掃(301)の事前チェック(C-6)★
timetable(3011, 2, FACILITY_2, "2026-09-10T19:00:00+09:00", FIRST_ROUND_MINUTES, 301),
],
// ★103 の行は置かない★(旧: `clean_error=1` の履歴。`clean_task_reports` には
// 未実施を表す列が無いので、未実施は「行が無い」でしか表せない。指示書AQ §1-1)
reports: [
report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00"),
// ★`ended_at` が NULL の行★(nullable。指示書AQ §1-4)— ★行がある以上は実施済み★で、
// `main_run_succeeded` の材料として読めること
report(104, "2026-09-11T00:30:00+09:00", null),
],
errorReports: [
errorReport("E1", 1021, 1, FACILITY_1, "2026-09-09T01:05:00+09:00"),
// ★★#4: エラー行の時刻は 9/10 だが、親の本清掃は 9/11★★
errorReport("E2", 1041, 1, FACILITY_1, "2026-09-10T23:35:00+09:00"),
// ★★本清掃行を指すエラー行★★ — ★拾わない★(D-104)ので
// ★1件も出てはいけない★(出発失敗の代わりに本清掃のエラーが出ていないことの検出)
errorReport("E3", 103, 1, FACILITY_1, "2026-09-10T02:10:00+09:00"),
// ★壁時計 20:00 の清掃(301)の事前チェックのエラー(C-6)★
errorReport("E5", 3011, 2, FACILITY_2, "2026-09-10T19:05:00+09:00"),
],
};
}
/** ★9月の行が全部「過去」になる基準時刻★(10/05 の行だけが未来として残る) */
const NOW = new Date("2026-09-30T00:00:00+09:00");
async function get(search: string): Promise<SummaryListResponse<PrecheckItem, PrecheckSummary>> {
const res = await GET(new Request(`http://localhost/api/prechecks${search}`));
return (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
}
/**
* 本清掃行を引くクエリ(= `where.precheck` を持つ1本目)。
* ★2026-10-29 から `include` を持たない★(`select: { id, start_at }`)ので、`where.precheck` で見分ける
*/
function isMainQuery(c: Record<string, unknown>): boolean {
return "where" in c && "precheck" in (c.where as object);
}
function mainQuery(): Record<string, unknown> {
const call = calls.cleanTimetable.find(isMainQuery);
expect(call).toBeDefined();
return call as Record<string, unknown>;
}
function mainQueryWhere(): Record<string, unknown> {
return (mainQuery() as { where: Record<string, unknown> }).where;
}
/** `severity` ごとの件数(★`precheck` 以外が混ざっていないことの検出に使う★) */
function bySeverity(items: PrecheckItem[]): Record<string, number> {
const out: Record<string, number> = {};
for (const item of items) out[item.severity] = (out[item.severity] ?? 0) + 1;
return out;
}
function memoryQuery(partial: Partial<PrecheckQuery>): PrecheckQuery {
return {
dateFrom: null,
dateTo: null,
status: "all",
// ★絞り込まない(指示書AK 段階2 で足った2つ。`null` = 条件なし)★
groupId: null,
customerId: null,
page: 1,
perPage: 50,
...partial,
};
}
beforeEach(() => {
data = buildData();
ignoreErrorWhere = false;
vi.mocked(logEvent).mockClear();
calls.cleanTimetable = [];
calls.cleanTimetableHistory = [];
calls.cleanTaskReport = [];
calls.robotErrorReport = [];
// ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// ---------------------------------------------------------------- テスト
describe("GET /prechecks — 日付をSQLに降ろす(指示書AC 段階4)", () => {
it("#1 範囲なし → 事前チェック異常の全件(★出発失敗の行は無い★)", async () => {
const body = await get("?status=all");
// ★detected_at の降順(D-052)★
expect(body.items.map((i) => `${i.severity}:${i.id}`)).toEqual([
"precheck:E2",
"precheck:E5",
"precheck:E1",
]);
expect(body.meta.total).toBe(3);
expect(body.summary).toEqual({ unresolved: 3, resolved: 0, total: 3 });
// ★本清掃の絞り込みが SQL に降りていること★
expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
// ★範囲なしのときは `start_at` のキーごと付けない★
expect(mainQueryWhere()).not.toHaveProperty("start_at");
});
it("★D-104: 本清掃行を指すエラー行は出さない★(出発失敗の代わりにもしない)", async () => {
const body = await get("?status=all");
expect(body.items.map((i) => i.id)).not.toContain("E3");
});
it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる(★`toDbJst()` を通す★)", async () => {
await get("?date_from=2026-09-09&date_to=2026-09-10&status=all");
const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
// ★★壁時計値であること★★(C-6。★生の `parseDate()`(真の瞬間)ではない★)
expect(startAt.gte.toISOString()).toBe("2026-09-09T00:00:00.000Z");
expect(startAt.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
});
it("★★#2 SQL版 = 全件版 + `filterPrechecksByDate()`(完全一致)★★", async () => {
const all = await get("?status=all");
const sql = await get("?date_from=2026-09-09&date_to=2026-09-10&status=all");
const memory = filterPrechecksByDate(
all.items,
memoryQuery({ dateFrom: "2026-09-09", dateTo: "2026-09-10" })
);
// ★件数だけでなく中身まで比べる★(指示書AC G-3)
expect(sql.items).toEqual(memory);
expect(sql.items.length).toBeGreaterThan(0);
});
it("★★#3 `severity` の内訳が全件版と一致する★★(★事前チェック異常だけ★)", async () => {
const all = await get("?status=all");
expect(bySeverity(all.items)).toEqual({ precheck: 3 });
for (const [from, to] of [
["2026-09-08", "2026-09-08"],
["2026-09-09", "2026-09-09"],
["2026-09-10", "2026-09-10"],
["2026-09-11", "2026-09-11"],
["2026-09-09", "2026-09-11"],
]) {
const sql = await get(`?date_from=${from}&date_to=${to}&status=all`);
const memory = filterPrechecksByDate(
all.items,
memoryQuery({ dateFrom: from, dateTo: to })
);
expect(bySeverity(sql.items)).toEqual(bySeverity(memory));
expect(sql.items).toEqual(memory);
}
});
it("★★#4 親の本清掃行が期間外・エラー行が期間内 → 親の `start_at` で判定される★★", async () => {
// ★E2 のエラー行と事前チェック行は 9/10、親の本清掃(104)は 9/11★
const sep11 = await get("?date_from=2026-09-11&date_to=2026-09-11&status=all");
expect(sep11.items.map((i) => i.id)).toEqual(["E2"]);
// ★`scheduled_at` は親の値(= `filterPrechecksByDate()` が見る値)★
expect(sep11.items[0].scheduled_at).toBe("2026-09-11T00:30:00+09:00");
// ★`detected_at` は 9/10 のまま(★絞り込みの基準ではない★)★
expect(sep11.items[0].detected_at).toBe("2026-09-10T23:35:00+09:00");
// ★★9/10 を指定しても E2 は出ない★★ —
// ★`errored_at` や事前チェック行の `start_at` で絞っていると、ここで E2 が混ざる★
const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
expect(sep10.items.map((i) => i.id)).not.toContain("E2");
// ★9/10 は E5(親 301 は 9/10 20:00)だけ★。出発失敗(103・301)は行にならない
expect(sep10.items.map((i) => i.id)).toEqual(["E5"]);
});
it("★#4(構造)事前チェック行は親の本清掃の id(`parent_id`)で引く★", async () => {
await get("?date_from=2026-09-11&date_to=2026-09-11&status=all");
const precheckCall = calls.cleanTimetable.find(
(c) => "where" in c && "parent_id" in (c.where as object)
);
expect(precheckCall).toBeDefined();
expect((precheckCall as { select: object }).select).toEqual({
id: true,
parent_id: true,
precheck: true,
});
// ★エラー行は事前チェック行の id でしか引かない(全件読みに戻さない)★
const errorWhere = (
calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } }
).where;
expect(errorWhere.clean_timetable_id.in.map(String)).toEqual(["1041"]);
});
it("★壁時計 20:00 の清掃が、その日の範囲に**含まれる**★(C-6。★ローカルDBでは検出できない★)", async () => {
// ★E5 の親(301)は 9/10 20:00★
const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
expect(sep10.items.map((i) => i.id)).toContain("E5");
expect(sep10.items.find((i) => i.id === "E5")?.scheduled_at).toBe("2026-09-10T20:00:00+09:00");
const sep11 = await get("?date_from=2026-09-11&date_to=2026-09-11&status=all");
expect(sep11.items.map((i) => i.id)).not.toContain("E5");
});
it("★レポートは行になったエラーの親の本清掃の id だけで引く(全件読みに戻さない)★", async () => {
await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
const reportWhere = (
calls.cleanTaskReport.find((c) => "where" in c) as {
where: { clean_timetable_id: { in: bigint[] } };
}
).where;
// ★9/10 の本清掃は 103・301 だが、行(E5)の親は 301 だけ★(旧: 出発失敗の判定のため 103 も引いていた)
expect(reportWhere.clean_timetable_id.in.map(String)).toEqual(["301"]);
});
it("★行が0件ならレポートを引かない★(出発失敗の判定のための読み取りは無い)", async () => {
// 9/8 の本清掃(101)は事前チェックのエラーが無い
const body = await get("?date_from=2026-09-08&date_to=2026-09-08&status=all");
expect(body.items).toEqual([]);
expect(calls.cleanTimetable.length).toBeGreaterThan(0);
expect(calls.cleanTaskReport).toEqual([]);
});
it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => {
const body = await get("?date_from=2026-01-01&date_to=2026-01-02&status=all");
expect(body.items).toEqual([]);
expect(calls.robotErrorReport).toEqual([]);
expect(calls.cleanTaskReport).toEqual([]);
});
it("★summary は日付範囲で絞ったあとの件数になる★(D-054。SQL に降ろしても変わらない)", async () => {
const body = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
// ★9/10 は E5 の1件だけ★(旧: 出発失敗 103・301 の2件。2026-10-29 から数えない)
expect(body.summary).toEqual({ unresolved: 1, resolved: 0, total: 1 });
expect(body.meta.total).toBe(1);
});
});
/**
* ★出発失敗はアラートに出さない★(ユーザー判断 2026-10-29「2で進めてください」)。
* ★過去の本清掃でレポートが無い(101・103・301)は、行・summary・meta.total のどれにも出ない★。
* 清掃詳細ログには出発失敗として出ること(同じ判定 `notExecutedFromReport()`)は
* `app/api/clean-logs/__tests__/route.test.ts` の「出発失敗はアラートに出ない」で固定する。
*/
describe("GET /prechecks — 出発失敗はアラートに出さない(2026-10-29)", () => {
const LAUNCH_FAILED_IDS = ["101", "103", "301", "901"];
it("★レポートの無い過去の本清掃は行にならない(summary・meta.total にも数えない)★", async () => {
const body = await get("?status=all");
const ids = body.items.map((i) => i.id);
for (const id of LAUNCH_FAILED_IDS) expect(ids).not.toContain(id);
expect(body.items.every((i) => i.severity === "precheck")).toBe(true);
// ★件数は事前チェック異常の3件だけ★
expect(body.summary.total).toBe(3);
expect(body.meta.total).toBe(3);
});
it("★事前チェックのエラーが無ければ、出発失敗の清掃があっても0件★", async () => {
data.errorReports = [];
const body = await get("?status=all");
expect(body.items).toEqual([]);
expect(body.summary).toEqual({ unresolved: 0, resolved: 0, total: 0 });
expect(body.meta.total).toBe(0);
});
it("★結果待ちの時刻でも、待ち時間を過ぎた後でも出さない★(旧: 終了+120分を過ぎると出発失敗として出した。D-173)", async () => {
for (const now of ["2026-09-10T04:59:00+09:00", "2026-09-10T05:00:00+09:00"]) {
vi.setSystemTime(new Date(now));
const ids = (await get("?status=all")).items.map((i) => i.id);
for (const id of LAUNCH_FAILED_IDS) expect(ids).not.toContain(id);
}
});
it("★親の本清掃が出発失敗の事前チェック異常は、行として出る(main_run_succeeded は false)★", async () => {
// E5 の親 301 はレポートなし(出発失敗)
const body = await get("?status=all");
const e5 = body.items.find((i) => i.id === "E5");
expect(e5).toBeDefined();
expect(e5?.main_run_succeeded).toBe(false);
expect(e5?.severity).toBe("precheck");
});
});
describe("★#5 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★(禁止事項6)", () => {
/**
* ★同じデータに対して★、
* - Prisma 版 = ★SQL で絞った結果★
* - fixtures 版 = ★全件版 + `filterFixturePrechecks()`★
* を突き合わせる(指示書AC G-3「等価テスト」)。
*/
it("日付範囲: SQL版 = 全件版 + fixtures 版の絞り込み", async () => {
const all = await get("?status=all");
const sql = await get("?date_from=2026-09-09&date_to=2026-09-11&status=all");
const viaFixtures = filterFixturePrechecks(all.items, {
date_from: "2026-09-09",
date_to: "2026-09-11",
facility_ids: null,
});
expect(sql.items).toEqual(viaFixtures);
expect(sql.items.length).toBeGreaterThan(0);
});
it("範囲なし: 件数が一致", async () => {
const all = await get("?status=all");
expect(
filterFixturePrechecks(all.items, { date_from: null, date_to: null, facility_ids: null })
).toHaveLength(all.items.length);
});
/**
* ★★空文字のクエリ(`?date_to=`)で fixtures 版と Prisma 版が食い違わないこと★★(禁止事項6)。
*
* `parsePrecheckQuery()` が生値をそのまま下へ渡していた頃、`""` は
* - Prisma 版 … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★
* - fixtures 版 … `query.date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★
* となり、★同じ入力で件数が変わっていました★(★例外もテスト失敗も出ない★)。
*
* ★ルートと同じ経路で組み立てる★ — `parsePrecheckQuery()` の戻り値をそのまま
* `PrecheckDbQuery` に詰め、fixtures 版の絞り込みへ渡す(ルートは `db.prechecks()` に
* 同じ値を渡している)。★テスト側で `null` を書き足さないこと★(それでは穴を再現できない)。
*/
describe("★空文字のクエリ(`?date_from=` / `?date_to=`)★", () => {
function dbQueryFromSearch(search: string): PrecheckDbQuery {
const parsed = parsePrecheckQuery(new URLSearchParams(search));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
return {
date_from: parsed.query.dateFrom,
date_to: parsed.query.dateTo,
facility_ids: null,
};
}
for (const search of ["date_to=", "date_from=", "date_from=&date_to="]) {
it(`\`?${search}\`: SQL版 = fixtures 版 = 全件`, async () => {
const all = await get("?status=all");
const sql = await get(`?${search}&status=all`);
const viaFixtures = filterFixturePrechecks(all.items, dbQueryFromSearch(search));
// ★空文字は「指定なし」=全件★(★片方だけ0件に化けないこと★)
expect(sql.items).toEqual(all.items);
expect(viaFixtures).toEqual(all.items);
expect(viaFixtures).toHaveLength(sql.items.length);
expect(all.items.length).toBeGreaterThan(0);
});
}
it("★空文字は `start_at` のキーごと付かない(範囲なしと同じ SQL)★", async () => {
await get("?date_from=&date_to=&status=all");
expect(mainQueryWhere()).not.toHaveProperty("start_at");
});
});
});
describe("GET /prechecks — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => {
it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★(select で robot_id / robot を読まない)", async () => {
const before = await get("?status=all");
data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null }));
const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
expect(res.status).toBe(200);
const after = (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
expect(after.items).toEqual(before.items);
expect(after.items.map((i) => i.id)).toContain("E1");
});
});
describe("GET /prechecks — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => {
it("★履歴だけの予定の事前チェックのエラーも出る★。2表に同じ引数が渡る", async () => {
data.historyTimetables = [
// ★履歴だけの本清掃(レポートなし → 出発失敗)★ — アラートには出ない(2026-10-29)
timetable(501, 1, FACILITY_1, "2026-09-12T02:00:00+09:00"),
// ★履歴だけの本清掃(正常)とその事前チェック行(エラーあり)★
timetable(502, 1, FACILITY_1, "2026-09-13T02:00:00+09:00"),
timetable(5021, 1, FACILITY_1, "2026-09-13T01:00:00+09:00", FIRST_ROUND_MINUTES, 502),
];
data.reports.push(report(502, "2026-09-13T02:00:00+09:00", "2026-09-13T03:00:00+09:00"));
data.errorReports.push(errorReport("E9", 5021, 1, FACILITY_1, "2026-09-13T01:05:00+09:00"));
const body = await get("?status=all");
const ids = body.items.map((i) => i.id);
expect(ids).toContain("E9");
expect(ids).not.toContain("501");
expect(ids).not.toContain("502");
const mainCall = calls.cleanTimetable.find(isMainQuery);
expect(mainCall).toBeDefined();
expect(calls.cleanTimetableHistory.find(isMainQuery)).toEqual(mainCall);
});
});
/**
* ★`main_run_succeeded`(ユーザー指示 2026-10-07。D-171 の期待値表 M1〜M5)★ — Prisma 版の経路。
* ★親の本清掃のレポートは `prechecks()` が行の親の id でまとめて1回引く★(N+1 にしない)。
* (M6「出発失敗の行は常に false」は、2026-10-29 に出発失敗の行を外したので無い)
*/
describe("GET /prechecks — main_run_succeeded(D-171)", () => {
it("親の本清掃が ◎○ なら true、△・レポートなし(出発失敗)は false", async () => {
// E1 の親 102 は 95%(◎。M1)、E2 の親 104 は 50%(△。M3)
data.reports = [
report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 95, 100),
report(104, "2026-09-11T00:30:00+09:00", null, 50, 100),
];
// ★M4: 親の本清掃にレポートが無い事前チェックのエラー★(親 101 そのものは行にならない)
data.timetables.push(
timetable(1011, 1, FACILITY_1, "2026-09-08T01:00:00+09:00", FIRST_ROUND_MINUTES, 101)
);
data.errorReports.push(errorReport("E4", 1011, 1, FACILITY_1, "2026-09-08T01:05:00+09:00"));
const body = await get("?status=all");
const byKey = new Map(body.items.map((i) => [`${i.severity}:${i.id}`, i.main_run_succeeded]));
expect(byKey.get("precheck:E1")).toBe(true);
expect(byKey.get("precheck:E2")).toBe(false);
expect(byKey.get("precheck:E4")).toBe(false);
// ★出発失敗(101)は行にならない(2026-10-29)★
expect(body.items.map((i) => i.id)).not.toContain("101");
});
it("M2: ○(72%)は true / M5: task_area 0(×)は false", async () => {
data.reports = [report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 72, 100)];
expect((await get("?status=all")).items.find((i) => i.id === "E1")?.main_run_succeeded).toBe(true);
data.reports = [report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 500, 0)];
expect((await get("?status=all")).items.find((i) => i.id === "E1")?.main_run_succeeded).toBe(false);
});
it("★レポートは本清掃の id でまとめて1回だけ引く★(N+1 にしない)", async () => {
await get("?status=all");
expect(calls.cleanTaskReport).toHaveLength(1);
});
});
/** `data.anomaly` のうち、指定の metric のものだけ */
function anomalies(metric: string) {
return vi
.mocked(logEvent)
.mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === metric)
.map(([, p]) => p);
}
describe("GET /prechecks — 対応状況が 0/1/2 以外のエラー行(指示書BB U3・表3 S1/S2)", () => {
it("★S2: `status` 3 の行は SQL の写し(`status IN (0,1,2)`)で外れる★", async () => {
data.errorReports.push({ ...errorReport("E-ODD", 1022, 1, FACILITY_1, "2026-09-09T01:35:00+09:00"), status: 3 });
const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
expect(res.status).toBe(200);
const where = calls.robotErrorReport[0].where as Record<string, unknown>;
expect(where.status).toEqual({ in: [0, 1, 2] });
});
it("★S2: スタブが `where` を無視しても、純粋関数の側で外れる(200・出ない・summary に数えない・ログ1回)★", async () => {
const before = await get("?status=all");
vi.mocked(logEvent).mockClear();
data.errorReports.push({ ...errorReport("E-ODD", 1022, 1, FACILITY_1, "2026-09-09T01:35:00+09:00"), status: 3 });
ignoreErrorWhere = true;
const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
expect(res.status).toBe(200);
const after = (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
expect(after.items.map((i) => i.id)).not.toContain("E-ODD");
expect(after.items).toEqual(before.items);
expect(after.summary).toEqual(before.summary);
expect(anomalies("robot_error_status_unknown")).toEqual([
{ event: "data.anomaly", metric: "robot_error_status_unknown", expected: "0/1/2", actual: 1 },
]);
});
it("S1: `status` 0/1/2 の行は今どおり出る(外した件数が0ならログを出さない)", async () => {
ignoreErrorWhere = true;
const body = await get("?status=all");
expect(body.items.map((i) => i.id)).toContain("E1");
expect(anomalies("robot_error_status_unknown")).toHaveLength(0);
});
});
/**
* ★本清掃の予定は `id` と `start_at` だけ読む★(2026-10-29)— 旧: 出発失敗の行の施設名・シリアルのため
* `facility` / `section.robots` を include し、有効なロボットが1台でない清掃エリアのログ(指示書BB U2)を出していた。
*/
describe("GET /prechecks — 本清掃の予定はロボット・施設を読まない(2026-10-29)", () => {
it("★本清掃の予定のクエリは `select: { id, start_at }`(include なし)。2表とも同じ★", async () => {
await get("?status=all");
expect(mainQuery()).not.toHaveProperty("include");
expect(mainQuery().select).toEqual({ id: true, start_at: true });
expect(calls.cleanTimetableHistory.find(isMainQuery)).toEqual(mainQuery());
});
it("★清掃エリアのロボットが0台でも、事前チェック異常の行は今どおり(シリアルはエラー行の生値・ログなし)★", async () => {
for (const t of data.timetables) {
if (Number(t.facility_id) === 1) t.section = { robots: [] };
}
const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
expect(res.status).toBe(200);
const body = (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
// ★事前チェック異常の行は `robot_error_reports.serial_number` の生値のまま(null にならない)★
expect(body.items.find((i) => i.id === "E1")?.serial_number).toBe("8111R562806001");
expect(anomalies("active_robots_per_section")).toHaveLength(0);
});
});
describe("GET /prechecks — 削除済みの施設・清掃エリアの行は出さない(指示書BB U1・表1 V7/V8)", () => {
const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");
it("★V8: 予定(2表とも)の `where` に施設・清掃エリアの可視性が入る★(事前チェックの親の本清掃)", async () => {
await get("?status=all");
expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
const historyMain = calls.cleanTimetableHistory.find(isMainQuery) as { where: Record<string, unknown> };
expect(historyMain.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
});
it("V7: エラー行の SQL(写し)にも `status IN` は入るが、可視性は純粋関数で外す(列を select する)", async () => {
await get("?status=all");
const select = calls.robotErrorReport[0].select as Record<string, { select: Record<string, unknown> }>;
expect(select.facility.select).toMatchObject({ deleted_at: true });
expect(select.section.select).toMatchObject({ deleted_at: true });
});
it.each([
["施設削除", { facility: { facility_name: FACILITY_1, deleted_at: DELETED } }],
["エリア削除", { section: { deleted_at: DELETED } }],
] as const)("★V7(%s): スタブが where を無視しても、そのエラー行は出ない・summary に数えない★", async (_label, patch) => {
const before = await get("?status=all");
data.errorReports = data.errorReports.map((r) => (r.error_id === "E1" ? { ...r, ...patch } : r));
ignoreErrorWhere = true;
const after = await get("?status=all");
expect(after.items.map((i) => i.id)).toEqual(before.items.map((i) => i.id).filter((id) => id !== "E1"));
expect(after.summary.total).toBe(before.summary.total - 1);
});
});
describe("GET /prechecks — 存在しない日付は422(指示書BB 表8 Q6)", () => {
it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => {
const bad = await GET(new Request("http://localhost/api/prechecks?date_from=2026-02-31"));
const format = await GET(new Request("http://localhost/api/prechecks?date_from=2026/02/03"));
expect(bad.status).toBe(422);
expect(format.status).toBe(422);
expect(await bad.json()).toEqual(await format.json());
});
});