Newer
Older
client_dwatch / lib / db / __tests__ / fixture-error-reports.test.ts
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: "",
  sort: null,
  order: "asc",
  schedule: null,
  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("include_resolved + schedule=unscheduled → FX-ERR-04・12・20 の3件(指示書BH §11 R5)", async () => {
    const { items, total } = await db.errorReports({
      ...BASE,
      include_resolved: true,
      schedule: "unscheduled",
    });
    expect(total).toBe(3);
    expect(new Set(items.map((i) => i.id))).toEqual(new Set(["FX-ERR-04", "FX-ERR-12", "FX-ERR-20"]));
    expect(items.map((i) => i.id)).toEqual(["FX-ERR-12", "FX-ERR-04", "FX-ERR-20"]);
  });

  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,
    });
  });
});