Newer
Older
client_dwatch / lib / db / __tests__ / error-report-mapping.test.ts
/**
 * `lib/db/error-report-mapping.ts` の期待値表(指示書AW §3 / 段階1-1・段階3-6-1 / CLAUDE.md §9.4)
 *
 * ★表1 は監督確定(D-160 / 指示書AW §3)。変えないこと★
 * ★表3 は指示書AW で導いたもの(監督確認済み・正)★(ラベルのテストは `lib/error-reports/__tests__/label.test.ts`)
 *
 * 表1(★監督確定・正・変えない★)
 * | #  | 行                                                       | 既定 | 解消も表示 | 紐づく予定                 | 検知時刻 |
 * |----|----------------------------------------------------------|------|------------|----------------------------|----------|
 * | E1 | status 0・本清掃の予定に紐づく                           | 出る | 出る       | 本清掃                     | errored_at |
 * | E2 | status 1・事前チェック(precheck=30)に紐づく            | 出る | 出る       | 事前チェック(30分前)     | errored_at |
 * | E3 | status 2・本清掃                                         | 出ない | 出る     | 本清掃                     | errored_at |
 * | E4 | status 0・clean_timetable_id NULL                        | 出る | 出る       | 予定なし                   | errored_at |
 * | E5 | status 0・clean_timetable_id が両方の予定表に無い id     | 出る | 出る       | 予定不明                   | errored_at |
 * | E6 | status 0・予定が clean_timetable_histories にだけある    | 出る | 出る       | 本清掃(履歴の予定も引く) | errored_at |
 * | E7 | status 0・errored_at NULL                                | 出る | 出る       | (予定どおり)             | created_at |
 * | E8 | status 0・robot_id NULL・serial_number '0'・SECURITY_ON  | 出る | 出る       | (予定どおり)             | errored_at。シリアル「0」・種類「SECURITY_ON」のまま |
 * | E9 | status 0・precheck=10 の予定に紐づく                     | 出る | 出る       | 事前チェック(10分前)     | errored_at(設定の 60/30 に無い値でも生の分数) |
 * 並び順: 検知時刻の降順、同時刻は error_id の昇順。
 *
 * 表3(指示書AW で導いたもの。`errorScheduleLabel()`)
 * | #  | schedule_link / precheck_minutes | JP                     | EN(暫定) |
 * |----|----------------------------------|------------------------|------------|
 * | S1 | main / null                      | 本清掃                 | Main run |
 * | S2 | precheck / 30                    | 事前チェック(30分前) | Pre-check (30 min before) |
 * | S3 | precheck / 10                    | 事前チェック(10分前) | Pre-check (10 min before) |
 * | S4 | none / null                      | 予定なし               | No schedule |
 * | S5 | unknown / null                   | 予定不明               | Unknown schedule |
 * | S6 | precheck / null(契約では起きない)| 事前チェック          | Pre-check |
 *
 * ★DB 依存のテストは書かない★。すべて純粋関数への入出力。
 * ★E2 の 30 は `PRECHECK_MINUTES_BEFORE[1]` から読む。E9 の 10 は「設定に無い値」であることが本題なので直書きする★。
 * ★日時は `+09:00` 付きの ISO 文字列から `toDbJst(new Date(...))` で DB の壁時計値を作る★(Prisma が返す `Date` と同じ形)。
 */
