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