import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` 冒頭の
// 理由と同じ。`it()` の中で `await import()` するとフルスイートでタイムアウトを踏む)
import { GET } from "@/app/api/dashboard/route";
import { CLEAN_TIMETABLE_PRECHECK, D_SEC_ALERT_ERROR_TYPES } from "@/lib/config";
import { db } from "@/lib/db";
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 { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select";
import { dsAlertWhere } from "@/lib/db/ds-alert-mapping";
import type { DashboardResponse } from "@/lib/types";
/**
* GET /dashboard が ★その1日ぶんだけ★ を引いていることを固定する(指示書AC 段階2 D-6)。
* ★`date` 省略(=直近モード。D-159 / 指示書AV)では、表3 の窓(8日ぶん)に絞られていること★と、
* 直近モードの `summary` / `problem_logs` / メタの `where` も固定する(ファイル末尾の `describe`)。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* ★`?date=`(★値が空★)のときに ★全件読みへ戻らない★ こと★。
* `const date = dateRaw ?? todayJst()` と書くと ★`""` は null でも undefined でもない★ ため
* ★空文字がそのまま残り★、`db.cleanLogs({ date_from: "", date_to: "" })` が
* `dbDateRange("", "")` を通って ★`start_at` の条件ごと消え=テーブル全件読み★ になります
* (本番想定で `clean_timetables` 約26万行)。
* ★★レスポンスは後段の日付一致(`logsOfDay`)で空になるので画面からは観測できません★★ —
* ★このテストが唯一の検出手段★です。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
* ★`prismaSource` の経路★ を走らせる(段階2 / 段階4 のルートテストと同じ作法)。
*
* ★★ただしスタブの入れ方だけが兄弟テストと違います★★ —
* 兄弟(`/clean-logs` / `/prechecks`)は `vi.mock("@/lib/db/prisma")` で `getPrisma()` を
* 差し替えていますが、★このルートは3つのデータ源を `Promise.all` で**同時に**引きます★。
* `prismaSource` の各メソッドは `await import("./prisma")` の★遅延読み込み★なので
* (`lib/db/prisma.ts` 冒頭の理由)、★★同じモジュールへの動的 import が同時に走ると
* 一部が差し替え前の実体を掴み★★、`DATABASE_URL` 未設定で 500 になります(実測)。
* そこで ★`getPrisma()` が最初に見るシングルトン `globalThis.__dwatchPrisma` に
* 直接スタブを置きます★ — ★実装(`lib/db/prisma.ts`)の公開された仕組みをそのまま使うので
* DBにも接続しません★。★`afterEach` で必ず片付けること★。
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface StubTimetable {
id: bigint;
facility_id: bigint;
section_id: bigint;
parent_id: bigint;
start_at: Date;
/** ★予定の終了★(直近モードの `cleanLogRunMeta()` が読む。指示書AV 段階4-5。開始+1時間) */
end_at: Date;
precheck: number;
/** ★`deleted_at` は可視性(`FACILITY_SECTION_VISIBLE_WHERE`)の再現用★(指示書BB U1) */
facility: { facility_name: string; deleted_at: Date | null };
section: {
deleted_at: Date | null;
robots: {
serial_number: string;
device: { maker_id: number; model_name: string };
}[];
};
}
/**
* ★清掃結果の取得元は `clean_task_reports`★(指示書AQ 決定2/3。旧 `clean_status_histories`)。
* ★`clean_error` 列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
* `clean_timetable_id` / `ended_at` は nullable(指示書AQ 罠1 / 罠3)。
*/
interface StubReport {
clean_timetable_id: bigint | null;
clean_ratio: number;
status: number;
started_at: Date;
ended_at: Date | null;
clean_area: number;
task_area: number;
}
interface StubData {
timetables: StubTimetable[];
reports: StubReport[];
/** `robot_error_reports` のうち D-Sec アラートの読み取りに返す行(既定は空) */
dsAlertRows: Record<string, unknown>[];
}
/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */
const calls: {
cleanTimetable: Record<string, unknown>[];
cleanTimetableHistory: Record<string, unknown>[];
cleanTaskReport: Record<string, unknown>[];
cleanSchedule: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], cleanSchedule: [] };
let data: StubData;
/** D-Sec アラートの読み取りの `where`(指示書BB 表10 N7) */
let dsAlertWheres: unknown[] = [];
/** 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;
// ★リレーションの条件(`facility: { deleted_at: null }` など。可視性 U1)★ — 行が結合先を持つときだけ再現する
// (持たない行=結合先を `include` しない読み取りは、従来どおり素通し)
if (!("in" in c) && !("gte" in c) && !("lt" in c) && value !== null && typeof value === "object" && !(value instanceof Date)) {
if (!matchesWhere(value as Record<string, unknown>, c)) 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 A2)を見るため `logEvent` だけ差し替える(`withRequestId` はそのまま)★
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
/** `getPrisma()` が最初に見るシングルトンへ差し込むスタブ(上の「スタブの入れ方」を参照) */
function prismaStub(): typeof globalThis.__dwatchPrisma {
return {
cleanTimetable: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
},
// ★`clean_timetable_histories`(指示書AU 段階3b)。★このテストでは空★(2表に同じ引数が渡ることだけ見る)
cleanTimetableHistory: {
findMany: async (args?: Record<string, unknown>) => findMany("cleanTimetableHistory", [], args),
},
cleanTaskReport: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
},
// ★要レスキュー(指示書AI 段階5)が施設名の解決に使う★
// (`loadFacilityNameIndex()`。★`where` を付けない=全件★)
facility: {
findMany: async () => [
{ id: BigInt(1), facility_name: FACILITY_1 },
{ id: BigInt(2), facility_name: FACILITY_2 },
],
},
// ★このテストで中身は要らない(`db.cleanLogs()` の `where` だけを見る)★。
// ★D-Sec アラート(`dsAlerts()`)の読み取りにだけ `dsAlertRows` を返す★(`where` を無視。指示書AX 表1 R10)
robotErrorReport: {
findMany: async (args?: Record<string, unknown>) => {
if (args?.select !== ROBOT_ERROR_DS_ALERT_SELECT) return [];
dsAlertWheres.push(args.where);
guardNullRobotId(data.dsAlertRows as unknown as Record<string, unknown>[], args);
return data.dsAlertRows;
},
},
// ★`clean_schedules`(`schedules()`)。★ダッシュボードはもう読まない★(2026-10-29 案B)— 読まれたら記録に残る(S7)★
cleanSchedule: {
findMany: async (args?: Record<string, unknown>) => {
calls.cleanSchedule.push(args ?? {});
return [];
},
},
// ★`controlErrorHistory` は置かない★ — D-Sec アラートは `robot_error_reports` から読む(指示書AX。P-10 = A)
} as unknown as typeof globalThis.__dwatchPrisma;
}
// ---------------------------------------------------------------- 合成データ
/** 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 = "バリューマート北町店";
/** ★対象日に清掃予定が無い施設★(要レスキューの検証用。指示書AI §1-5) */
const FACILITY_2 = "メガバリューマート緑野店";
function timetable(id: number, facilityId: number, jstIso: string): StubTimetable {
return {
id: BigInt(id),
facility_id: BigInt(facilityId),
section_id: BigInt(facilityId * 10),
parent_id: BigInt(0),
start_at: dbWallClock(jstIso),
// ★開始+1時間★(壁時計値のまま足す。直近モードの「終わった」の判定に使う。指示書AV 段階4-5)
end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
precheck: CLEAN_TIMETABLE_PRECHECK.main,
facility: { facility_name: FACILITY_1, deleted_at: null },
section: {
deleted_at: null,
robots: [
{
serial_number: `8111R56280600${facilityId}`,
device: { maker_id: 1, model_name: "CC1" },
},
],
},
};
}
function report(timetableId: number, cleanRatio: number, jstIso: string): StubReport {
return {
clean_timetable_id: BigInt(timetableId),
clean_ratio: cleanRatio,
// ★意味を持たない任意の生値★(CSV実測で最頻の値。status は判定に使わない。D-155 項2)。
// ★未実施の判定には使われない★(行の有無で決まる。§1-1)
status: 4,
started_at: dbWallClock(jstIso),
ended_at: dbWallClock(jstIso),
clean_area: 100,
task_area: 100,
};
}
function buildData(): StubData {
return {
timetables: [
timetable(101, 1, "2026-09-28T02:00:00+09:00"),
timetable(102, 1, "2026-09-29T02:00:00+09:00"),
// ★`NOW` と同じ日(`todayJst()` が返す日)★
timetable(103, 1, "2026-09-30T02:00:00+09:00"),
// ★★施設2は 09-27〜09-29 が3日連続で未実施、当日(09-30)には予定が無い★★
// (レポートの行を作らない=未実施。`notExecutedFromReport()`。指示書AQ §1-1)—
// ★要レスキューが「当該日のログ」で母集団を絞っていないことの検証用★(指示書AI §1-5)
timetable(201, 2, "2026-09-27T02:00:00+09:00"),
timetable(202, 2, "2026-09-28T02:00:00+09:00"),
timetable(203, 2, "2026-09-29T02:00:00+09:00"),
],
reports: [
report(101, 95, "2026-09-28T03:00:00+09:00"),
report(102, 95, "2026-09-29T03:00:00+09:00"),
report(103, 95, "2026-09-30T03:00:00+09:00"),
],
dsAlertRows: [],
};
}
/** ★JST の 2026-09-30★(`todayJst()` がこの日を返す) */
const NOW = new Date("2026-09-30T09:00:00+09:00");
const TODAY = "2026-09-30";
async function get(search: string): Promise<DashboardResponse> {
const res = await GET(new Request(`http://localhost/api/dashboard${search}`));
return (await res.json()) as DashboardResponse;
}
/** 最後に発行された `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;
}
/** ★その1日ぶんに絞られているか★ を「壁時計値の範囲」で確かめる */
function expectSingleDayRange(where: Record<string, unknown>, date: string): void {
expectDayRange(where, date, date);
}
/** ★`from`〜`to`(両端を含む)に絞られているか★ を「壁時計値の範囲」で確かめる */
function expectDayRange(where: Record<string, unknown>, from: string, to: string): void {
expect(where).toHaveProperty("start_at");
const startAt = where.start_at as { gte: Date; lt: Date };
// ★★`toDbJst()` を通した壁時計値であること★★(C-6。★生の `parseDate()` ではない★)
expect(startAt.gte.toISOString()).toBe(`${from}T00:00:00.000Z`);
expect(startAt.lt.toISOString()).toBe(
new Date(Date.parse(`${to}T00:00:00.000Z`) + 86_400_000).toISOString()
);
}
/** ★直近モードの窓(指示書AV 表3 W4。`NOW` = 2026-09-30 09:00 JST)★ */
const LATEST_WINDOW = { from: "2026-09-23", to: TODAY } as const;
/**
* ★メタ(`cleanLogRunMeta()`)の呼び出し★ = `select` のキーがちょうど `id` / `section_id` / `end_at` の3つ。
* ★「`select` 付き」だけでは見分けられない★ — 要レスキュー(`loadConsecutiveInputs()` など)も
* `cleanTimetable.findMany` を `select` 付きで呼ぶ(指示書AV 段階4-5)。
*/
function metaCalls(): Record<string, unknown>[] {
return calls.cleanTimetable.filter((c) => {
const select = c.select as Record<string, unknown> | undefined;
return select !== undefined && Object.keys(select).sort().join(",") === "end_at,id,section_id";
});
}
beforeEach(() => {
data = buildData();
calls.cleanTimetable = [];
calls.cleanTimetableHistory = [];
calls.cleanTaskReport = [];
calls.cleanSchedule = [];
dsAlertWheres = [];
globalThis.__dwatchPrisma = prismaStub();
vi.mocked(logEvent).mockClear();
// ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
// ★★他のテストへ漏らさないこと★★(`getPrisma()` はここを最初に見る)
globalThis.__dwatchPrisma = undefined;
vi.useRealTimers();
});
// ---------------------------------------------------------------- テスト
describe("GET /dashboard — その1日ぶんだけを引く(指示書AC 段階2 D-6)", () => {
it("`date` を指定すると SQL の `where` にその1日の `start_at` が降りる", async () => {
await get("?date=2026-09-29");
expectSingleDayRange(mainQueryWhere(), "2026-09-29");
});
it("★予定は2表から読み、履歴にも同じ引数が渡る★(指示書AU 段階3b。第11回 P-7)", async () => {
await get("?date=2026-09-29");
const mainCall = calls.cleanTimetable.find((c) => "include" in c);
expect(calls.cleanTimetableHistory.find((c) => "include" in c)).toEqual(mainCall);
});
it("直近モード(`date` 省略)は表3 W4 の窓(2026-09-23〜2026-09-30)で `start_at` が絞られる(指示書AV)", async () => {
await get("");
expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
});
it("★★`?date=`(★空文字★)でも直近モードの窓に倒れ、全件読みに戻らない★★", async () => {
// ★`??` で書くと `""` がそのまま残り、`start_at` のキーごと消えて
// ★テーブル全件読み★ になる(本番想定で約26万行)。
// ★★レスポンスからは観測しにくい★★ので、★`start_at` の条件が消えないこと★をここで見る
const body = await get("?date=");
expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
// ★当日の行(103)が拾えていること=空振りしていないこと★
const { success, failed, not_executed } = body.summary;
expect(success + failed + not_executed).toBeGreaterThan(0);
});
it("★`?date=` と `date` 省略が同じ結果になる★", async () => {
const omitted = await get("");
const empty = await get("?date=");
expect(empty).toEqual(omitted);
});
it("形式が不正な `date` は 422 で弾かれ、`db` まで来ない", async () => {
const res = await GET(new Request("http://localhost/api/dashboard?date=2026/09/30"));
expect(res.status).toBe(422);
expect(calls.cleanTimetable).toHaveLength(0);
expect(calls.cleanTimetableHistory).toHaveLength(0);
});
});
describe("GET /dashboard — 要レスキュー店舗(指示書AI 段階5)", () => {
it("★当該日に清掃予定が無い施設も出る(評価日は直前のスケジュール存在日)★", async () => {
const body = await get(`?date=${TODAY}`);
// ★施設2は 09-30 に予定が無い★ — 当該日のログで母集団を絞っていたら消える
expect(body.rescue_required).toHaveLength(1);
expect(body.rescue_required[0]).toMatchObject({
facility_id: 2,
facility_name: FACILITY_2,
evaluated_on: "2026-09-29",
reasons: ["not_executed_streak", "escalation_l3"],
not_executed_days: 3,
consecutive_problem_days: 3,
});
});
it("★数値カード(summary)は要レスキューの影響を受けない★", async () => {
const body = await get(`?date=${TODAY}`);
// ★当日のログは施設1の1行だけ★(施設2の未実施3日は分母に入らない。ユーザー指示 2026-09-15)
const { success, failed, not_executed } = body.summary;
expect([success, failed, not_executed]).toEqual([1, 0, 0]);
});
it("要レスキューが0件でも `rescue_required` のキーは落とさない", async () => {
// ★施設2の3日連続が始まる前の日を指定する★(=どの施設も条件に当たらない)
const body = await get("?date=2026-09-28");
expect(body.rescue_required).toEqual([]);
});
});
describe("GET /dashboard — 直近モード(D-159 / 指示書AV 段階4-5)", () => {
// ★期待値は指示書AV で導いた値(§11 Q10 で監督が確定)★:
// 施設1(エリア10)は 09-30 02:00〜03:00 が一番新しい終わった予定 = 成功(完了率100)。
// 施設2(エリア20)は 09-29 02:00〜03:00 = レポートが無い = 未実施。
it("summary は各清掃エリアの直近の清掃(成功1・未実施1)", async () => {
const body = await get("");
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
});
it("同じデータで `?date=2026-09-30` はその日の行だけ([1, 0, 0] のまま)", async () => {
const body = await get(`?date=${TODAY}`);
const { success, failed, not_executed } = body.summary;
expect([success, failed, not_executed]).toEqual([1, 0, 0]);
});
it("problem_logs は直近モードで施設2の 09-29 の1行だけ。`?date=2026-09-30` では []", async () => {
const latest = await get("");
expect(latest.problem_logs.map((r) => r.id)).toEqual([203]);
expect(latest.problem_logs[0]).toMatchObject({
facility_id: 2,
scheduled_at: "2026-09-29T02:00:00+09:00",
departed: false,
// ★`capCleanLogs()` を通っている★(指示書AV D13)
consecutive_problem_days_capped: false,
});
const ofDay = await get(`?date=${TODAY}`);
expect(ofDay.problem_logs).toEqual([]);
});
it("★メタの `where` は清掃ログ(`include` 付きの1本目)の `where` と同じ★(4-8)", async () => {
await get("");
const meta = metaCalls();
expect(meta).toHaveLength(1);
expect(meta[0].where).toEqual(mainQueryWhere());
});
it("★E1′: メタが1行ぶん欠けても 200。その行は選ばれず、`data.anomaly` を1回(row_ids つき)★(指示書BB A2)", async () => {
// ★`cleanLogs()` と `cleanLogRunMeta()` の間に行が増えた状態★ — 施設1の 09-30(103)のメタだけ返さない
const original = db.cleanLogRunMeta.bind(db);
const spy = vi
.spyOn(db, "cleanLogRunMeta")
.mockImplementation(async (query) => (await original(query)).filter((m) => m.row_key !== "tt-103"));
try {
const res = await GET(new Request("http://localhost/api/dashboard"));
expect(res.status).toBe(200);
const body = (await res.json()) as DashboardResponse;
// ★103 が選ばれないので、施設1は1つ前の 09-29(102。成功)が直近になる★
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
const events = vi
.mocked(logEvent)
.mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing");
expect(events).toHaveLength(1);
expect(events[0][1]).toEqual({
event: "data.anomaly",
route: "/api/dashboard",
method: "GET",
metric: "clean_log_run_meta_missing",
expected: 0,
actual: 1,
// ★`row_key` を出す★(指示書BG §2-2。予定外の行は id が無いため。旧: "103")
row_ids: ["tt-103"],
});
} finally {
spy.mockRestore();
}
});
it("メタが揃っていれば `clean_log_run_meta_missing` を出さない", async () => {
await get("");
const events = vi
.mocked(logEvent)
.mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing");
expect(events).toHaveLength(0);
});
it("★`?date=2026-09-29` ではメタを読まない★(0回)", async () => {
await get("?date=2026-09-29");
expect(metaCalls()).toHaveLength(0);
});
});
/**
* ★スケジュール進捗(ユーザー判断 2026-10-29「案B」)★ — 期待値表(`NOW` = 2026-09-30 09:00 JST):
* | # | 指定 | 対象日 | 対象日の本清掃 | planned | completed | rate |
* |---|---|---|---|---|---|---|
* | S1 | 省略(直近モード) | 09-29(前日) | 102(出発)/ 203(未実施) | 2 | 1 | 50 |
* | S2 | `?date=2026-09-29` | 09-29 | 同上 | 2 | 1 | 50 |
* | S3 | `?date=2026-09-30` | 09-30 | 103(出発) | 1 | 1 | 100 |
* | S4 | `?date=2026-09-30` + 施設3の結果待ち 301 | 09-30 | 103 / 301(結果待ち) | 2 | 1 | 50 |
* | S5 | `?date=2026-10-01`(予定なし) | 10-01 | なし | 0 | 0 | null |
* | S6 | 省略 + 施設2が削除済み | 09-29 | 102 だけ(203 は見えない) | 1 | 1 | 100 |
* | S7 | 省略 + `clean_schedules` に毎日の予定7件 | 09-29 | 102 / 203 | 2 | 1 | 50(★曜日の設定を数えない★) |
*/
describe("GET /dashboard — スケジュール進捗(2026-10-29 案B)", () => {
it("S1 直近モードの対象日は前日(09-29)。当日(09-30)の行で数えない", async () => {
const body = await get("");
expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 2, completed: 1, rate: 50 });
});
it("S2 `?date=2026-09-29` は S1 と同じ", async () => {
const latest = await get("");
const ofDay = await get("?date=2026-09-29");
expect(ofDay.schedule_progress).toEqual(latest.schedule_progress);
});
it("S3 `?date=2026-09-30` はその日(前日にしない)", async () => {
const body = await get(`?date=${TODAY}`);
expect(body.schedule_progress).toEqual({ date: TODAY, planned: 1, completed: 1, rate: 100 });
});
it("S4 結果待ちは planned にだけ入る", async () => {
data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
const body = await get(`?date=${TODAY}`);
expect(body.schedule_progress).toEqual({ date: TODAY, planned: 2, completed: 1, rate: 50 });
});
it("S5 対象日に予定が無ければ planned 0・rate null", async () => {
const body = await get("?date=2026-10-01");
expect(body.schedule_progress).toEqual({ date: "2026-10-01", planned: 0, completed: 0, rate: null });
});
it("S6 削除済みの施設の予定は数えない", async () => {
for (const t of data.timetables) {
if (t.facility_id === BigInt(2)) t.facility.deleted_at = dbWallClock("2026-09-01T00:00:00+09:00");
}
const body = await get("");
expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 1, completed: 1, rate: 100 });
});
it("S7 `clean_schedules`(曜日の設定)は読まない — 予定件数は予定の表の行から", async () => {
const body = await get("");
expect(calls.cleanSchedule).toHaveLength(0);
expect(body.schedule_progress.planned).toBe(2);
await get(`?date=${TODAY}`);
expect(calls.cleanSchedule).toHaveLength(0);
});
});
/**
* ★結果待ち(D-173。ユーザー判断 2026-10-07)★ — 表2 C6・C7(監督確定)。
* `NOW` = 09-30 09:00。★施設3の 09-30 07:30〜08:30 にレポートが無い★ —
* ★終了+`RESULT_WAIT_MINUTES`(120分)= 10:30 より前なので結果待ち★(`isResultPending()` 表1 P5 と同じ形)。
* ★終了(08:30)≤ 現在(09:00)なので直近の候補になる★(C6。30分待ちはやめた)。
* ★`result_pending` はデータソース(`prismaSource.cleanLogs()`)が詰める★ — ここでは数え方と `problem_logs` だけを見る。
*/
describe("GET /dashboard — 結果待ち(表2 C6・C7。D-173)", () => {
beforeEach(() => {
data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
});
it("C6 直近モード: 施設3の結果待ちは pending に数え、未実施・成功率の分母に入れない", async () => {
const body = await get("");
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 1, success_rate: 50 });
});
it("C7 problem_logs に結果待ちの行を出さない(確定した出発失敗 203 は今までどおり出す)", async () => {
const body = await get("");
expect(body.problem_logs.map((r) => r.id)).toEqual([203]);
});
it("C5・C7 `?date=2026-09-30` でも同じ(pending 1・problem_logs は空)", async () => {
const body = await get(`?date=${TODAY}`);
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 0, pending: 1, success_rate: 100 });
expect(body.problem_logs).toEqual([]);
});
});
/**
* ★D-Sec アラートの想定外の状態でダッシュボードを落とさない★(指示書AX 表1 R10・D3)。
* `db.dsAlerts()` はダッシュボードも呼ぶ。`status` 3 の行を状態の変換に通すと例外 → 全体が500になる。
* ★スタブは where を無視して返す★(SQL の `status IN` が効かなかった状態。正は純粋関数)。
*/
describe("GET /dashboard — D-Sec アラート(指示書AX 表1 R10)", () => {
it("★status 3 の D-S_CONTROL_FAILURE 行(robot_id NULL)が返っても 200・バナーは空★", async () => {
data.dsAlertRows = [
{
error_id: "R-10",
robot_id: null,
clean_timetable_id: null,
serial_number: "DFAS010001",
facility_id: BigInt(1),
facility: { facility_name: FACILITY_1, deleted_at: null },
section: { section_name: "1F売り場", deleted_at: null },
error_type: D_SEC_ALERT_ERROR_TYPES[0],
errored_at: dbWallClock("2026-09-30T02:00:00+09:00"),
created_at: dbWallClock("2026-09-30T02:01:00+09:00"),
status: 3,
responded_at: null,
resolved_at: null,
comment: null,
},
];
const res = await GET(new Request(`http://localhost/api/dashboard?date=${TODAY}`));
expect(res.status).toBe(200);
const body = (await res.json()) as DashboardResponse;
expect(body.security_banner).toEqual([]);
});
});
describe("GET /dashboard — 削除済みの施設・清掃エリア(指示書BB U1・表1 V4/V5)", () => {
it("★V4: 清掃ログ・直近のメタの `where` に可視性が入る★(V5 の予定件数も同じ清掃ログから数える。2026-10-29 案B)", async () => {
await get("");
expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
for (const m of metaCalls()) expect(m.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
// ★予定件数は `clean_schedules` を読まない★(スケジュール進捗の describe の S6・S7)
expect(calls.cleanSchedule).toHaveLength(0);
});
});
describe("GET /dashboard — 存在しない日付は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/dashboard?date=2026-02-31"));
const format = await GET(new Request("http://localhost/api/dashboard?date=2026/02/03"));
expect(bad.status).toBe(422);
expect(format.status).toBe(422);
expect(await bad.json()).toEqual(await format.json());
});
});
// ★表10 N7(指示書BB A6・D17)★ — ダッシュボードの D-Sec は解消でないものだけ読む(バナーは解消でないものしか見ない)
describe("GET /dashboard — D-Sec アラートは未解消だけ読む(指示書BB 表10 N7)", () => {
it("★`dsAlerts({ open_only: true })` — SQL の status は未解消・対応中(0/1)だけ★", async () => {
await get("");
expect(dsAlertWheres).toHaveLength(1);
expect(dsAlertWheres[0]).toEqual(dsAlertWhere({ open_only: true }));
expect((dsAlertWheres[0] as { status: { in: number[] } }).status.in).toEqual([0, 1]);
});
});