import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { db } from "@/lib/db";
import {
errorReportItemFromRow,
errorReportKeyFromRow,
isVisibleErrorRow,
} from "@/lib/db/error-report-mapping";
import {
applyFixtureErrorReportPatch,
resetFixtureErrorReportOverrides,
withFixtureErrorReportOverride,
} from "@/lib/db/fixture-error-report-overrides";
import { PAGINATION, PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { errorListIndex } from "@/lib/db/error-list-source";
import { errorLists } from "@/lib/fixtures/error-lists";
import { errorReportRows, errorReportTimetables } from "@/lib/fixtures/error-reports";
import { facilities } from "@/lib/fixtures/facilities";
import type { ErrorReportDbQuery, PrecheckStatusPatch } from "@/lib/types";
/**
* fixtures のエラー一覧(`lib/fixtures/error-reports.ts`)と上書き表(`lib/db/fixture-error-report-overrides.ts`)
* (指示書AW 段階3-6-2)。
*
* ★`fixtureSource` を通す★ — `USE_FIXTURES` を `true` に固定して `db` を読む
* (`lib/db/index.ts` の `db` はモジュール読み込み時に束縛される)。
* ★テストごとに `resetFixtureErrorReportOverrides()` で空に戻す★。
*/
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
const BASE: ErrorReportDbQuery = {
include_resolved: false,
facility_ids: null,
date_from: null,
date_to: null,
error_type: "",
page: 1,
per_page: PAGINATION.defaultPerPage,
};
const timetableById = new Map(errorReportTimetables.map((t) => [t.id.toString(), t]));
const timetableFor = (id: bigint | null) => (id === null ? undefined : timetableById.get(id.toString()));
beforeEach(() => {
resetFixtureErrorReportOverrides();
});
afterEach(() => {
vi.useRealTimers();
resetFixtureErrorReportOverrides();
});
describe("fixtures の20行(lib/fixtures/error-reports.ts)", () => {
it("20行・id は FX-ERR-01〜20 で重複が無い", () => {
const ids = errorReportRows.map((r) => r.error_id);
expect(ids).toHaveLength(20);
expect(new Set(ids).size).toBe(20);
expect(ids).toEqual(Array.from({ length: 20 }, (_, i) => `FX-ERR-${String(i + 1).padStart(2, "0")}`));
});
it("★見える行は18行(FX-ERR-18 は削除済みの施設・19 は削除済みの清掃エリア)★", () => {
const invisible = errorReportRows.filter((r) => !isVisibleErrorRow(r)).map((r) => r.error_id);
expect(invisible).toEqual(["FX-ERR-18", "FX-ERR-19"]);
const e18 = errorReportRows.find((r) => r.error_id === "FX-ERR-18")!;
expect(e18.facility.deleted_at).not.toBeNull();
expect(e18.section.deleted_at).toBeNull();
// ★ダミー施設は facilities の fixture に足さない★(施設一覧・分類に出さない)
expect(facilities.some((f) => BigInt(f.id) === e18.facility_id)).toBe(false);
const e19 = errorReportRows.find((r) => r.error_id === "FX-ERR-19")!;
expect(e19.facility.deleted_at).toBeNull();
expect(e19.section.deleted_at).not.toBeNull();
});
it.each(errorReportRows.map((r) => [r.error_id, r] as const))(
"%s は errorReportKeyFromRow() / errorReportItemFromRow() を例外なく通る",
(_id, row) => {
expect(() => errorReportKeyFromRow(row)).not.toThrow();
expect(() => errorReportItemFromRow(row, timetableFor(row.clean_timetable_id), errorListIndex(errorLists))).not.toThrow();
}
);
it("施設名は facilities から id で引いた値(直書きしない。削除済みのダミー施設 FX-ERR-18 は除く)", () => {
for (const row of errorReportRows.filter((r) => r.error_id !== "FX-ERR-18")) {
const facility = facilities.find((f) => BigInt(f.id) === row.facility_id);
expect(row.facility.facility_name).toBe(facility?.facility_name);
}
});
it("表1 との対応: 予定の4区分・生値のシリアル・種類", async () => {
const { items } = await db.errorReports({ ...BASE, include_resolved: true });
const byId = new Map(items.map((item) => [item.id, item]));
expect(byId.get("FX-ERR-01")?.schedule_link).toBe("main");
expect(byId.get("FX-ERR-02")).toMatchObject({
schedule_link: "precheck",
precheck_minutes: PRECHECK_MINUTES_BEFORE[1],
resolution_status: "in_progress",
});
expect(byId.get("FX-ERR-03")?.resolution_status).toBe("resolved");
expect(byId.get("FX-ERR-04")).toMatchObject({ schedule_link: "none", clean_timetable_id: null });
expect(byId.get("FX-ERR-05")?.schedule_link).toBe("unknown");
expect(byId.get("FX-ERR-06")?.schedule_link).toBe("main");
// E7: errored_at が NULL → 検知時刻は created_at
const e7 = errorReportRows.find((r) => r.error_id === "FX-ERR-07");
expect(e7?.errored_at).toBeNull();
expect(byId.get("FX-ERR-07")?.detected_at).toBe(errorReportKeyFromRow(e7!).detected_at);
expect(byId.get("FX-ERR-08")).toMatchObject({ serial_number: "---", error_type: "SECURITY_ON" });
// ★10 は設定に無い分数(生の分数で出す)★
expect(byId.get("FX-ERR-09")).toMatchObject({ schedule_link: "precheck", precheck_minutes: 10 });
expect(byId.get("FX-ERR-10")).toMatchObject({ serial_number: "DS-001", error_type: "D-S_CONTROL_FAILURE" });
expect(byId.get("FX-ERR-11")).toMatchObject({ serial_number: "---", error_type: "NOT_STARTED", error_level: "CRITICAL" });
// R11: ★シリアルは施設2の制御盤・行の施設は3★
expect(byId.get("FX-ERR-16")).toMatchObject({ serial_number: "DS-007", facility_id: 3 });
});
it("★clean_timetable_id = 0(予定外のタスク。TeamWorks 回答 2026-10-27)は NULL と同じく「なし」・API の clean_timetable_id は null★", async () => {
const raw = errorReportRows.find((r) => r.error_id === "FX-ERR-20");
// ★fixtures の生値は 0(NULL に置き換えていない)★
expect(raw?.clean_timetable_id).toBe(BigInt(0));
const { items } = await db.errorReports({ ...BASE, include_resolved: true });
// ★「予定不明」(unknown)に化けないこと★(0 は本表にも履歴にも無い id)
expect(items.find((i) => i.id === "FX-ERR-20")).toMatchObject({
schedule_link: "none",
precheck_minutes: null,
clean_timetable_id: null,
});
});
it("robot_id が NULL の行(制御盤・セキュリティ・清掃開始失敗)を持つ", () => {
const nullRobot = errorReportRows.filter((r) => r.robot_id === null).map((r) => r.error_id);
expect(nullRobot).toEqual([
"FX-ERR-08",
"FX-ERR-10",
"FX-ERR-11",
"FX-ERR-12",
"FX-ERR-13",
"FX-ERR-14",
"FX-ERR-15",
"FX-ERR-16",
"FX-ERR-17",
"FX-ERR-19",
]);
});
});
describe("fixtureSource.errorReports()", () => {
it("★既定で15行(FX-ERR-03・14・15 は出ない)・「解消済みも表示」で18行(FX-ERR-18・19 はどちらでも出ない)★", async () => {
const def = await db.errorReports(BASE);
expect(def.total).toBe(15);
const defIds = def.items.map((i) => i.id);
for (const id of ["FX-ERR-03", "FX-ERR-14", "FX-ERR-15", "FX-ERR-18", "FX-ERR-19"]) {
expect(defIds).not.toContain(id);
}
const all = await db.errorReports({ ...BASE, include_resolved: true });
expect(all.total).toBe(18);
expect(all.items).toHaveLength(18);
expect(all.items.map((i) => i.id)).not.toContain("FX-ERR-18");
expect(all.items.map((i) => i.id)).not.toContain("FX-ERR-19");
});
it("並びは検知時刻の降順(表の時刻から手で導いた順。同値は id の昇順)", async () => {
const { items } = await db.errorReports({ ...BASE, include_resolved: true });
expect(items.map((i) => i.id)).toEqual([
"FX-ERR-07", // 当日 06:40(created_at)
"FX-ERR-12", // 当日 02:55(created_at)
"FX-ERR-16", // 当日 01:30
"FX-ERR-17", // 当日 01:30(同値 → id 昇順)
"FX-ERR-05", // 前日 23:50
"FX-ERR-11", // 前日 23:30
"FX-ERR-01", // 前日 23:10
"FX-ERR-13", // 前日 23:10(同値 → id 昇順)
"FX-ERR-08", // 前日 22:55
"FX-ERR-09", // 前日 22:50
"FX-ERR-10", // 前日 22:45
"FX-ERR-02", // 前日 22:30
"FX-ERR-04", // 前日 15:20
"FX-ERR-20", // 前日 14:40
"FX-ERR-14", // 前日 03:55
"FX-ERR-03", // 2日前 23:40
"FX-ERR-15", // 2日前 02:00
"FX-ERR-06", // 3日前 23:05
]);
});
it("ページ分け: total はページ分け前・範囲外のページは空", async () => {
const page2 = await db.errorReports({ ...BASE, per_page: 3, page: 2 });
const all = await db.errorReports(BASE);
expect(page2.total).toBe(15);
expect(page2.items.map((i) => i.id)).toEqual(all.items.slice(3, 6).map((i) => i.id));
const far = await db.errorReports({ ...BASE, page: 99 });
expect(far).toEqual({ items: [], total: 15 });
});
it("★errorReportById() は削除済みの施設・清掃エリアの行で null(= PATCH は404)★", async () => {
expect(await db.errorReportById("FX-ERR-18")).toBeNull();
expect(await db.errorReportById("FX-ERR-19")).toBeNull();
// 同じ施設2の生きている清掃エリアの行は出続ける(表5 V9)
expect(await db.errorReportById("FX-ERR-04")).not.toBeNull();
});
it("★errorReportById() は `status` が 0/1/2 以外の行で null(= PATCH は404。指示書BB U3・表3 S4/S7)★", async () => {
// ★fixtures のデータは足さない★ — テストの中だけで1行足して、終わったら外す
const base = errorReportRows.find((r) => r.error_id === "FX-ERR-04");
if (!base) throw new Error("fixtures に FX-ERR-04 がありません");
errorReportRows.push({ ...base, error_id: "FX-ERR-ODD", status: 3 });
try {
expect(await db.errorReportById("FX-ERR-ODD")).toBeNull();
// ★一覧にも出ない★(`errorReportStatusCodes()` の絞り込みと `errorReportItemsInPageOrder()` の両方)
const all = await db.errorReports({ ...BASE, include_resolved: true });
expect(all.items.map((i) => i.id)).not.toContain("FX-ERR-ODD");
} finally {
errorReportRows.splice(errorReportRows.findIndex((r) => r.error_id === "FX-ERR-ODD"), 1);
}
});
it("★facility_ids = [] → 0件(罠1)★", async () => {
expect(await db.errorReports({ ...BASE, facility_ids: [] })).toEqual({ items: [], total: 0 });
});
it("error_type=secur → FX-ERR-08 だけ", async () => {
const { items } = await db.errorReports({ ...BASE, error_type: "secur" });
expect(items.map((i) => i.id)).toEqual(["FX-ERR-08"]);
});
});
describe("上書き表(applyFixtureErrorReportPatch)", () => {
it("★パッチが errorReports() / errorReportById() に反映される(事前チェック以外の行にも書ける)★", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-06T09:00:00+09:00"));
const patch: PrecheckStatusPatch = { status: "in_progress", responded_at: new Date(), note: "現場に連絡" };
expect(applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-08", patch)).toEqual({ matched: 1 });
const item = await db.errorReportById("FX-ERR-08");
expect(item).toMatchObject({
resolution_status: "in_progress",
responded_at: "2026-10-06T09:00:00+09:00",
note: "現場に連絡",
});
const { items } = await db.errorReports(BASE);
expect(items.find((i) => i.id === "FX-ERR-08")).toEqual(item);
});
it("db.updateErrorReportColumns() も同じ上書き表に書く・解消にすると既定の一覧から外れる", async () => {
expect(await db.updateErrorReportColumns("FX-ERR-01", { status: "resolved" })).toEqual({ matched: 1 });
expect((await db.errorReports(BASE)).total).toBe(14);
expect((await db.errorReportById("FX-ERR-01"))?.resolution_status).toBe("resolved");
});
it("上書きは重なる(後のパッチで変えない列は前の値のまま)・null は消す", async () => {
applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { note: "一回目" });
applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { status: "resolved", resolved_at: new Date("2026-10-06T10:00:00+09:00") });
let item = await db.errorReportById("FX-ERR-02");
expect(item).toMatchObject({ note: "一回目", resolution_status: "resolved", resolved_at: "2026-10-06T10:00:00+09:00" });
applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { note: null });
item = await db.errorReportById("FX-ERR-02");
expect(item?.note).toBeNull();
});
it("★元の配列の要素は書き換えない★", () => {
const before = errorReportRows.find((r) => r.error_id === "FX-ERR-01");
const snapshot = { ...before };
applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-01", { status: "resolved", note: "x" });
expect(before).toEqual(snapshot);
expect(withFixtureErrorReportOverride(before!)).not.toBe(before);
});
it("resetFixtureErrorReportOverrides() で元に戻る", async () => {
applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-04", { status: "resolved" });
expect((await db.errorReportById("FX-ERR-04"))?.resolution_status).toBe("resolved");
resetFixtureErrorReportOverrides();
expect((await db.errorReportById("FX-ERR-04"))?.resolution_status).toBe("unresolved");
});
it("★存在しない id・大小文字違いは matched: 0(errorReportById() も null)★", async () => {
expect(applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-99", { status: "resolved" })).toEqual({ matched: 0 });
expect(applyFixtureErrorReportPatch(errorReportRows, "fx-err-01", { status: "resolved" })).toEqual({ matched: 0 });
expect(await db.errorReportById("fx-err-01")).toBeNull();
expect((await db.errorReportById("FX-ERR-01"))?.resolution_status).toBe("unresolved");
});
it("★書ける5列(4列+error_list_id)以外のキー・空パッチは例外(Prisma 版と同じ歯止め)★", () => {
const bad = { status: "resolved", updated_at: new Date() } as unknown as PrecheckStatusPatch;
expect(() => applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-01", bad)).toThrow();
expect(() => applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-01", {})).toThrow();
});
});
// ---------------------------------------------------------------- エラー内容(指示書BD §5-2。D-184)
describe("fixtures のエラー内容(error_list_id。指示書BD §5-2)", () => {
it("★値があるのは5行だけ(有効 40/39/37・その他 131・error_lists に無い 9999)、他は null★", () => {
const withValue = errorReportRows
.filter((r) => r.error_list_id !== null)
.map((r) => [r.error_id, Number(r.error_list_id)]);
expect(withValue).toEqual([
["FX-ERR-01", 40],
["FX-ERR-03", 39],
["FX-ERR-05", 9999],
["FX-ERR-06", 37],
["FX-ERR-11", 131],
]);
});
it("★選べない行(D-S_CONTROL_FAILURE・SECURITY_ON)と NOT_STARTED の行が fixtures にある・選べない行には値が無い★", () => {
const types = new Set(errorReportRows.map((r) => r.error_type));
for (const t of ["D-S_CONTROL_FAILURE", "SECURITY_ON", "NOT_STARTED"]) expect(types.has(t)).toBe(true);
const unselectableWithValue = errorReportRows.filter(
(r) => (r.error_type === "D-S_CONTROL_FAILURE" || r.error_type === "SECURITY_ON") && r.error_list_id !== null
);
expect(unselectableWithValue).toEqual([]);
});
it("★その他(131)の行(FX-ERR-11)は備考がある★(備考必須の選択肢)", () => {
expect(errorReportRows.find((r) => r.error_id === "FX-ERR-11")?.comment).toBeTruthy();
});
it("errorReports() の3項目(解決・不明・未選択・選べるか)", async () => {
const { items } = await db.errorReports({ ...BASE, include_resolved: true });
const byId = new Map(items.map((item) => [item.id, item]));
expect(byId.get("FX-ERR-01")).toMatchObject({
error_list_id: 40,
error_list: {
id: 40,
error_category_main_en: "CLEAN ROBOT ERROR",
error_category_main_ja: "清掃ロボットエラー",
error_category_sub_en: "operation",
error_category_sub_ja: "操作",
error_detail_en: "Emergency stop switch triggered",
note_required: false,
},
error_list_selectable: true,
});
expect(byId.get("FX-ERR-03")?.error_list?.error_detail_ja).toBe("ハンドルが持ち上げられました");
// ★error_lists に無い id → error_list は null・error_list_id は値を保つ★
expect(byId.get("FX-ERR-05")).toMatchObject({ error_list_id: 9999, error_list: null, error_list_selectable: true });
// ★NOT_STARTED は選べる・その他(131)は note_required★
expect(byId.get("FX-ERR-11")).toMatchObject({
error_list_id: 131,
error_list: { id: 131, error_category_main_ja: "その他(該当なし)", note_required: true },
error_list_selectable: true,
});
// 未選択
expect(byId.get("FX-ERR-02")).toMatchObject({ error_list_id: null, error_list: null, error_list_selectable: true });
// ★選べない行★
expect(byId.get("FX-ERR-08")).toMatchObject({ error_type: "SECURITY_ON", error_list_selectable: false });
expect(byId.get("FX-ERR-10")).toMatchObject({ error_type: "D-S_CONTROL_FAILURE", error_list_selectable: false });
const selectableFalse = items.filter((i) => !i.error_list_selectable).map((i) => i.id);
expect(selectableFalse).toEqual(["FX-ERR-12", "FX-ERR-16", "FX-ERR-17", "FX-ERR-13", "FX-ERR-08", "FX-ERR-10", "FX-ERR-14", "FX-ERR-15"]);
});
it("errorReportById() も同じ3項目(一覧の行と一致)", async () => {
const { items } = await db.errorReports({ ...BASE, include_resolved: true });
for (const id of ["FX-ERR-01", "FX-ERR-05", "FX-ERR-11", "FX-ERR-02", "FX-ERR-10"]) {
expect(await db.errorReportById(id)).toEqual(items.find((i) => i.id === id));
}
});
it("errorLists(): 131行・id の昇順・note_required は 131 だけ", async () => {
const list = await db.errorLists();
expect(list).toHaveLength(131);
expect(list.map((i) => i.id)).toEqual(Array.from({ length: 131 }, (_, i) => i + 1));
expect(list.filter((i) => i.note_required).map((i) => i.id)).toEqual([131]);
expect(list[130]).toEqual({
id: 131,
error_category_main_en: "OTHER ERROR",
error_category_main_ja: "その他(該当なし)",
error_category_sub_en: "other",
error_category_sub_ja: "その他(該当なし)",
error_detail_en: "There are no errors.",
error_detail_ja: "該当するエラーはありません",
note_required: true,
});
});
});