/**
* `lib/db/precheck-mapping.ts` の期待値表(指示書S 3-5 / CLAUDE.md §9.4)
*
* ★DB 依存のテストは書かない★(禁止事項12)。すべて純粋関数への入出力。
* ★閾値・コード値は `lib/config` から取り、テストにも直書きしない★(禁止事項3)。
*/
import { describe, expect, it } from "vitest";
import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import type { RobotErrorLike } from "@/lib/db/clean-log-mapping";
import { robotStatusFromErrors } from "@/lib/db/clean-log-mapping";
import {
detectedAtFromError,
isKnownRobotErrorStatus,
knownRobotErrorStatusCodes,
mainRunSucceededFromReport,
precheckItemFromErrorRow,
precheckMinutesFromCode,
resolutionStatusFromRobotErrorStatus,
robotErrorStatusFromResolutionStatus,
statusColumnsFromPrecheckRows,
} from "@/lib/db/precheck-mapping";
import { prechecks as fixturePrechecks } from "@/lib/fixtures/prechecks";
import { judgeGrade } from "@/lib/judge";
import type { PrecheckStatus } from "@/lib/types";
/** 待機中(エラー無し)の期待値。`ROBOT_STATUS_STANDBY` を直書きしない */
const STANDBY = robotStatusFromErrors([]);
describe("precheckMinutesFromCode", () => {
// 期待値表(指示書X 段階1 1-1。★境界値★)
// 本清掃 → null(事前チェック行ではない = アラートの対象外)
// 0 以外 → 生値をそのまま「分前」として返す(TeamWorks 第7回 A5-2/A5-5。D-112)
// 負の値・想定外の値 → 例外にせず生値をそのまま返す(丸めない。D-077/D-089項3/D-104項2)
it("本清掃行は null(事前チェック行ではない)", () => {
expect(precheckMinutesFromCode(CLEAN_TIMETABLE_PRECHECK.main)).toBeNull();
});
it("★『1』は1分前(従来は60分前と読んでいたが、TeamWorks第7回A5-3で真逆と判明。2026-09-11)★", () => {
// 意図した仕様変更: 以前はコード値の1回目とみなして PRECHECK_MINUTES_BEFORE[0](60)を
// 返していたが、A5-3「もし1や2が格納されていたら1分, 2分前としてください」により、
// 1はそのまま1分前が正しい。
expect(precheckMinutesFromCode(1)).toBe(1);
});
it("★『2』は2分前(従来は30分前と読んでいたが、TeamWorks第7回A5-3で真逆と判明。2026-09-11)★", () => {
expect(precheckMinutesFromCode(2)).toBe(2);
});
it("PRECHECK_MINUTES_BEFORE[0](実DBに実際に入る値。A5-2)はその値のまま", () => {
expect(precheckMinutesFromCode(PRECHECK_MINUTES_BEFORE[0])).toBe(
PRECHECK_MINUTES_BEFORE[0]
);
});
it("PRECHECK_MINUTES_BEFORE[1](同上)はその値のまま", () => {
expect(precheckMinutesFromCode(PRECHECK_MINUTES_BEFORE[1])).toBe(
PRECHECK_MINUTES_BEFORE[1]
);
});
it("設定値以外の分数も生値をそのまま返す(20なら20分前。D-093 項2のユーザー原文)", () => {
expect(precheckMinutesFromCode(20)).toBe(20);
});
it("tinyint unsigned の上限(255)も丸めない・その他に寄せない", () => {
expect(precheckMinutesFromCode(255)).toBe(255);
});
it("★負の値も例外にせず生値をそのまま返す(丸めない・弾かない)★", () => {
expect(precheckMinutesFromCode(-1)).toBe(-1);
});
it("★本清掃コードだけが null で、それ以外は入力と同じ値を返す(プロパティテスト)★", () => {
for (let code = 0; code <= 255; code += 1) {
const result = precheckMinutesFromCode(code);
if (code === CLEAN_TIMETABLE_PRECHECK.main) {
expect(result).toBeNull();
} else {
expect(result).toBe(code);
}
}
});
});
describe("statusColumnsFromPrecheckRows", () => {
// ★期待値表(D-178。ユーザー判断 2026-10-16「案1」)★
// 列の割り当ては ★分数の一致ではなく順番★。本清掃に付く事前チェックの予定(`precheck` の生値)を
// 分数の大きい順(=早い順)に並べ、1つ目を列1(`status_1h`)、2つ目を列2(`status_30m`)に入れる。
// E = 何らかのエラー1件(error_type は生値)/ S = ROBOT_STATUS_STANDBY(Standby.)
// ★null = その回の事前チェックの予定行が無い★(ユーザー指示 2026-10-07「事前チェックがないものは-にしてください」。D-171)。
// ★Map にキーがある = 予定行がある★(エラーが無ければ空配列で入る。`lib/db/index.ts` の詰め替え)
// ★分数はテストに直書きする★ — 本題は「設定(`PRECHECK_MINUTES_BEFORE`)と無関係に順番で割り当てる」ことなので、
// 設定から読むと設定を変えたときに表の意味が変わる
const E1: RobotErrorLike = { error_type: "EmergencyStop", errored_at: null };
const E1b: RobotErrorLike = {
error_type: "LostLocalization",
errored_at: new Date("2026-08-27T01:10:00+09:00"),
};
const E2: RobotErrorLike = { error_type: "オフライン", errored_at: null };
const E3: RobotErrorLike = { error_type: "充電中", errored_at: null };
/** Map を組む短縮形(挿入順は結果に影響しないことも P10 で確かめる) */
const rows = (...entries: [number, RobotErrorLike[]][]) => new Map(entries);
/**
* ★期待値表 P1〜P11(D-178)★
*
* | # | 本清掃に付く事前チェックの予定(分前) | エラー | 期待 status(列1 / 列2) | 期待 minutes | dropped |
* |-----|----------------------------------------|--------------------|--------------------------------|----------------|---------|
* | P1 | 60・30 | なし | S / S | 60 / 30 | [] |
* | P2 | 60・10(設定と違う分数) | なし | S / S | 60 / 10 | [] |
* | P3 | 30 だけ | EmergencyStop | "EmergencyStop" / null | 30 / null | [] |
* | P4 | 無し | — | null / null | null / null | [] |
* | P5 | 1・2(旧コード値。★1分前・2分前★) | 1:E1 / 2:E2 | E2 / E1(2分前が先) | 2 / 1 | [] |
* | P6 | 90・60・30(3つ。通常は起きない) | 90:E3 / 60:E1 / 30:E2 | E1 / E2(開始に近い2つ) | 60 / 30 | [90] |
* | P7 | 60・30 | 60:E1 / 30:なし | E1 / S | 60 / 30 | [] |
* | P8 | 60・30 | 60:[E1,E1b] / 30:なし | robotStatusFromErrors([E1,E1b]) / S | 60 / 30 | [] |
* | P9 | 10 だけ(設定 [60,30] に無い) | なし | S / null | 10 / null | [] |
* | P10 | 30・60(Map への挿入順が逆) | 30:E2 / 60:E1 | E1 / E2(挿入順でなく分数順) | 60 / 30 | [] |
* | P11 | 本清掃(0)のキーが混ざる | 0:E3 / 60:なし | S / null(0 は事前チェックでない) | 60 / null | [] |
*/
describe("期待値表 P1〜P11(順番で割り当てる。D-178)", () => {
it("P1: 60・30 でエラーが無ければ両方 Standby.・60 / 30", () => {
const r = statusColumnsFromPrecheckRows(rows([60, []], [30, []]));
expect(r.status).toEqual([STANDBY, STANDBY]);
expect(r.minutes).toEqual([60, 30]);
expect(r.dropped).toEqual([]);
});
it("★P2: 60・10(設定と違う分数)でも10分前の結果を落とさず2列目に出す★", () => {
const r = statusColumnsFromPrecheckRows(rows([60, []], [10, [E1]]));
expect(r.status).toEqual([STANDBY, E1.error_type]);
expect(r.minutes).toEqual([60, 10]);
expect(r.dropped).toEqual([]);
});
it("★P3: 30分前だけなら、分数によらず1列目に入り2列目は null★", () => {
const r = statusColumnsFromPrecheckRows(rows([30, [E1]]));
expect(r.status).toEqual([E1.error_type, null]);
expect(r.minutes).toEqual([30, null]);
expect(r.dropped).toEqual([]);
});
it("P4: 事前チェックの予定が無ければ両方 null(Standby. にしない。D-171)", () => {
const r = statusColumnsFromPrecheckRows(new Map());
expect(r.status).toEqual([null, null]);
expect(r.minutes).toEqual([null, null]);
expect(r.dropped).toEqual([]);
});
it("★P5: 旧コード値 1・2 は1分前・2分前として扱う(2分前が1回目。読み替えない。A5-3)★", () => {
// ★意図した仕様変更(D-178)★: 以前は設定 [60,30] と一致しないため両方 null だった
const r = statusColumnsFromPrecheckRows(rows([1, [E1]], [2, [E2]]));
expect(r.status).toEqual([E2.error_type, E1.error_type]);
expect(r.minutes).toEqual([2, 1]);
expect(r.dropped).toEqual([]);
});
it("★P6: 3つあれば開始に近い2つ(60・30)を残し、90 を dropped で返す★", () => {
const r = statusColumnsFromPrecheckRows(rows([90, [E3]], [60, [E1]], [30, [E2]]));
expect(r.status).toEqual([E1.error_type, E2.error_type]);
expect(r.minutes).toEqual([60, 30]);
expect(r.dropped).toEqual([90]);
});
it("P7: 1回目だけ異常・2回目は予定ありエラー無し → E1 / Standby.", () => {
const r = statusColumnsFromPrecheckRows(rows([60, [E1]], [30, []]));
expect(r.status).toEqual([E1.error_type, STANDBY]);
expect(r.minutes).toEqual([60, 30]);
});
it("P8: 複数エラーの畳み方は robotStatusFromErrors() に委ねる", () => {
const r = statusColumnsFromPrecheckRows(rows([60, [E1, E1b]], [30, []]));
expect(r.status).toEqual([robotStatusFromErrors([E1, E1b]), STANDBY]);
});
it("P9: 設定に無い分数(10分前)だけでも1列目に出す(旧: 両方 null)", () => {
const r = statusColumnsFromPrecheckRows(rows([10, []]));
expect(r.status).toEqual([STANDBY, null]);
expect(r.minutes).toEqual([10, null]);
});
it("P10: Map への挿入順ではなく分数の大きい順に並べる(列が入れ替わらない)", () => {
const r = statusColumnsFromPrecheckRows(rows([30, [E2]], [60, [E1]]));
expect(r.status).toEqual([E1.error_type, E2.error_type]);
expect(r.minutes).toEqual([60, 30]);
});
it("P11: 本清掃(CLEAN_TIMETABLE_PRECHECK.main)のキーは事前チェックとして数えない", () => {
const r = statusColumnsFromPrecheckRows(
rows([CLEAN_TIMETABLE_PRECHECK.main, [E3]], [60, []])
);
expect(r.status).toEqual([STANDBY, null]);
expect(r.minutes).toEqual([60, null]);
expect(r.dropped).toEqual([]);
});
});
it("★不変条件: status が null のときだけ minutes も null(全ケース)★", () => {
const cases = [
new Map(),
rows([30, []]),
rows([60, []], [10, [E1]]),
rows([90, []], [60, []], [30, [E2]]),
];
for (const c of cases) {
const r = statusColumnsFromPrecheckRows(c);
for (const i of [0, 1] as const) {
expect(r.status[i] === null).toBe(r.minutes[i] === null);
}
}
});
});
describe("resolutionStatusFromRobotErrorStatus", () => {
// 期待値表: 0 → unresolved / 1 → in_progress / 2 → resolved / それ以外 → 例外
it("0/1/2 を3値に変換する", () => {
expect(resolutionStatusFromRobotErrorStatus(0)).toBe("unresolved");
expect(resolutionStatusFromRobotErrorStatus(1)).toBe("in_progress");
expect(resolutionStatusFromRobotErrorStatus(2)).toBe("resolved");
});
it("想定外の値は例外にする(静かに既定値へ倒さない)", () => {
// UnsignedTinyInt は 0〜255 を許すため 3以上が入りうる
expect(() => resolutionStatusFromRobotErrorStatus(3)).toThrow(
/想定外の値/
);
expect(() => resolutionStatusFromRobotErrorStatus(255)).toThrow(/想定外の値/);
expect(() => resolutionStatusFromRobotErrorStatus(-1)).toThrow(/想定外の値/);
});
});
// ★表3 S8(指示書BB U3・D-182)★ — 一覧は外し・1件読みは404 にするための判定(変換関数の例外は残す)
describe("isKnownRobotErrorStatus / knownRobotErrorStatusCodes", () => {
it.each([
[0, true],
[1, true],
[2, true],
[3, false],
[255, false],
[-1, false],
] as const)("status=%i → %s", (input, expected) => {
expect(isKnownRobotErrorStatus(input)).toBe(expected);
});
it("SQL の写しに使うコードは 0/1/2(`robotErrorStatusFromResolutionStatus()` から作る)", () => {
expect(knownRobotErrorStatusCodes()).toEqual([0, 1, 2]);
});
});
describe("robotErrorStatusFromResolutionStatus", () => {
it("3値を 0/1/2 に戻す(seed 用の逆変換)", () => {
expect(robotErrorStatusFromResolutionStatus("unresolved")).toBe(0);
expect(robotErrorStatusFromResolutionStatus("in_progress")).toBe(1);
expect(robotErrorStatusFromResolutionStatus("resolved")).toBe(2);
});
it("正変換と逆変換が往復する", () => {
const all: PrecheckStatus[] = ["unresolved", "in_progress", "resolved"];
for (const status of all) {
expect(
resolutionStatusFromRobotErrorStatus(
robotErrorStatusFromResolutionStatus(status)
)
).toBe(status);
}
});
});
describe("detectedAtFromError", () => {
const errored = new Date("2026-08-27T01:00:00+09:00");
const created = new Date("2026-08-27T01:05:00+09:00");
it("errored_at があればそれを使う", () => {
expect(detectedAtFromError({ errored_at: errored, created_at: created })).toBe(
errored
);
});
it("errored_at が null のときだけ created_at に落とす", () => {
expect(detectedAtFromError({ errored_at: null, created_at: created })).toBe(
created
);
});
});
/**
* `precheckItemFromErrorRow()`(指示書AT 段階4-1)— `prechecks()`(一覧)と `precheckItemById()`(PATCH)が
* 共有する行の組み立て。★`prechecks()` が以前インラインで作っていた行と同じ形★になること
* (一覧側の回帰は `app/api/prechecks/__tests__/route.test.ts` が無修正で通ることで見ている)。
*/
describe("precheckItemFromErrorRow", () => {
/** DB の壁時計値としての `Date`(D-115。他のテストと同じヘルパー) */
function dbWallClock(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
const report = {
error_id: "RER-1001-1",
serial_number: "8111R5628060011",
facility_id: BigInt(3),
facility: { facility_name: "バリューマート北町店" },
error_type: "EmergencyStop",
errored_at: dbWallClock("2026-09-10T01:05:00+09:00"),
created_at: dbWallClock("2026-09-10T01:06:00+09:00"),
status: 1,
responded_at: dbWallClock("2026-09-10T07:00:00+09:00"),
resolved_at: null,
comment: "現場に連絡済み",
};
it("一覧と同じ形の行を作る(id は error_id のまま・日時は DB の壁時計値そのまま)", () => {
const item = precheckItemFromErrorRow(
report,
PRECHECK_MINUTES_BEFORE[0],
{ start_at: dbWallClock("2026-09-10T02:00:00+09:00") },
// ★親の本清掃にレポート行が無い★ → `main_run_succeeded: false`(D-171 の M4)
undefined
);
expect(item).toEqual({
id: "RER-1001-1",
severity: "precheck",
precheck_minutes: PRECHECK_MINUTES_BEFORE[0],
serial_number: "8111R5628060011",
facility_id: 3,
facility_name: "バリューマート北町店",
// ★親の本清掃行の start_at★(事前チェック行の時刻ではない)
scheduled_at: "2026-09-10T02:00:00+09:00",
robot_status: robotStatusFromErrors([
{ error_type: "EmergencyStop", errored_at: report.errored_at },
]),
detected_at: "2026-09-10T01:05:00+09:00",
resolution_status: "in_progress",
responded_at: "2026-09-10T07:00:00+09:00",
resolved_at: null,
note: "現場に連絡済み",
battery_level: null,
main_run_succeeded: false,
});
});
it("errored_at が null なら created_at を検知時刻にする / 解消日時を変換する", () => {
const item = precheckItemFromErrorRow(
{ ...report, errored_at: null, status: 2, resolved_at: dbWallClock("2026-09-10T08:00:00+09:00") },
PRECHECK_MINUTES_BEFORE[1],
{ start_at: dbWallClock("2026-09-10T02:00:00+09:00") },
undefined
);
expect(item.detected_at).toBe("2026-09-10T01:06:00+09:00");
expect(item.resolution_status).toBe("resolved");
expect(item.resolved_at).toBe("2026-09-10T08:00:00+09:00");
});
it("★親の本清掃のレポートから main_run_succeeded を決める★(`mainRunSucceededFromReport()` の1本。D-171)", () => {
const start = { start_at: dbWallClock("2026-09-10T02:00:00+09:00") };
const ok = precheckItemFromErrorRow(report, PRECHECK_MINUTES_BEFORE[0], start, {
clean_area: 95,
task_area: 100,
clean_ratio: 0,
});
expect(ok.main_run_succeeded).toBe(true);
// ★対応状況は変えない★(本清掃が成功しても未解消・対応中のまま。D-171)
expect(ok.resolution_status).toBe("in_progress");
const ng = precheckItemFromErrorRow(report, PRECHECK_MINUTES_BEFORE[0], start, {
clean_area: 50,
task_area: 100,
clean_ratio: 0,
});
expect(ng.main_run_succeeded).toBe(false);
});
});
/**
* ★`mainRunSucceededFromReport()` の期待値表(M1〜M6。ユーザー指示 2026-10-07。D-171)★
*
* ★成功 = 親の本清掃にレポート行があり、完了率の判定が ◎○★(`SUCCESS_GRADES`。ダッシュボードの「成功」と同じ定義)。
* ★完了率・等級は既存の関数で決める★(`completionRateFromReport()` → `judgeGrade()`)。ここで割り算や閾値を書かない。
* ★`clean_ratio` 列は使わない★(D-155)ので、わざと逆向きの値を入れて読んでいないことも確かめる。
*/
describe("mainRunSucceededFromReport(D-171 期待値表 M1〜M6)", () => {
const row = (clean_area: number, task_area: number) => ({
clean_area,
task_area,
// ★完了率には使わない列★ — 読んでいたら M1/M2 と M3/M5 の答えが逆になる
clean_ratio: clean_area >= task_area * 0.7 ? 0 : 100,
});
it("M1: レポートあり・完了率 95(◎)→ true", () => {
expect(judgeGrade(95)).toBe("◎");
expect(mainRunSucceededFromReport(row(95, 100))).toBe(true);
});
it("M2: レポートあり・完了率 72(○)→ true", () => {
expect(judgeGrade(72)).toBe("○");
expect(mainRunSucceededFromReport(row(72, 100))).toBe(true);
});
it("M3: レポートあり・完了率 50(△)→ false", () => {
expect(judgeGrade(50)).toBe("△");
expect(mainRunSucceededFromReport(row(50, 100))).toBe(false);
});
it("M4: レポートなし → false", () => {
expect(mainRunSucceededFromReport(undefined)).toBe(false);
});
it("M5: レポートあり・task_area 0(完了率 0 → ×)→ false", () => {
// ★`task_area = 0` は 0%(P-5=B)★ — 清掃面積が大きくても成功にしない
expect(mainRunSucceededFromReport(row(500, 0))).toBe(false);
});
// ★M6(出発失敗の行は常に false)は廃止★ — 2026-10-29 にアラートから出発失敗の行を外した(ユーザー判断「2で進めてください」)。
// 親の本清掃が出発失敗の事前チェック異常は M4(レポートなし → false)で見ている
it("fixtures の行はすべて事前チェック異常(出発失敗の行が無い)", () => {
expect(fixturePrechecks.length).toBeGreaterThan(0);
for (const p of fixturePrechecks) {
expect(p.severity).toBe("precheck");
expect(Number.isInteger(p.precheck_minutes)).toBe(true);
}
});
it("fixtures: id 6(出発成功)は true、id 7(完了率62% = △)は false", () => {
const byId = (id: string) => fixturePrechecks.find((p) => p.id === id);
expect(byId("6")?.main_run_succeeded).toBe(true);
expect(byId("7")?.main_run_succeeded).toBe(false);
// ★true は id 6 の1件だけ★(ほかは false。D-171)
expect(fixturePrechecks.filter((p) => p.main_run_succeeded).map((p) => p.id)).toEqual(["6"]);
});
});