import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
// ★最初の1本だけがモジュール読み込み(`next/server` / ロガー等)を丸ごと抱え込み、
// フルスイート実行時に既定のタイムアウト(5秒)を踏んで★不定期に落ちます★
// (実測: 単体実行 2.8秒 / フルスイート 7.1秒で #1・#2 が不定期に赤)。
// ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効きます。
import { GET } from "@/app/api/clean-logs/route";
// ★出発失敗はアラートに出ない(2026-10-29)ことを、同じスタブの上で突き合わせるため★
import { GET as GET_PRECHECKS } from "@/app/api/prechecks/route";
import type { PrecheckSummary } from "@/lib/prechecks/query";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { db } from "@/lib/db";
import { consecutiveLookbackCalendarDays } from "@/lib/db/consecutive-source";
import { filterFixtureCleanLogs } from "@/lib/db/fixture-clean-logs";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { getPrisma } from "@/lib/db/prisma";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { logEvent } from "@/lib/logger";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type { CleanLogItem, ListResponse, PrecheckItem, SummaryListResponse } from "@/lib/types";
/**
* 指示書AC 段階2 の期待値表のうち、★★API ルートから `db` までを通した経路★★
* でなければ固定できないもの(★#6b / #6c / #6d / #6e / #7 / #8 / #9★)。
*
* ------------------------------------------------------------------------
* ★★なぜ解決関数の単体テストだけでは不十分なのか★★(指示書AC 期待値表 #6d)
* ------------------------------------------------------------------------
* ★ルートが `facility_ids` を `db.cleanLogs()` に渡し忘れていても、
* `facilityIdsMatchingName()` の単体テストは緑のままです★。
* ★★だからここでは「ルート → `db`(Prisma 実装)→ 発行されたクエリの `where`」まで
* 通して確かめます★★。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
* `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
* ★`prismaSource` の経路★ を走らせます(`lib/db/__tests__/consecutive-source.test.ts` と
* 同じ「DBに接続しないでDB実装を試す」作法)。
*
* ★★合成データを使う理由★★: ローカルDB(`dwatch_local`)では
* - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★(#7 / C-6)
* - ★論理削除された施設が0件で、罠7 を検出できない★(#6e。2026-09-13 実測)
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
/** `clean_timetables` の1行(★`include` 済みの形で持つ★) */
interface StubTimetable {
id: bigint;
facility_id: bigint;
section_id: bigint;
parent_id: bigint;
start_at: Date;
/** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */
end_at: Date;
precheck: number;
/** ★`deleted_at` は省略可(省略は生きている)。削除済みの施設の行を作るテストだけが入れる(指示書BB U1)★ */
facility: { facility_name: string; deleted_at?: Date | null };
section: {
/** ★省略は生きている(指示書BB U1)★ */
deleted_at?: Date | null;
robots: {
/** `robots.id`(★2台以上のときの並び `orderBy: { id: "asc" }` を見るため。省略可★。指示書BB D7) */
id?: bigint;
serial_number: string;
device: { maker_id: number; model_name: string };
}[];
};
}
/**
* `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。
* ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
* ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3)
*/
interface StubReport {
/** ★予定外の行(指示書BG)だけ使う★(予定のある行の読み取りは読まない) */
report_id?: string;
facility_id?: bigint;
section_id?: bigint;
/** ★予定外の行の `include`(施設名・清掃エリアのロボット・削除済み)★。予定のある行には付けない */
facility?: { facility_name: string; deleted_at: Date | null };
section?: StubTimetable["section"];
clean_timetable_id: bigint | null;
clean_ratio: number;
/** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */
status: number;
started_at: Date;
ended_at: Date | null;
clean_area: number;
task_area: number;
}
interface StubErrorReport {
clean_timetable_id: bigint | null;
error_type: string;
errored_at: Date;
/** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
robot_id?: bigint | null;
}
interface StubFacility {
id: bigint;
facility_name: string;
/** ★論理削除★(`null` 以外なら削除済み) */
deleted_at: Date | null;
}
interface StubData {
timetables: StubTimetable[];
/** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */
historyTimetables?: StubTimetable[];
reports: StubReport[];
errorReports: StubErrorReport[];
facilities: StubFacility[];
}
/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */
const calls: {
cleanTimetable: Record<string, unknown>[];
cleanTimetableHistory: Record<string, unknown>[];
cleanTaskReport: Record<string, unknown>[];
robotErrorReport: Record<string, unknown>[];
facility: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [], facility: [] };
let data: StubData;
/** 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>)) {
// ★`OR`(予定外の行の `UNSCHEDULED_TIMETABLE_ID_WHERE`。指示書BG)★ — どれか1つに当たれば通す
if (field === "OR") {
if (!(cond as unknown[]).some((sub) => matchesWhere(row, sub))) return false;
continue;
}
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;
if ("deleted_at" in c) {
// ★このスタブは `deleted_at` での絞り込みを**わざと**実装している★ —
// 罠7(解決用の読み取りに `where` を足してしまう)が起きたら結果が変わるように。
// ★リレーション(`facility` / `section`)の条件は、その行の `deleted_at`(省略は生きている)で見る★(指示書BB U1)
const target =
value !== null && typeof value === "object" && !(value instanceof Date)
? ((value as { deleted_at?: Date | null }).deleted_at ?? null)
: value;
if (c.deleted_at === null && target !== null) 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 applyOrderBy(
rows: Record<string, unknown>[],
orderBy: unknown
): Record<string, unknown>[] {
if (!Array.isArray(orderBy)) return rows;
return [...rows].sort((a, b) => {
for (const key of orderBy as Record<string, "asc" | "desc">[]) {
const [field, dir] = Object.entries(key)[0];
const av = a[field];
const bv = b[field];
const an = av instanceof Date ? av.getTime() : Number(av);
const bn = bv instanceof Date ? bv.getTime() : Number(bv);
if (an !== bn) return (an < bn ? -1 : 1) * (dir === "desc" ? -1 : 1);
}
return 0;
});
}
function findMany(
bucket: keyof typeof calls,
rows: Record<string, unknown>[],
args: Record<string, unknown> | undefined
) {
calls[bucket].push(args ?? {});
const filtered = rows.filter((row) => matchesWhere(row, args?.where));
const ordered = applyOrderBy(filtered, args?.orderBy);
return ordered.map((row) => sortIncludedRobots(project(row, args?.select), args));
}
/**
* ★`include: { section: { include: { robots: { orderBy: { id: "asc" } } } } }` を再現する★(指示書BB D7・表2 R5)—
* 実装が `orderBy` を付け忘れたら、スタブはデータの順のまま返す(R5 が赤くなる)
*/
function sortIncludedRobots(
row: Record<string, unknown>,
args: Record<string, unknown> | undefined
): Record<string, unknown> {
const include = args?.include as
| { section?: { include?: { robots?: { orderBy?: { id?: string } } } } }
| undefined;
if (include?.section?.include?.robots?.orderBy?.id !== "asc") return row;
const section = row.section as StubTimetable["section"];
const robots = [...section.robots].sort((a, b) => Number(a.id ?? 0) - Number(b.id ?? 0));
return { ...row, section: { ...section, robots } };
}
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
// ★事前チェックが3つ以上のときの `data.anomaly`(D-178)を見るため `logEvent` だけ差し替える★
// (`withRequestId` はそのまま使う。`app/api/clean-area/__tests__/route.test.ts` と同じ作法)
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
),
},
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);
return findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], args);
},
},
facility: {
findMany: async (args?: Record<string, unknown>) =>
findMany("facility", data.facilities 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 = "ドソ・キホーテ戸塚";
/** ★論理削除された施設★(#6e / 罠7) */
const FACILITY_2 = "ドソ・キホーテ四街道";
const FACILITY_3 = "フレッシュタウン大和店";
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}`,
device: { maker_id: 1, model_name: "CC1" },
},
],
},
};
}
/**
* 実施済みの清掃1回ぶんのレポート行。
* ★未実施は行を作らないことで表す★(指示書AQ §1-1。旧 `clean_error = 1` の行は作らない)。
* `started_at` は `ended_at` の1時間前(どちらも壁時計値)。`status` は生値の一例(4)。
* ★完了率は面積から `runCompletionRate()` で決まる(D-155)★ので、`task_area = 1000` に対して
* `clean_area = cleanRatio × 10` とし、`clean_ratio` 列にも同じ値を入れる(列は完了率に使っていない。D6)。
*/
function report(timetableId: number, cleanRatio: number, endedJstIso: string): StubReport {
const endedAt = dbWallClock(endedJstIso);
return {
clean_timetable_id: BigInt(timetableId),
clean_ratio: cleanRatio,
status: 4,
started_at: new Date(endedAt.getTime() - 60 * 60 * 1000),
ended_at: endedAt,
clean_area: cleanRatio * 10,
task_area: 1000,
};
}
function buildData(): StubData {
return {
facilities: [
{ id: BigInt(1), facility_name: FACILITY_1, deleted_at: null },
// ★★論理削除済み(施設マスタ側)★★ — 名前の解決(罠7)の検証用。
// ★予定の行(201)の `facility.deleted_at` は既定では入れない(=生きている扱い)★ —
// 削除済みの施設の行が外れること(指示書BB U1)は #6e で行に `deleted_at` を入れて確かめる
{ id: BigInt(2), facility_name: FACILITY_2, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") },
{ id: BigInt(3), facility_name: FACILITY_3, deleted_at: null },
],
timetables: [
// 施設1: ★3日連続で×★(9/8・9/9・9/10)→ 9/10 の行の連続日数は ★3★
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"),
// 事前チェック行(★本清掃の子★)— `status_1h` / `status_30m` の材料
timetable(1031, 1, FACILITY_1, "2026-09-10T01:00:00+09:00", 60, 103),
timetable(1032, 1, FACILITY_1, "2026-09-10T01:30:00+09:00", 30, 103),
// 施設2(★論理削除された施設★): 9/10 に1件
timetable(201, 2, FACILITY_2, "2026-09-10T02:00:00+09:00"),
// 施設3: ★★壁時計 20:00 の清掃★★(#7。★ローカルDBには1件も無い時間帯★)
timetable(301, 3, FACILITY_3, "2026-09-10T20:00:00+09:00"),
],
reports: [
report(101, 5, "2026-09-08T03:00:00+09:00"),
report(102, 5, "2026-09-09T03:00:00+09:00"),
report(103, 5, "2026-09-10T03:00:00+09:00"),
report(201, 95, "2026-09-10T03:00:00+09:00"),
report(301, 80, "2026-09-10T21:00:00+09:00"),
],
errorReports: [
{
clean_timetable_id: BigInt(1031),
error_type: "EmergencyStop",
errored_at: dbWallClock("2026-09-10T01:05:00+09:00"),
},
],
};
}
/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */
const NOW = new Date("2026-09-30T00:00:00+09:00");
async function get(search: string): Promise<ListResponse<CleanLogItem>> {
const res = await GET(new Request(`http://localhost/api/clean-logs${search}`));
return (await res.json()) as ListResponse<CleanLogItem>;
}
/** 最後に発行された `clean_timetables` の本体クエリ(= `include` 付きの1本目) */
function mainQueryWhere(): Record<string, unknown> {
const call = calls.cleanTimetable.find((c) => "include" in c);
expect(call).toBeDefined();
return (call as { where: Record<string, unknown> }).where;
}
beforeEach(() => {
data = buildData();
calls.cleanTimetable = [];
calls.cleanTimetableHistory = [];
calls.cleanTaskReport = [];
calls.robotErrorReport = [];
calls.facility = [];
vi.mocked(logEvent).mockClear();
// ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// ---------------------------------------------------------------- テスト
describe("GET /clean-logs — 日付・施設をSQLに降ろす(指示書AC 段階2)", () => {
it("#1 範囲なし → 本清掃の全件(★事前チェック行は並ばない★)", async () => {
const body = await get("");
expect(body.items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]);
expect(body.meta.total).toBe(5);
// ★本清掃の絞り込みが SQL に降りていること★
expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
});
it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる", async () => {
await get("?date_from=2026-09-09&date_to=2026-09-10");
const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
// ★★`toDbJst()` を通した壁時計値であること★★(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("#3 `date_from = date_to` → その日の行だけ", async () => {
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
expect(body.items.map((i) => i.id)).toEqual([301, 103, 201]);
expect(body.meta.total).toBe(3);
});
it("#4 `date_from` がデータの最古日より前 → #1 と同じ件数", async () => {
const body = await get("?date_from=2000-01-01");
expect(body.meta.total).toBe(5);
});
it("#5 `date_from > date_to` は 422 で弾かれ、`db` まで来ない", async () => {
const res = await GET(
new Request("http://localhost/api/clean-logs?date_from=2026-09-10&date_to=2026-09-08")
);
expect(res.status).toBe(422);
// ★★クエリが1本も発行されていないこと★★
expect(calls.cleanTimetable).toHaveLength(0);
});
it("★#7 壁時計 20:00 の清掃が、その日の範囲に**含まれる**★(C-6。★ローカルDBでは検出できない★)", async () => {
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
const evening = body.items.find((i) => i.id === 301);
expect(evening).toBeDefined();
expect(evening?.scheduled_at).toBe("2026-09-10T20:00:00+09:00");
// ★前日・翌日の指定では出てこない★
expect((await get("?date_from=2026-09-09&date_to=2026-09-09")).items.map((i) => i.id)).not.toContain(301);
expect((await get("?date_from=2026-09-11&date_to=2026-09-11")).items.map((i) => i.id)).not.toContain(301);
});
it("★★#8 期間で絞っても `consecutive_problem_days` が全期間版と完全一致する★★(★C-2 の地雷★)", async () => {
// ★施設1 は 9/8・9/9・9/10 の3日連続で×★
const all = await get("");
const expected = new Map(all.items.map((i) => [i.id, i.consecutive_problem_days]));
expect(expected.get(103)).toBe(3);
// ★9/10 の1日だけを指定しても 3 のまま★
// ★★期間で絞った行から連続日数を数えると 1 になり、L3 が L1 に化ける★★
// (★例外もテスト失敗も出ない fail-silent★。だからこのテストが唯一の検出手段)
const oneDay = await get("?date_from=2026-09-10&date_to=2026-09-10");
for (const item of oneDay.items) {
expect(item.consecutive_problem_days).toBe(expected.get(item.id));
}
expect(oneDay.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3);
expect(oneDay.items.find((i) => i.id === 103)?.escalation_level).toBe("L3");
});
it("★#8(構造)連続日数用のクエリは「表示期間 + 上限ぶんの遡り」で絞る★(指示書AC 段階6)", async () => {
await get("?date_from=2026-09-10&date_to=2026-09-10");
// ★`loadConsecutiveInputs()` が発行する `select` 付きのクエリ★
const consecutiveCall = calls.cleanTimetable.find(
(c) => "select" in c && (c.select as Record<string, unknown>).facility_id === true
);
expect(consecutiveCall).toBeDefined();
// ★★表示期間の開始日(9/10)をそのまま下限にしたら連続が途切れる(C-2)★★ —
// 遡り幅は ★保存された表示上限★ から導く(★定数に写し取らないこと★。申し送り⑪)
const start = (consecutiveCall as { where: { start_at: { gte: Date; lt: Date } } })
.where.start_at;
const lookback = consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax());
expect(start.gte.toISOString().slice(0, 10)).toBe(
new Date(Date.parse("2026-09-10T00:00:00Z") - lookback * 86_400_000)
.toISOString()
.slice(0, 10)
);
// ★上限側は表示期間の末日を含む(翌日 00:00 未満)★
expect(start.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
});
});
describe("施設名 → 施設ID の解決(指示書AC C-1 / D-6)", () => {
it("★★#6d 記号違いで指定した施設名が解決され、SQL の `where` に施設IDで降りる★★", async () => {
const body = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
// ★★ルートが `facility_ids` を渡していること(=渡し忘れの検出)★★
expect(mainQueryWhere().facility_id).toEqual({ in: [1, 2] });
// ★★そのログがヒットすること★★(`ドソ・キホーテ戸塚` に `ドソキホーテ` で当たる)
expect(body.items.map((i) => i.id).sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([101, 102, 103, 201]);
expect(body.items.every((i) => i.facility_name.startsWith("ドソ"))).toBe(true);
});
it("★★#6e 削除済みの施設の名前で検索すると、施設ID には解決される(罠7)が、その行は可視性の条件で外れて0件★★(指示書BB D3)", async () => {
// ★予定の行(201)の施設を削除済みにする(施設マスタの施設2 と同じ状態)★
for (const t of data.timetables) {
if (Number(t.facility_id) === 2) t.facility = { ...t.facility, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") };
}
const body = await get(`?facility_name=${encodeURIComponent("四街道")}`);
// ★解決用の読み取りに `where` を足していないこと★(罠7。解決は行の可視性と別の責務)
expect(calls.facility).toEqual([{ select: { id: true, facility_name: true } }]);
expect(mainQueryWhere().facility_id).toEqual({ in: [2] });
// ★行は削除済みの施設・清掃エリアの条件で外れる(指示書BB U1)★
expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(0);
});
it("★#6e(対照)`deleted_at` で絞ると施設2 が解決できなくなる★(流用してはいけない理由)", async () => {
// ★解決用の読み取りは論理削除された施設2 も返す★
expect((await db.facilityNameIndex()).map((f) => f.id)).toEqual([1, 2, 3]);
// ★対照★: `prismaSource.facilities()` が付けている `where: { deleted_at: null }` を
// 同じスタブに投げると ★施設2 が消える★ → 名前の解決の結果が変わる
// (★解決は行の可視性と別の責務★。行は予定の `where` の可視性の条件で外す。指示書BB D3)
const prisma = (await getPrisma()) as unknown as {
facility: {
findMany(args: unknown): Promise<{ id: bigint }[]>;
};
};
const visible = await prisma.facility.findMany({
where: { deleted_at: null },
select: { id: true, facility_name: true },
});
expect(visible.map((f) => Number(f.id))).toEqual([1, 3]);
});
it("★★#6b 1件も当たらない施設名 → 0件★★(★`in: undefined` に化けていないことの検出。罠1★)", async () => {
const body = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`);
// ★★`in: []` が渡っていること(`in: undefined` なら全件になる)★★
expect(mainQueryWhere().facility_id).toEqual({ in: [] });
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(0);
});
it("★#6c 施設名を指定しない → `facility_id` の条件を付けない(全件)★", async () => {
const body = await get("");
// ★★`in: []` を渡さないこと★★ — 渡すと施設名未指定で0件になる
expect(mainQueryWhere()).not.toHaveProperty("facility_id");
expect(body.meta.total).toBe(5);
// ★施設名が空なら施設マスタを読みにいかない(余計な往復を増やさない)★
expect(calls.facility).toEqual([]);
});
it("★機種・シリアルは据え置き(SQL に降ろさずメモリで絞る)★", async () => {
const body = await get("?serial=8111R56280600");
// ★`section` の条件は可視性(`deleted_at: null`)だけ★(シリアルの条件を SQL に降ろしていない。指示書BB U1 で可視性が入った)
expect(mainQueryWhere().section).toEqual({ deleted_at: null });
expect(body.meta.total).toBe(5);
});
});
describe("関連テーブルの引き直し(指示書AC 段階2 D-4 / §E-5)", () => {
it("★履歴は本清掃の id、エラー報告は事前チェック行の id で引く(全件読みに戻さない)★", async () => {
await get("?date_from=2026-09-10&date_to=2026-09-10");
// ★`select` の無い1本 = `cleanLogs()` 本体の引き直し★(`select` 付きは連続日数用。
// 取得元が `clean_task_reports` になって連続日数用も `where` を持つので、`where` だけでは区別できない)
// ★予定外の行の読み取り(`include` 付き。指示書BG)は `clean_timetable_id` の `in` を持たないので除く★
const reportCall = calls.cleanTaskReport.find(
(c) => "where" in c && !("select" in c) && !("include" in c)
);
expect(reportCall).toBeDefined();
const historyWhere = (
reportCall as {
where: { clean_timetable_id: { in: bigint[] } };
}
).where;
expect(historyWhere.clean_timetable_id.in.map(String).sort()).toEqual(
["103", "201", "301"].sort()
);
const errorWhere = (
calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } }
).where;
expect(errorWhere.clean_timetable_id.in.map(String).sort()).toEqual(
["1031", "1032"].sort()
);
});
it("★事前チェック行は `parent_id` で別クエリに分けて引く★", async () => {
await get("?date_from=2026-09-10&date_to=2026-09-10");
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,
});
});
it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => {
const body = await get("?date_from=2026-01-01&date_to=2026-01-02");
expect(body.items).toEqual([]);
expect(calls.robotErrorReport).toEqual([]);
// ★レポートの引き直しは0本(本清掃・連続日数用とも `in` の塊が0個)★。
// ★予定外の行の読み取り(`clean_timetable_id` の `in` を持たない。指示書BG)は予定の有無によらず撃つ★ので数えない
expect(
calls.cleanTaskReport.filter(
(c) => "where" in c && "clean_timetable_id" in (c.where as Record<string, unknown>)
)
).toEqual([]);
expect(calls.cleanTaskReport.filter((c) => "include" in c)).toHaveLength(1);
});
});
describe("清掃結果 `task_status` の取得元(指示書AQ §1-1 / §1-2 / §5-2 / 落とし穴1)", () => {
it("★`clean_task_reports.status` の生値をそのまま返す(変換しない)★", async () => {
data.reports = data.reports.map((r) =>
r.clean_timetable_id === BigInt(201) ? { ...r, status: 6 } : r
);
const body = await get("");
expect(body.items.find((i) => i.id === 201)?.task_status).toBe(6);
expect(body.items.find((i) => i.id === 103)?.task_status).toBe(4);
});
it("★レポートの行が無い本清掃 → `task_status` は null・出発失敗・完了率 null★(§1-1)", async () => {
data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201));
const body = await get("");
const item = body.items.find((i) => i.id === 201);
// ★行ごと消えない(出発失敗として出る)★
expect(item).toBeDefined();
expect(item?.task_status).toBeNull();
expect(item?.departed).toBe(false);
expect(item?.completion_rate).toBeNull();
});
it("★★`clean_timetable_id` が NULL のレポートはどの清掃にも紐づかない★★(落とし穴1)", async () => {
data.reports = data.reports.map((r) =>
r.clean_timetable_id === BigInt(201) ? { ...r, clean_timetable_id: null } : r
);
const body = await get("");
const item = body.items.find((i) => i.id === 201);
expect(item).toBeDefined();
expect(item?.task_status).toBeNull();
expect(item?.departed).toBe(false);
// ★他の清掃の結果を上書きしない★
expect(body.items.find((i) => i.id === 301)?.completion_rate).toBe(80);
});
});
describe("★#9 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★(指示書AC D-5 / 禁止事項6)", () => {
/**
* ★同じデータに対して★、
* - Prisma 版 = ★SQL で絞った結果★
* - fixtures 版 = ★全件版 + `filterFixtureCleanLogs()`★
* を突き合わせる(指示書AC G-3「等価テスト」)。
* ★`null` と `[]` の両方で比べること★(罠9)。
*/
it("日付範囲: SQL版 = 全件版 + fixtures 版の絞り込み", async () => {
const all = await get("");
const sql = await get("?date_from=2026-09-09&date_to=2026-09-10");
const viaFixtures = filterFixtureCleanLogs(all.items, {
id: null,
date_from: "2026-09-09",
date_to: "2026-09-10",
facility_ids: null,
});
expect(sql.items).toEqual(viaFixtures);
expect(sql.items.length).toBeGreaterThan(0);
});
it("`facility_ids: null`(施設名なし): 件数が一致", async () => {
const all = await get("");
expect(
filterFixtureCleanLogs(all.items, {
id: null,
date_from: null,
date_to: null,
facility_ids: null,
})
).toHaveLength(all.items.length);
});
it("★`facility_ids: []`: どちらも0件★(★片方だけ全件に化けないこと★)", async () => {
const all = await get("");
const sql = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`);
const viaFixtures = filterFixtureCleanLogs(all.items, {
id: null,
date_from: null,
date_to: null,
facility_ids: [],
});
expect(sql.items).toEqual([]);
expect(viaFixtures).toEqual([]);
});
it("施設IDで絞った場合も一致", async () => {
const all = await get("");
const sql = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
const viaFixtures = filterFixtureCleanLogs(all.items, {
id: null,
date_from: null,
date_to: null,
facility_ids: [1, 2],
});
expect(sql.items).toEqual(viaFixtures);
});
});
describe("GET /clean-logs — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => {
it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★(select で robot_id / robot を読まない)", async () => {
const before = await get("");
data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null }));
const res = await GET(new Request("http://localhost/api/clean-logs"));
expect(res.status).toBe(200);
const after = (await res.json()) as ListResponse<CleanLogItem>;
expect(after.items).toEqual(before.items);
});
});
describe("GET /clean-logs — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => {
it("★履歴だけの予定が出る・両方にある同じ予定は1件★。2表に同じ引数が渡る", async () => {
data.historyTimetables = [
// ★履歴だけの本清掃(9/11)とその事前チェック行★
timetable(401, 3, FACILITY_3, "2026-09-11T02:00:00+09:00"),
timetable(4011, 3, FACILITY_3, "2026-09-11T01:00:00+09:00", 60, 401),
// ★両方にある同じ予定(中身も同じ)★
timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"),
];
data.reports.push(report(401, 80, "2026-09-11T03:00:00+09:00"));
const body = await get("");
expect(body.items.map((i) => i.id)).toEqual([401, 301, 103, 201, 102, 101]);
expect(body.items.find((i) => i.id === 401)?.completion_rate).toBe(80);
// ★本清掃の主クエリ(`include` 付き)は2表に同じ引数★
const mainCall = calls.cleanTimetable.find((c) => "include" in c);
const historyCall = calls.cleanTimetableHistory.find((c) => "include" in c);
expect(historyCall).toEqual(mainCall);
// ★事前チェック行は合わせた後の本清掃の id(履歴の 401 を含む)で引く★
const precheckHistoryCall = calls.cleanTimetableHistory.find(
(c) => "where" in c && "parent_id" in (c.where as object)
) as { where: { parent_id: { in: bigint[] } } };
expect(precheckHistoryCall.where.parent_id.in.map(String)).toContain("401");
});
});
/**
* ★出発失敗はアラートに出ない(ユーザー判断 2026-10-29「2で進めてください」)★ —
* 同じデータ(このファイルのスタブ)で、清掃詳細ログには出発失敗(未実施)として出て、
* アラート(`GET /prechecks`)には行が出ないことを突き合わせる。
* ★判定は `notExecutedFromReport()` の1本のまま★(清掃詳細ログの `departed`)。アラートはその判定を使わない。
*/
describe("出発失敗は清掃詳細ログに出て、アラートには出ない(2026-10-29)", () => {
beforeEach(() => {
// ★9/9 02:00 の本清掃(102)からレポートを外す=確定した出発失敗(now は 9/30)★
data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(102));
// ★本清掃 103 の事前チェック(1031)のエラーを1件、アラートに出る形で足す★(アラートが空振りしていないことの確認)
data.errorReports.push({
error_id: "E-PRE-1",
clean_timetable_id: BigInt(1031),
error_type: "EmergencyStop",
errored_at: dbWallClock("2026-09-10T01:06:00+09:00"),
created_at: dbWallClock("2026-09-10T01:06:00+09:00"),
serial_number: "8111R562806001",
facility_id: BigInt(1),
facility: { facility_name: FACILITY_1, deleted_at: null },
section: { deleted_at: null },
status: 0,
responded_at: null,
resolved_at: null,
comment: null,
} as StubErrorReport);
});
async function getPrechecks(search: string): Promise<SummaryListResponse<PrecheckItem, PrecheckSummary>> {
const res = await GET_PRECHECKS(new Request(`http://localhost/api/prechecks${search}`));
expect(res.status).toBe(200);
return (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
}
it("★清掃詳細ログ: 102 は出発失敗(未実施)★", async () => {
const item = (await get("")).items.find((i) => i.id === 102);
expect(item).toMatchObject({
departed: false,
task_status: null,
completion_rate: null,
result_pending: false,
grade: "×",
});
});
it("★アラート: 102 の行は無い。行は事前チェック異常だけ(summary・meta.total も)★", async () => {
const body = await getPrechecks("?status=all");
expect(body.items.map((i) => i.id)).not.toContain("102");
expect(body.items.map((i) => i.id)).toEqual(["E-PRE-1"]);
expect(body.items.every((i) => i.severity === "precheck")).toBe(true);
expect(body.summary).toEqual({ unresolved: 1, resolved: 0, total: 1 });
expect(body.meta.total).toBe(1);
// ★9/9 だけに絞ると、出発失敗(102)があってもアラートは0件★
const sep9 = await getPrechecks("?date_from=2026-09-09&date_to=2026-09-09&status=all");
expect(sep9.items).toEqual([]);
expect(sep9.summary.total).toBe(0);
// 同じ日の清掃詳細ログには出発失敗として出る
const log9 = await get("?date_from=2026-09-09&date_to=2026-09-09");
expect(log9.items.find((i) => i.id === 102)?.departed).toBe(false);
});
});
describe("GET /clean-logs — 結果待ち `result_pending`(D-173。表1・表2 C1)", () => {
// ★施設2の 9/10 02:00〜03:00 の清掃(id 201)からレポートを外し、now を動かす★
beforeEach(() => {
data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201));
});
it("★now=04:59(終了+120分より前)→ R: `result_pending: true`。出発失敗の値は変えない★", async () => {
vi.setSystemTime(new Date("2026-09-10T04:59:00+09:00"));
const body = await get("");
const item = body.items.find((i) => i.id === 201);
expect(item).toMatchObject({
result_pending: true,
departed: false,
task_status: null,
completion_rate: null,
grade: "×",
});
// ★レポートがある行(S)は false★
expect(body.items.find((i) => i.id === 103)?.result_pending).toBe(false);
// ★結果待ちは連続日数に数えない(表2 C10)★
expect(item?.consecutive_problem_days).toBe(0);
expect(item?.escalation_level).toBeNull();
});
it("★now=05:00(終了+120分ちょうど)→ F: `result_pending: false`(確定した出発失敗。表1 P6)★", async () => {
vi.setSystemTime(new Date("2026-09-10T05:00:00+09:00"));
const body = await get("");
const item = body.items.find((i) => i.id === 201);
expect(item).toMatchObject({ result_pending: false, departed: false, completion_rate: null });
expect(item?.consecutive_problem_days).toBe(1);
});
it("★清掃中(now=02:30。開始以降・終了前)も結果待ち(表1 P4)★", async () => {
vi.setSystemTime(new Date("2026-09-10T02:30:00+09:00"));
const body = await get("");
expect(body.items.find((i) => i.id === 201)?.result_pending).toBe(true);
});
});
describe("GET /clean-logs — 事前チェックの列は分数ではなく順番で割り当てる(D-178。ユーザー判断 2026-10-16)", () => {
// ★本清掃 103 の事前チェック行(1031: 60分前・EmergencyStop / 1032: 30分前・エラー無し)の分数だけを差し替える★
function setPrecheckMinutes(byId: Record<number, number>): void {
data.timetables = data.timetables.map((t) =>
byId[Number(t.id)] === undefined ? t : { ...t, precheck: byId[Number(t.id)] }
);
}
async function row103(): Promise<CleanLogItem> {
const r = (await get("")).items.find((i) => i.id === 103);
if (!r) throw new Error("清掃 103 が無い");
return r;
}
function precheckAnomalies() {
return vi
.mocked(logEvent)
.mock.calls.filter(
([, p]) => p.event === "data.anomaly" && p.metric === "precheck_rounds_per_main_run"
);
}
it("60 / 30(設定どおり)→ 1回目 EmergencyStop・60、2回目 Standby.・30", async () => {
const r = await row103();
expect(r).toMatchObject({
status_1h: "EmergencyStop",
status_1h_minutes: 60,
status_30m: "Standby.",
status_30m_minutes: 30,
});
expect(precheckAnomalies()).toHaveLength(0);
});
it("★60 / 10(設定と違う分数)でも10分前の結果を落とさない★(旧: 2列目が null で「-」だった)", async () => {
setPrecheckMinutes({ 1032: 10 });
expect(await row103()).toMatchObject({
status_1h: "EmergencyStop",
status_1h_minutes: 60,
status_30m: "Standby.",
status_30m_minutes: 10,
});
});
it("★事前チェックが1回(30分前)だけなら1回目に入り、2回目は null★", async () => {
data.timetables = data.timetables.filter((t) => Number(t.id) !== 1031);
expect(await row103()).toMatchObject({
status_1h: "Standby.",
status_1h_minutes: 30,
status_30m: null,
status_30m_minutes: null,
});
});
it("事前チェックの無い本清掃は4つとも null", async () => {
const r = (await get("")).items.find((i) => i.id === 101);
expect(r).toMatchObject({
status_1h: null,
status_1h_minutes: null,
status_30m: null,
status_30m_minutes: null,
});
});
it("★3つあれば開始に近い2つを残し、捨てた分数を `data.anomaly` に1回出す★(route / method は載せない)", async () => {
data.timetables.push(timetable(1033, 1, FACILITY_1, "2026-09-10T00:30:00+09:00", 90, 103));
expect(await row103()).toMatchObject({
status_1h: "EmergencyStop",
status_1h_minutes: 60,
status_30m: "Standby.",
status_30m_minutes: 30,
});
const events = precheckAnomalies();
expect(events).toHaveLength(1);
expect(events[0][0]).toBe("warn");
expect(events[0][1]).toEqual({
event: "data.anomaly",
metric: "precheck_rounds_per_main_run",
expected: 2,
actual: 3,
clean_timetable_id: "103",
dropped_precheck_minutes: [90],
});
});
it("★判定(出発・完了率・判定)は事前チェックの割り当てで変わらない★(D-170)", async () => {
const before = await row103();
setPrecheckMinutes({ 1031: 2, 1032: 1 });
const after = await row103();
expect(after).toMatchObject({ status_1h_minutes: 2, status_30m_minutes: 1 });
expect({
departed: after.departed,
completion_rate: after.completion_rate,
grade: after.grade,
consecutive_problem_days: after.consecutive_problem_days,
}).toEqual({
departed: before.departed,
completion_rate: before.completion_rate,
grade: before.grade,
consecutive_problem_days: before.consecutive_problem_days,
});
});
});
describe("GET /clean-logs — 有効なロボットがいない清掃エリア(指示書BB U2・表2)", () => {
/** `data.anomaly` のうち、清掃エリアのロボット台数のものだけ */
function robotAnomalies() {
return vi
.mocked(logEvent)
.mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "active_robots_per_section")
.map(([, p]) => p);
}
it("★R2: 0台でも例外にならず、その行は出る。機種・シリアルは null。ログは清掃エリアごとに1回★", async () => {
// 施設1(清掃エリア 10)の本清掃3件(101〜103)からロボットを外す
for (const t of data.timetables) {
if (Number(t.facility_id) === 1) t.section = { robots: [] };
}
const res = await GET(new Request("http://localhost/api/clean-logs"));
expect(res.status).toBe(200);
const body = (await res.json()) as ListResponse<CleanLogItem>;
expect(body.items.map((i) => [i.id, i.model_name, i.serial_number])).toEqual([
[301, "PUDU CC1", "8111R562806003"],
[103, null, null],
[201, "PUDU CC1", "8111R562806002"],
[102, null, null],
[101, null, null],
]);
// ★同じ清掃エリアの行が3行あっても1回★
expect(robotAnomalies()).toEqual([
{ event: "data.anomaly", metric: "active_robots_per_section", expected: 1, actual: 0, section_id: "10" },
]);
});
it("R1: 1台なら今どおり(ログを出さない)。`robots` の並びは `id` 昇順を SQL に頼む", async () => {
await get("");
expect(robotAnomalies()).toHaveLength(0);
const include = calls.cleanTimetable.find((c) => "include" in c)?.include as {
section: { include: { robots: { orderBy: unknown } } };
};
expect(include.section.include.robots.orderBy).toEqual({ id: "asc" });
});
it("★R5: 有効なロボット2台(id 10 と id 7)→ id 7 を採る。ログ(actual: 2)は清掃エリアごとに1回★", async () => {
for (const t of data.timetables) {
if (Number(t.facility_id) !== 3) continue;
t.section = {
robots: [
{ id: BigInt(10), serial_number: "SERIAL-ID10", device: { maker_id: 1, model_name: "CC1" } },
{ id: BigInt(7), serial_number: "SERIAL-ID7", device: { maker_id: 1, model_name: "CC1" } },
],
};
}
const body = await get("");
expect(body.items.find((i) => i.id === 301)?.serial_number).toBe("SERIAL-ID7");
expect(robotAnomalies()).toEqual([
{ event: "data.anomaly", metric: "active_robots_per_section", expected: 1, actual: 2, section_id: "30" },
]);
});
it("★R7: 0台の行に `serial=` / `model=` で絞ると当たらない(null は部分一致しない)。絞り込みなしなら出る★", async () => {
for (const t of data.timetables) {
if (Number(t.facility_id) === 1) t.section = { robots: [] };
}
const bySerial = await get("?serial=8111R");
expect(bySerial.items.map((i) => i.id)).toEqual([301, 201]);
const byModel = await get(`?model=${encodeURIComponent("pudu")}`);
expect(byModel.items.map((i) => i.id)).toEqual([301, 201]);
expect((await get("")).items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]);
});
});
describe("GET /clean-logs — 削除済みの施設・清掃エリアの行は出さない(指示書BB U1・表1 V1)", () => {
const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");
it("★V1: 予定の2表の `where` に施設・清掃エリアの可視性が入る★", async () => {
await get("");
expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
const history = calls.cleanTimetableHistory.find((c) => "include" in c) as { where: Record<string, unknown> };
expect(history.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
});
it.each([
["施設削除", (t: StubTimetable) => ({ ...t, facility: { ...t.facility, deleted_at: DELETED } })],
["エリア削除", (t: StubTimetable) => ({ ...t, section: { ...t.section, deleted_at: DELETED } })],
] as const)("V1(%s): その行は出ない・meta.total に数えない(生きている行は出る)", async (_label, mark) => {
data.timetables = data.timetables.map((t) => (Number(t.id) === 301 ? mark(t) : t));
const body = await get("");
expect(body.items.map((i) => i.id)).toEqual([103, 201, 102, 101]);
expect(body.meta.total).toBe(4);
});
});
describe("GET /clean-logs — 存在しない日付は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/clean-logs?date_from=2026-02-31"));
const format = await GET(new Request("http://localhost/api/clean-logs?date_from=2026/02/03"));
expect(bad.status).toBe(422);
expect(format.status).toBe(422);
expect(await bad.json()).toEqual(await format.json());
});
});
/**
* ★表10 N5(指示書BB A6・D16)★ — 施設で絞ったとき、`cleanLogs()` は連続日数の入力(`loadConsecutiveInputs()`)にも
* 同じ施設IDを渡す。★応答の `consecutive_problem_days`・`escalation_level` は絞る前と同じ★。
* (ルートの分類の解決はこのテストのスタブでは施設名で行う — 解決の経路は違っても渡る配列は同じ `query.facility_ids`)
*/
describe("GET /clean-logs — 連続日数の入力も施設で絞る(指示書BB 表10 N5)", () => {
/** 連続日数用の予定のクエリ(`precheck` を読む select 付き) */
function consecutiveTimetableCalls() {
return calls.cleanTimetable.filter((c) => {
const select = c.select as Record<string, unknown> | undefined;
return select !== undefined && select.precheck === true && select.facility_id === true;
});
}
it("★施設名で施設1・2に絞る → 連続日数の入力の `where` に `facility_id: { in: [1n, 2n] }`。値は絞る前と同じ★", async () => {
const before = await get("");
const expected = new Map(before.items.map((i) => [i.id, [i.consecutive_problem_days, i.escalation_level]]));
calls.cleanTimetable = [];
const scoped = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
const consecutive = consecutiveTimetableCalls();
expect(consecutive).toHaveLength(1);
expect((consecutive[0].where as Record<string, unknown>).facility_id).toEqual({ in: [BigInt(1), BigInt(2)] });
expect(scoped.items.length).toBeGreaterThan(0);
for (const item of scoped.items) {
expect([item.consecutive_problem_days, item.escalation_level]).toEqual(expected.get(item.id));
}
expect(scoped.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3);
});
it("施設で絞らない → 連続日数の入力にも `facility_id` の条件を付けない", async () => {
await get("");
const consecutive = consecutiveTimetableCalls();
expect(consecutive).toHaveLength(1);
expect(consecutive[0].where).not.toHaveProperty("facility_id");
});
});
/**
* ★予定外タスクの行(指示書BG §1・§2-1 / 表U1)★ — `clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行。
*
* | # | 行 | 期待 |
* |---|---|---|
* | U1-1 | 予定外・`started_at` 9/10 21:30・完了率 95.9 | 1行。`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 |
* | U1-5 | 予定外・削除済みの施設(清掃エリア) | 出さない・数えない |
* | U1-6 | 予定のある行 | 今と同じ(`unscheduled` false・`row_key` "tt-<id>") |
*
* ★日付は表の「10-28 21:00」の代わりに、このファイルの合成データ(9/10)に合わせた★(扱いは同じ)
*/
describe("予定外タスクの行(指示書BG 表U1)", () => {
/** 予定外の清掃結果1行(壁時計値。完了率は `clean_area ÷ task_area × 100`) */
function unscheduledReport(
reportId: string,
facilityId: number,
facilityName: string,
startedJstIso: string,
cleanArea: number,
taskArea: number,
timetableId: bigint | null = null,
status = 4
): StubReport {
const startedAt = dbWallClock(startedJstIso);
return {
report_id: reportId,
facility_id: BigInt(facilityId),
section_id: BigInt(facilityId * 10),
clean_timetable_id: timetableId,
clean_ratio: 0,
status,
started_at: startedAt,
ended_at: new Date(startedAt.getTime() + 60 * 60_000),
clean_area: cleanArea,
task_area: taskArea,
facility: { facility_name: facilityName, deleted_at: null },
section: {
deleted_at: null,
robots: [{ serial_number: `8111R56280600${facilityId}`, device: { maker_id: 1, model_name: "CC1" } }],
},
};
}
const RP1 = "9200000000000000001";
const RP2 = "9200000000000000002";
it("U1-1: 予定外の1行 — unscheduled true・id null・row_key rp-…・出発 true・◎・事前チェック null・scheduled_at は started_at", async () => {
data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000));
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
const item = body.items.find((i) => i.unscheduled);
expect(item).toMatchObject({
id: null,
row_key: `rp-${RP1}`,
report_id: RP1,
unscheduled: true,
facility_id: 3,
facility_name: FACILITY_3,
model_name: "PUDU CC1",
serial_number: "8111R562806003",
scheduled_at: "2026-09-10T21:30: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: "◎",
});
// ★予定のある行と一緒に `scheduled_at` の新しい順で並び、`meta.total` にも数える★(9/10 の予定3行 + 予定外1行)
expect(body.items[0].row_key).toBe(`rp-${RP1}`);
expect(body.meta.total).toBe(4);
});
it("U1-2: 予定外・clean_area 0 → 完了率 0・×・出発 true(未実施ではない)", async () => {
data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 0, 800, null, 6));
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
const item = body.items.find((i) => i.unscheduled);
expect(item).toMatchObject({ completion_rate: 0, grade: "×", departed: true, task_status: 6, result_pending: false });
});
it("U1-3: clean_timetable_id が 0 と NULL のどちらも予定外。0 でも NULL でもないレポートは予定外の行にしない", async () => {
data.reports.push(
unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null),
unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0)),
// ★予定の id を持つ(予定の表に行が無い)レポート★ — 予定外の行にしない
unscheduledReport("9200000000000000003", 3, FACILITY_3, "2026-09-10T23:00:00+09:00", 500, 1000, BigInt(999))
);
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
const unscheduled = body.items.filter((i) => i.unscheduled);
expect(unscheduled.map((i) => i.row_key)).toEqual([`rp-${RP2}`, `rp-${RP1}`]);
expect(unscheduled.map((i) => [i.completion_rate, i.grade, i.departed])).toEqual([
[50, "△", true],
[50, "△", true],
]);
});
it("U1-4: 予定外・task_area 0 → 完了率 0(`runCompletionRate()` と同じ)", async () => {
data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 300, 0));
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
expect(body.items.find((i) => i.unscheduled)).toMatchObject({ completion_rate: 0, grade: "×", departed: true });
});
it("U1-5: 削除済みの施設・清掃エリアの予定外は出さない・数えない", async () => {
const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000);
deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") };
const deletedSection = unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 959, 1000);
deletedSection.section = {
robots: deletedSection.section?.robots ?? [],
deleted_at: dbWallClock("2026-09-01T00:00:00+09:00"),
};
data.reports.push(deletedFacility, deletedSection);
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
expect(body.items.some((i) => i.unscheduled)).toBe(false);
expect(body.meta.total).toBe(3);
// ★SQL の `where` にも可視性の条件が載っている★
const call = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record<string, unknown> };
expect(call.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
});
it("U1-6: 予定のある行は今と同じ(unscheduled false・row_key tt-<id>・report_id null)", async () => {
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
const item = body.items.find((i) => i.id === 103);
expect(item).toMatchObject({ unscheduled: false, row_key: "tt-103", report_id: null });
expect(body.items.every((i) => !i.unscheduled)).toBe(true);
});
it("期間は started_at(JST の日付)で絞る・施設の絞り込みも効く", async () => {
data.reports.push(
unscheduledReport(RP1, 3, FACILITY_3, "2026-09-09T21:30:00+09:00", 959, 1000),
unscheduledReport(RP2, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)
);
const day10 = await get("?date_from=2026-09-10&date_to=2026-09-10");
expect(day10.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP2}`]);
const facility3 = await get(`?facility_name=${encodeURIComponent(FACILITY_3)}`);
expect(facility3.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP1}`]);
});
it("予定外の行の連続日数・L1〜L3 は、施設のその日の値(同じ施設・同じ日の予定のある行と同じ)", async () => {
// 施設1 の 9/10 は 9/8・9/9・9/10 の × で3日連続(L3)。同じ日の予定外 ◎ も同じ値になる
data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
const item = body.items.find((i) => i.unscheduled);
expect(item).toMatchObject({ grade: "◎", consecutive_problem_days: 3, escalation_level: "L3" });
});
it("result=problem の絞り込みも予定のある行と同じ(判定で絞る)", async () => {
data.reports.push(
unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000),
unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000)
);
const problem = await get("?date_from=2026-09-10&date_to=2026-09-10&result=problem");
expect(problem.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP2}`]);
});
it("単一ID(`/clean-logs/{id}/*`)の取得では予定外の行を読まない", async () => {
data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
const rows = await db.cleanLogs({ id: 103, date_from: null, date_to: null, facility_ids: null });
expect(rows.map((r) => r.row_key)).toEqual(["tt-103"]);
expect(calls.cleanTaskReport.filter((c) => "include" in c)).toEqual([]);
});
});