Newer
Older
client_dwatch / lib / db / __tests__ / precheck-mapping.test.ts
/**
 * `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,
  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(/想定外の値/);
  });
});

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

  it("M6: 出発失敗の行(severity: launch_failed)→ false(fixtures。Prisma 版は route.test.ts)", () => {
    const launchFailed = fixturePrechecks.filter((p) => p.severity === "launch_failed");
    expect(launchFailed.length).toBeGreaterThan(0);
    for (const p of launchFailed) expect(p.main_run_succeeded).toBe(false);
  });

  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"]);
  });
});