Newer
Older
client_dwatch / lib / db / __tests__ / ds-alert-mapping.test.ts
/**
 * `lib/db/ds-alert-mapping.ts` の期待値表(指示書AX §3 表1・表5 / 段階1-1・1-10 / CLAUDE.md §9.4)
 *
 * ★R1〜R7・R11 は監督確定、R8〜R10・R12・R13 は指示書AX で導き監督が正としたもの(2026-10-19)。変えないこと★
 * ★表5(V4〜V7)は指示書AX で U8 から導き、監督が確認したもの(§11 Q9。2026-10-19)★
 *
 * 表1(D-Sec アラートに出るか・出たときの値。`dsAlertsFromErrorRows(, ALL_QUERY)`)
 * | #   | `robot_error_reports` の行 | D-Sec に出るか | 出たときの値 |
 * |-----|----------------------------|----------------|--------------|
 * | R1  | `D-S_CONTROL_FAILURE`・status 0・errored_at あり・robot_id NULL・シリアル DFAS010001 | 出る | disconnected/DFAS010001/unresolved/detected_at = errored_at/note null |
 * | R2  | 同上・status 1・responded_at あり・comment「回線業者に確認中」 | 出る | in_progress/responded_at あり/note「回線業者に確認中」 |
 * | R3  | 同上・status 2・responded_at・resolved_at あり | 出る(open には出ない) | resolved |
 * | R4  | 同上・status 0・★errored_at NULL★ | 出る | ★detected_at = created_at★ |
 * | R5  | `SECURITY_ON`・robot_id NULL・シリアル `---` | ★出ない★ | — |
 * | R6  | `NOT_STARTED`・robot_id NULL・シリアル `---` | ★出ない★ | — |
 * | R7  | ロボットのエラー(`EmergencyStop`・robot_id あり) | ★出ない★ | — |
 * | R8  | ★`d-s_control_failure`★(小文字) | ★出ない★(SQL の ci では当たるが `===` で外れる) | — |
 * | R9  | ★`D-S_CONTROL_FAILURE␣`★(末尾に空白1つ) | ★出ない★(SQL の PAD SPACE では当たるが `===` で外れる) | — |
 * | R10 | `D-S_CONTROL_FAILURE`・★status 3★ | ★出ない・例外にならない★ | — |
 * | R11 | `D-S_CONTROL_FAILURE`・シリアル DFAS010001(施設2の制御盤)・★行の facility_id / section_id は施設3★ | 出る | ★facility_id 3・施設3の名前・行の section_id の名前★/シリアルはそのまま |
 * | R12 | `D-S_CONTROL_FAILURE`・R1 と同じ検知時刻・error_id ★"E-10"★(R1 は "E-9") | 出る | 並びで "E-10" が先(`lib/ds-alerts/__tests__/query.test.ts`) |
 * | R13 | `D-S_CONTROL_FAILURE`・★clean_timetable_id NULL★ | 出る | 予定の有無で絞らない |
 *
 * 表5 の D-Sec 列(U8・D12)
 * | #  | 行 | D-Sec |
 * |----|----|-------|
 * | V4 | 施設・清掃エリアとも生きている・D-S_CONTROL_FAILURE・status 0 | 出る |
 * | V5 | ★施設が削除済み★・status 0 | ★出ない★ |
 * | V6 | ★清掃エリアが削除済み★・status 1 | ★出ない★ |
 * | V7 | ★施設・清掃エリアとも削除済み★・status 2 | ★出ない★(status=all / resolved でも) |
 *
 * ★DB 依存のテストは書かない★。すべて純粋関数への入出力。
 * ★`error_type` は `D_SEC_ALERT_ERROR_TYPES[0]` から読む★。R8・R9 の「わざと違う綴り」だけ直書きする。
 * ★状態コードは `robotErrorStatusFromResolutionStatus()` から作る(0/1/2 を直書きしない)★。
 *   R10 の 3 は「想定外の値」であることが本題なので直書きする。
 * ★日時は `+09:00` 付きの ISO 文字列から `toDbJst(new Date(...))` で DB の壁時計値を作る★(Prisma が返す `Date` と同じ形)。
 */
import { describe, expect, it } from "vitest";
import { D_SEC_ALERT_ERROR_TYPES } from "@/lib/config";
import {
  dsAlertFromErrorRow,
  dsAlertsFromErrorRows,
  dsAlertWhere,
  isDsAlertRow,
  type DsAlertRowSource,
} from "@/lib/db/ds-alert-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { toDbJst } from "@/lib/format/date";

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