import { describe, expect, it } from "vitest";
import { PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import {
  errorReportItemFromRow,
  errorReportKeyFromRow,
  errorReportListWhere,
  errorScheduleLink,
  type ErrorReportRowSource,
  type ErrorTimetableRef,
} from "@/lib/db/error-report-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { toDbJst } from "@/lib/format/date";
import type { ErrorReportDbQuery, ErrorScheduleLink } from "@/lib/types";

/** `+09:00` 付きの ISO 文字列 → DB の壁時計値の `Date`(Prisma が返す形) */
const db = (iso: string): Date => toDbJst(new Date(iso));

const PRECHECK_30 = PRECHECK_MINUTES_BEFORE[1];
// ★10 は設定(PRECHECK_MINUTES_BEFORE = 60/30)に無い値であることが本題なので直書きする★
//   (`lib/fixtures/prechecks.ts` の `20` と同じ扱い。D-105 項1: 生の分数を出す)
const PRECHECK_10 = 10;

/** 本表(clean_timetables) */
const MAIN_TABLE: ErrorTimetableRef[] = [
  { id: BigInt(100), precheck: 0 }, // 本清掃
  { id: BigInt(101), precheck: PRECHECK_30 }, // 事前チェック(30分前)
  { id: BigInt(109), precheck: PRECHECK_10 }, // 事前チェック(10分前。設定に無い値)
];
/** 履歴(clean_timetable_histories)。★E6 の予定はここにだけある★ */
const HISTORY_TABLE: ErrorTimetableRef[] = [{ id: BigInt(106), precheck: 0 }];

/** 予定の引き当て(★同じ id は本表が勝つ★。`.toString()` をキーにする。指示書AW 4-9) */
function timetableFor(id: bigint | null): ErrorTimetableRef | undefined {
  if (id === null) return undefined;
  const byId = new Map<string, ErrorTimetableRef>();
  for (const t of HISTORY_TABLE) byId.set(t.id.toString(), t);
  for (const t of MAIN_TABLE) byId.set(t.id.toString(), t);
  return byId.get(id.toString());
}

function row(overrides: Partial<ErrorReportRowSource> & Pick<ErrorReportRowSource, "error_id">): ErrorReportRowSource {
  return {
    clean_timetable_id: BigInt(100),
    serial_number: "PD-CC1-0001",
    facility_id: BigInt(1),
    facility: { facility_name: "テスト施設A" },
    error_type: "EmergencyStop",
    error_level: "ERROR",
    errored_at: db("2026-10-05T19:00:00+09:00"),
    created_at: db("2026-10-05T19:01:00+09:00"),
    status: 0,
    responded_at: null,
    resolved_at: null,
    comment: null,
    ...overrides,
  };
}

/**
 * 表1 の9行。
 * ★E7 の `created_at`(10/6 08:00)は9行の中で一番新しい★ → 並べ替えで E7 が先頭に来る
 *   (`nulls: "last"` の並べ替えだと最後に来るので取り違えを検出できる)。
 * ★E4 = "E-10"・E5 = "E-9" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆)。
 * E7・E8 の「(予定どおり)」は本清掃にした。
 */
const E1 = row({ error_id: "E-1", errored_at: db("2026-10-05T19:05:00+09:00"), created_at: db("2026-10-05T19:06:00+09:00") });
const E2 = row({
  error_id: "E-2",
  clean_timetable_id: BigInt(101),
  error_type: "LowBattery",
  error_level: "WARNING",
  errored_at: db("2026-10-05T19:30:00+09:00"),
  created_at: db("2026-10-05T19:31:00+09:00"),
  status: robotErrorStatusFromResolutionStatus("in_progress"),
  responded_at: db("2026-10-06T09:00:00+09:00"),
});
const E3 = row({
  error_id: "E-3",
  errored_at: db("2026-10-05T20:00:00+09:00"),
  created_at: db("2026-10-05T20:01:00+09:00"),
  status: robotErrorStatusFromResolutionStatus("resolved"),
  responded_at: db("2026-10-06T09:00:00+09:00"),
  resolved_at: db("2026-10-06T10:00:00+09:00"),
  comment: "ブラシを清掃した",
});
const E4 = row({
  error_id: "E-10",
  clean_timetable_id: null,
  errored_at: db("2026-10-05T21:00:00+09:00"),
  created_at: db("2026-10-05T21:01:00+09:00"),
});
const E5 = row({
  error_id: "E-9",
  clean_timetable_id: BigInt(999), // 本表にも履歴にも無い
  errored_at: db("2026-10-05T21:00:00+09:00"),
  created_at: db("2026-10-05T21:02:00+09:00"),
});
const E6 = row({
  error_id: "E-6",
  clean_timetable_id: BigInt(106), // 履歴にだけある
  facility_id: BigInt(2),
  facility: { facility_name: "テスト施設B" },
  errored_at: db("2026-10-05T20:30:00+09:00"),
  created_at: db("2026-10-05T20:31:00+09:00"),
});
const E7 = row({
  error_id: "E-7",
  facility_id: BigInt(2),
  facility: { facility_name: "テスト施設B" },
  errored_at: null,
  created_at: db("2026-10-06T08:00:00+09:00"),
});
const E8 = row({
  error_id: "E-8",
  facility_id: BigInt(2),
  facility: { facility_name: "テスト施設B" },
  serial_number: "0",
  error_type: "SECURITY_ON",
  error_level: "CRITICAL",
  errored_at: db("2026-10-05T21:30:00+09:00"),
  created_at: db("2026-10-05T21:31:00+09:00"),
});
const E9 = row({
  error_id: "E-11",
  clean_timetable_id: BigInt(109),
  facility_id: BigInt(2),
  facility: { facility_name: "テスト施設B" },
  errored_at: db("2026-10-05T22:00:00+09:00"),
  created_at: db("2026-10-05T22:01:00+09:00"),
});

const TABLE1: [string, ErrorReportRowSource, ErrorScheduleLink, number | null, string, string, string, string][] = [
  // [#, 行, schedule_link, precheck_minutes, detected_at, serial_number, error_type, error_level]
  ["E1", E1, "main", null, "2026-10-05T19:05:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
  ["E2", E2, "precheck", PRECHECK_30, "2026-10-05T19:30:00+09:00", "PD-CC1-0001", "LowBattery", "WARNING"],
  ["E3", E3, "main", null, "2026-10-05T20:00:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
  ["E4", E4, "none", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
  ["E5", E5, "unknown", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
  ["E6", E6, "main", null, "2026-10-05T20:30:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
  ["E7", E7, "main", null, "2026-10-06T08:00:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
  ["E8", E8, "main", null, "2026-10-05T21:30:00+09:00", "0", "SECURITY_ON", "CRITICAL"],
  ["E9", E9, "precheck", PRECHECK_10, "2026-10-05T22:00:00+09:00", "PD-CC1-0001", "EmergencyStop", "ERROR"],
];

describe("errorReportItemFromRow(表1 E1〜E9)", () => {
  it.each(TABLE1)(
    "%s",
    (_no, source, link, minutes, detectedAt, serial, errorType, errorLevel) => {
      const item = errorReportItemFromRow(source, timetableFor(source.clean_timetable_id));
      expect(item.schedule_link).toBe(link);
      expect(item.precheck_minutes).toBe(minutes);
      expect(item.detected_at).toBe(detectedAt);
      expect(item.serial_number).toBe(serial);
      expect(item.error_type).toBe(errorType);
      expect(item.error_level).toBe(errorLevel);
    }
  );

  it("id は error_id の文字列のまま(Number() を通さない。D-105 項2)", () => {
    expect(errorReportItemFromRow(E4, undefined).id).toBe("E-10");
  });

  it("★BigInt を残さない★(facility_id / clean_timetable_id は number。JSON にできる)", () => {
    const item = errorReportItemFromRow(E1, timetableFor(E1.clean_timetable_id));
    expect(item.facility_id).toBe(1);
    expect(item.clean_timetable_id).toBe(100);
    expect(() => JSON.stringify(item)).not.toThrow();
    expect(errorReportItemFromRow(E4, undefined).clean_timetable_id).toBeNull();
  });

  it("E5(予定不明)でも clean_timetable_id は載せる(調査用。D18)", () => {
    expect(errorReportItemFromRow(E5, undefined).clean_timetable_id).toBe(999);
  });

  it("施設名・対応状況・日時・備考を写す(E3: 解消済み)", () => {
    const item = errorReportItemFromRow(E3, timetableFor(E3.clean_timetable_id));
    expect(item.facility_name).toBe("テスト施設A");
    expect(item.resolution_status).toBe("resolved");
    expect(item.responded_at).toBe("2026-10-06T09:00:00+09:00");
    expect(item.resolved_at).toBe("2026-10-06T10:00:00+09:00");
    expect(item.note).toBe("ブラシを清掃した");
  });

  it("E2: 対応中・responded_at だけある", () => {
    const item = errorReportItemFromRow(E2, timetableFor(E2.clean_timetable_id));
    expect(item.resolution_status).toBe("in_progress");
    expect(item.responded_at).toBe("2026-10-06T09:00:00+09:00");
    expect(item.resolved_at).toBeNull();
    expect(item.note).toBeNull();
  });

  it("★status が 0/1/2 以外なら例外(fail-loud。一覧では1段目で外れるので来ない)★", () => {
    expect(() => errorReportItemFromRow({ ...E1, status: 3 }, timetableFor(E1.clean_timetable_id))).toThrow();
  });
});

describe("errorScheduleLink(指示書AW D4)", () => {
  it("clean_timetable_id が NULL → none", () => {
    expect(errorScheduleLink(null, undefined)).toEqual({ schedule_link: "none", precheck_minutes: null });
  });

  it("予定が見つからない(undefined)→ unknown", () => {
    expect(errorScheduleLink(BigInt(999), undefined)).toEqual({ schedule_link: "unknown", precheck_minutes: null });
  });

  it("本清掃 → main", () => {
    expect(errorScheduleLink(BigInt(100), { id: BigInt(100), precheck: 0 })).toEqual({
      schedule_link: "main",
      precheck_minutes: null,
    });
  });

  it("PRECHECK_MINUTES_BEFORE[1] → precheck・その分数", () => {
    expect(errorScheduleLink(BigInt(101), { id: BigInt(101), precheck: PRECHECK_30 })).toEqual({
      schedule_link: "precheck",
      precheck_minutes: PRECHECK_30,
    });
  });

  it("★設定に無い 10 → precheck・10(生の分数)★", () => {
    expect(errorScheduleLink(BigInt(109), { id: BigInt(109), precheck: PRECHECK_10 })).toEqual({
      schedule_link: "precheck",
      precheck_minutes: 10,
    });
  });

  it("★予定の id と clean_timetable_id が違う → 例外(呼び出しの誤り)★", () => {
    expect(() => errorScheduleLink(BigInt(100), { id: BigInt(101), precheck: 0 })).toThrow();
  });
});

describe("errorReportKeyFromRow", () => {
  it("detected_at は errored_at(壁時計値のまま +09:00 の ISO)", () => {
    const key = errorReportKeyFromRow({
      error_id: "K-1",
      facility_id: BigInt(1),
      error_type: "EmergencyStop",
      status: 0,
      errored_at: toDbJst(new Date("2026-10-05T19:05:00+09:00")),
      created_at: toDbJst(new Date("2026-10-05T19:06:00+09:00")),
    });
    expect(key.detected_at).toBe("2026-10-05T19:05:00+09:00");
  });

  it.each(TABLE1)("%s: detected_at が表1 どおり(E7 だけ created_at)", (_no, source, _l, _m, detectedAt) => {
    expect(errorReportKeyFromRow(source).detected_at).toBe(detectedAt);
  });

  it("キーの形(facility_id は number・error_id は string のまま)", () => {
    expect(errorReportKeyFromRow(E8)).toEqual({
      error_id: "E-8",
      facility_id: 2,
      error_type: "SECURITY_ON",
      detected_at: "2026-10-05T21:30:00+09:00",
      status: 0,
    });
  });

  it("★status に 3 が入った行でも例外にならない(キーは生の数値を持つ。表2 F12)★", () => {
    const key = errorReportKeyFromRow({ ...E1, status: 3 });
    expect(key.status).toBe(3);
  });
});

describe("errorReportListWhere(SQL 側の写し。指示書AW 段階3-6-1)", () => {
  const base: ErrorReportDbQuery = {
    include_resolved: false,
    facility_ids: null,
    date_from: null,
    date_to: null,
    error_type: "",
    page: 1,
    per_page: 50,
  };
  const OPEN_CODES = (["unresolved", "in_progress"] as const).map(robotErrorStatusFromResolutionStatus);
  const ALL_CODES = (["unresolved", "in_progress", "resolved"] as const).map(robotErrorStatusFromResolutionStatus);

  it("既定 → status: { in: [未解消, 対応中] } だけ", () => {
    expect(errorReportListWhere(base)).toEqual({ status: { in: OPEN_CODES } });
  });

  it("include_resolved=true → 解消のコードも入る", () => {
    expect(errorReportListWhere({ ...base, include_resolved: true })).toEqual({ status: { in: ALL_CODES } });
  });

  it("★facility_ids: [] → facility_id: { in: [] }(キーが消えない=0件。罠1)★", () => {
    expect(errorReportListWhere({ ...base, facility_ids: [] })).toEqual({
      status: { in: OPEN_CODES },
      facility_id: { in: [] },
    });
  });

  it("facility_ids: [1, 2] → BigInt で in", () => {
    expect(errorReportListWhere({ ...base, facility_ids: [1, 2] })).toEqual({
      status: { in: OPEN_CODES },
      facility_id: { in: [BigInt(1), BigInt(2)] },
    });
  });

  it("facility_ids: null → facility_id のキーが無い", () => {
    expect(errorReportListWhere(base)).not.toHaveProperty("facility_id");
  });

  it("★期間あり → OR の2要素(errored_at の範囲 / errored_at が NULL なら created_at の範囲)。値は壁時計値★", () => {
    const range = {
      gte: new Date("2026-10-05T00:00:00.000Z"),
      lt: new Date("2026-10-06T00:00:00.000Z"),
    };
    expect(errorReportListWhere({ ...base, date_from: "2026-10-05", date_to: "2026-10-05" })).toEqual({
      status: { in: OPEN_CODES },
      OR: [{ errored_at: range }, { errored_at: null, created_at: range }],
    });
  });

  it("下限だけ → OR の範囲は gte だけ", () => {
    const range = { gte: new Date("2026-10-05T00:00:00.000Z") };
    expect(errorReportListWhere({ ...base, date_from: "2026-10-05" })).toEqual({
      status: { in: OPEN_CODES },
      OR: [{ errored_at: range }, { errored_at: null, created_at: range }],
    });
  });

  it("期間なし → OR のキーが無い", () => {
    expect(errorReportListWhere(base)).not.toHaveProperty("OR");
  });

  it("★error_type・page・per_page は SQL に降ろさない(D8 / D9)★", () => {
    expect(errorReportListWhere({ ...base, error_type: "secur", page: 3, per_page: 10 })).toEqual({
      status: { in: OPEN_CODES },
    });
  });
});