const D_S_CONTROL_FAILURE = D_SEC_ALERT_ERROR_TYPES[0];
const UNRESOLVED = robotErrorStatusFromResolutionStatus("unresolved");
const IN_PROGRESS = robotErrorStatusFromResolutionStatus("in_progress");
const RESOLVED = robotErrorStatusFromResolutionStatus("resolved");
/** 削除の日時(★過去の日時★。表5 の注意) */
const DELETED_AT = db("2026-10-01T10:00:00+09:00");

/**
 * テストの行。`ROBOT_ERROR_DS_ALERT_SELECT` は `clean_timetable_id` も読むので持たせる(R13)。
 * ★`robot_id` は持たせない★(読まないことが本題。D-Sec の行は全部 NULL)
 */
type Row = DsAlertRowSource & { clean_timetable_id: bigint | null };

const FACILITY_2 = { facility_name: "テスト施設B", deleted_at: null };
const FACILITY_3 = { facility_name: "テスト施設C", deleted_at: null };

function row(overrides: Partial<Row> & Pick<Row, "error_id">): Row {
  return {
    clean_timetable_id: BigInt(9001),
    serial_number: "DFAS010001",
    facility_id: BigInt(2),
    facility: FACILITY_2,
    section: { section_name: "1F売り場", deleted_at: null },
    error_type: D_S_CONTROL_FAILURE,
    errored_at: db("2026-10-18T22:45:00+09:00"),
    created_at: db("2026-10-18T22:46:00+09:00"),
    status: UNRESOLVED,
    responded_at: null,
    resolved_at: null,
    comment: null,
    ...overrides,
  };
}

const R1 = row({ error_id: "E-9" });
const R2 = row({
  error_id: "E-2",
  errored_at: db("2026-10-18T23:10:00+09:00"),
  created_at: db("2026-10-18T23:11:00+09:00"),
  status: IN_PROGRESS,
  responded_at: db("2026-10-19T09:05:00+09:00"),
  comment: "回線業者に確認中",
});
const R3 = row({
  error_id: "E-3",
  errored_at: db("2026-10-18T03:55:00+09:00"),
  created_at: db("2026-10-18T03:56:00+09:00"),
  status: RESOLVED,
  responded_at: db("2026-10-18T08:40:00+09:00"),
  resolved_at: db("2026-10-18T09:15:00+09:00"),
  comment: "正常復帰を確認",
});
const R4 = row({
  error_id: "E-4",
  errored_at: null,
  created_at: db("2026-10-19T02:55:00+09:00"),
});
const R5 = row({ error_id: "E-5", error_type: "SECURITY_ON", serial_number: "---" });
const R6 = row({ error_id: "E-6", error_type: "NOT_STARTED", serial_number: "---" });
const R7 = row({ error_id: "E-7", error_type: "EmergencyStop", serial_number: "PD-CC1-0001" });
// ★わざと違う綴り(直書き)★: DB の照合順序(ci)なら SQL の `IN` で当たってしまう値(D2)
const R8 = row({ error_id: "E-8", error_type: "d-s_control_failure" });
// ★わざと違う綴り(直書き)★: 末尾に空白1つ。DB の PAD SPACE なら SQL の `IN` で当たってしまう値(D2)
const R9 = row({ error_id: "E-29", error_type: "D-S_CONTROL_FAILURE " });
// ★3 は「0/1/2 以外の想定外の値」であることが本題なので直書きする(D3)★
const R10 = row({ error_id: "E-30", status: 3 });
const R11 = row({
  error_id: "1000004",
  serial_number: "DFAS010001", // 施設2の制御盤のシリアル
  facility_id: BigInt(3),
  facility: FACILITY_3,
  section: { section_name: "2F衣料品", deleted_at: null },
  errored_at: db("2026-10-19T01:30:00+09:00"),
  created_at: db("2026-10-19T01:31:00+09:00"),
});
const R12 = row({ error_id: "E-10" }); // R1 と同じ検知時刻
const R13 = row({
  error_id: "E-13",
  clean_timetable_id: null,
  errored_at: db("2026-10-19T00:10:00+09:00"),
  created_at: db("2026-10-19T00:11:00+09:00"),
});

const TABLE1: [string, Row, boolean][] = [
  ["R1", R1, true],
  ["R2", R2, true],
  ["R3", R3, true],
  ["R4", R4, true],
  ["R5", R5, false],
  ["R6", R6, false],
  ["R7", R7, false],
  ["R8", R8, false],
  ["R9", R9, false],
  ["R10", R10, false],
  ["R11", R11, true],
  ["R12", R12, true],
  ["R13", R13, true],
];
const ALL_ROWS: Row[] = TABLE1.map(([, r]) => r);

/** ★全件(`/ds-alerts`)★ — 表1 は全件の読み方で固定する(`open_only` は表10 N9 で別に確かめる。指示書BB D17) */
const ALL_QUERY = { open_only: false } as const;

describe("isDsAlertRow(表1 R1〜R13)", () => {
  it.each(TABLE1)("%s → %s", (_no, source, expected) => {
    expect(isDsAlertRow(source)).toBe(expected);
  });

  it("★R10(status 3)は例外にならない(ダッシュボードを落とさない。D3)★", () => {
    expect(() => isDsAlertRow(R10)).not.toThrow();
  });
});

describe("dsAlertFromErrorRow(表1 R1〜R4・R11)", () => {
  it("R1: disconnected・シリアル生値・unresolved・detected_at = errored_at・note null", () => {
    expect(dsAlertFromErrorRow(R1)).toEqual({
      id: "E-9",
      alert_type: "disconnected",
      device_serial: "DFAS010001",
      facility_id: 2,
      facility_name: "テスト施設B",
      section_name: "1F売り場",
      detected_at: "2026-10-18T22:45:00+09:00",
      resolution_status: "unresolved",
      responded_at: null,
      resolved_at: null,
      note: null,
    });
  });

  it("R2: in_progress・responded_at あり・note は comment", () => {
    const a = dsAlertFromErrorRow(R2);
    expect(a.resolution_status).toBe("in_progress");
    expect(a.responded_at).toBe("2026-10-19T09:05:00+09:00");
    expect(a.resolved_at).toBeNull();
    expect(a.note).toBe("回線業者に確認中");
  });

  it("R3: resolved・responded_at / resolved_at あり", () => {
    const a = dsAlertFromErrorRow(R3);
    expect(a.resolution_status).toBe("resolved");
    expect(a.responded_at).toBe("2026-10-18T08:40:00+09:00");
    expect(a.resolved_at).toBe("2026-10-18T09:15:00+09:00");
    expect(a.note).toBe("正常復帰を確認");
  });

  it("★R4: errored_at が NULL → detected_at = created_at(9時間ずらさない)★", () => {
    expect(dsAlertFromErrorRow(R4).detected_at).toBe("2026-10-19T02:55:00+09:00");
  });

  it("★R11: 施設は行の facility_id(シリアルの制御盤の施設ではない。U3)・シリアルはそのまま★", () => {
    const a = dsAlertFromErrorRow(R11);
    expect(a.id).toBe("1000004");
    expect(a.facility_id).toBe(3);
    expect(a.facility_name).toBe("テスト施設C");
    expect(a.section_name).toBe("2F衣料品");
    expect(a.device_serial).toBe("DFAS010001");
  });

  it("★id は error_id の文字列のまま(Number() を通さない。D-105)・BigInt を残さない★", () => {
    const a = dsAlertFromErrorRow(R11);
    expect(typeof a.id).toBe("string");
    expect(typeof a.facility_id).toBe("number");
    expect(() => JSON.stringify(a)).not.toThrow();
  });

  it("★DsAlert に memo は無い(note に改名。D9)★", () => {
    expect(dsAlertFromErrorRow(R1)).not.toHaveProperty("memo");
  });

  it("isDsAlertRow() を通らない status(3)を直接渡すと例外(fail-loud)", () => {
    expect(() => dsAlertFromErrorRow(R10)).toThrow();
  });
});

describe("dsAlertsFromErrorRows(表1 の13行)", () => {
  it("★13行を入れて7行が出る(R5〜R10 は出ない)。順は問わない★", () => {
    const alerts = dsAlertsFromErrorRows(ALL_ROWS, ALL_QUERY);
    expect(alerts).toHaveLength(7);
    expect(alerts.map((a) => a.id)).toEqual(
      expect.arrayContaining(["E-9", "E-2", "E-3", "E-4", "1000004", "E-10", "E-13"])
    );
  });

  it("★R10(status 3)が混ざっても例外にならない(D3)★", () => {
    expect(() => dsAlertsFromErrorRows(ALL_ROWS, ALL_QUERY)).not.toThrow();
  });

  it("出た行はすべて disconnected(security_off はここから出ない。D4)", () => {
    expect(dsAlertsFromErrorRows(ALL_ROWS, ALL_QUERY).every((a) => a.alert_type === "disconnected")).toBe(true);
  });

  it("出た7行の状態の内訳は unresolved 5・in_progress 1・resolved 1(表2 A7 の入力)", () => {
    const alerts = dsAlertsFromErrorRows(ALL_ROWS, ALL_QUERY);
    expect(alerts.filter((a) => a.resolution_status === "unresolved")).toHaveLength(5);
    expect(alerts.filter((a) => a.resolution_status === "in_progress")).toHaveLength(1);
    expect(alerts.filter((a) => a.resolution_status === "resolved")).toHaveLength(1);
  });

  it("★R13(clean_timetable_id NULL)も出る(予定の有無で絞らない)★", () => {
    expect(dsAlertsFromErrorRows([R13], ALL_QUERY).map((a) => a.id)).toEqual(["E-13"]);
  });

  it("空なら空", () => {
    expect(dsAlertsFromErrorRows([], ALL_QUERY)).toEqual([]);
  });
});

describe("dsAlertsFromErrorRows の可視性(表5 V4〜V7。U8・D12)", () => {
  const V4 = row({ error_id: "V-4" });
  const V5 = row({ error_id: "V-5", facility: { ...FACILITY_2, deleted_at: DELETED_AT } });
  const V6 = row({
    error_id: "V-6",
    status: IN_PROGRESS,
    section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT },
  });
  const V7 = row({
    error_id: "V-7",
    status: RESOLVED,
    facility: { ...FACILITY_2, deleted_at: DELETED_AT },
    section: { section_name: "1F売り場", deleted_at: DELETED_AT },
  });

  it("V4: 施設・清掃エリアとも生きている → 出る", () => {
    expect(dsAlertsFromErrorRows([V4], ALL_QUERY).map((a) => a.id)).toEqual(["V-4"]);
  });

  it.each([
    ["V5(施設が削除済み)", V5],
    ["V6(清掃エリアが削除済み)", V6],
    ["V7(施設・清掃エリアとも削除済み)", V7],
  ])("★%s → 出ない(isDsAlertRow() は true でも外れる)★", (_label, source) => {
    expect(isDsAlertRow(source)).toBe(true);
    expect(dsAlertsFromErrorRows([source], ALL_QUERY)).toEqual([]);
  });

  it("V4〜V7 を混ぜると V4 だけ(summary にも数えない=ここで外れる)", () => {
    expect(dsAlertsFromErrorRows([V4, V5, V6, V7], ALL_QUERY).map((a) => a.id)).toEqual(["V-4"]);
  });
});

describe("dsAlertWhere(SQL 側の写し)", () => {
  it("error_type IN・status IN(0/1/2 は変換関数から作る)・施設と清掃エリアの可視性", () => {
    expect(dsAlertWhere(ALL_QUERY)).toEqual({
      error_type: { in: [...D_SEC_ALERT_ERROR_TYPES] },
      status: {
        in: (["unresolved", "in_progress", "resolved"] as const).map(robotErrorStatusFromResolutionStatus),
      },
      facility: { deleted_at: null },
      section: { deleted_at: null },
    });
  });
});

// ★表10 N7・N9(指示書BB A6・D17・D-182)★ — ダッシュボードは `open_only: true`(バナーは解消でないものしか見ない)
describe("open_only(指示書BB 表10 N9)", () => {
  it("★N9: スタブが解消の行を混ぜて返しても、`open_only: true` なら解消は出ない(SQL の写しが外れても純粋関数で外れる)★", () => {
    const all = dsAlertsFromErrorRows(ALL_ROWS, ALL_QUERY);
    expect(all.some((a) => a.resolution_status === "resolved")).toBe(true);
    const open = dsAlertsFromErrorRows(ALL_ROWS, { open_only: true });
    expect(open.map((a) => a.id)).toEqual(all.filter((a) => a.resolution_status !== "resolved").map((a) => a.id));
    expect(open.some((a) => a.resolution_status === "resolved")).toBe(false);
  });

  it("N7: `dsAlertWhere({ open_only: true })` の status は未解消・対応中のコードだけ(変換関数から作る)", () => {
    expect(dsAlertWhere({ open_only: true }).status).toEqual({
      in: (["unresolved", "in_progress"] as const).map(robotErrorStatusFromResolutionStatus),
    });
  });
});