Newer
Older
client_dwatch / lib / prechecks / __tests__ / query.test.ts
import { describe, expect, it } from "vitest";
import { PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import {
  applyResolutionChange,
  filterPrechecksByDate,
  filterPrechecksByStatus,
  parsePrecheckQuery,
  sortPrechecks,
  summarizePrechecks,
} from "@/lib/prechecks/query";
import type { PrecheckItem } from "@/lib/types";

function row(overrides: Partial<PrecheckItem>): PrecheckItem {
  return {
    id: "1",
    severity: "precheck",
    // ★分数は直書きしない(禁止事項3)。60分前 = 1回目のチェック★
    precheck_minutes: PRECHECK_MINUTES_BEFORE[0],
    serial_number: "8111R5628060011",
    facility_id: 1,
    facility_name: "メガバリューマート中央本店",
    scheduled_at: "2026-08-27T02:00:00+09:00",
    robot_status: "充電中",
    detected_at: "2026-08-27T01:00:00+09:00",
    resolution_status: "unresolved",
    responded_at: null,
    resolved_at: null,
    note: null,
    battery_level: null,
    main_run_succeeded: false,
    ...overrides,
  };
}

/**
 * ★`main_run_succeeded` は表示のためだけの値★(ユーザー指示 2026-10-07。D-171)—
 *   本清掃が成功しても ★未解消は未解消のまま数える★(サマリの件数・絞り込みを変えない)。
 */
describe("main_run_succeeded は件数・絞り込みを変えない(D-171)", () => {
  it("true の未解消の行も unresolved に数え、status=open で残る", () => {
    const items = [
      row({ id: "a", main_run_succeeded: true }),
      row({ id: "b", main_run_succeeded: false }),
    ];
    expect(summarizePrechecks(items)).toEqual(
      summarizePrechecks(items.map((i) => ({ ...i, main_run_succeeded: false })))
    );
    expect(summarizePrechecks(items).unresolved).toBe(2);
    expect(filterPrechecksByStatus(items, "open").map((i) => i.id)).toEqual(["a", "b"]);
  });
});

describe("parsePrecheckQuery", () => {
  it("省略時は status=open になる", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.status).toBe("open");
  });

  /**
   * ★group_id / customer_id(指示書AK 段階2)★
   *
   * ★★元は「渡してもパース結果に含まれない(D-053)」だったテストを反転させたもの★★ —
   *   ★`it` ごと消さないこと★。1本も残っていないと、次に誰かが「受け取って捨てる」
   *   実装に戻しても気づけない(指示書AK 落とし穴8)。
   *
   * | # | 入力 | 期待 |
   * |---|---|---|
   * | 2-1 | `group_id=1` | `1`(★`toHaveProperty` ではなく値まで見る★) |
   * | 2-2 | `customer_id=2` | `2` |
   * | 2-3 | 未指定 | ★`null`★(絞らない) |
   * | 2-4 | `group_id=0` | ★422★(0 は「グループなし」のセンチネル。有効なIDではない。D-011) |
   * | 2-5 | `group_id=1abc` | ★422★(★`parseInt` の前方一致で `1` にしない★。D-123 で実際に踏んだ罠) |
   * | 2-6 | `group_id=-1` | 422 |
   * | 2-7 | `group_id=1.5` | 422 |
   * | 2-8 | `9007199254740992`(安全整数超) | 422 |
   */
  it("group_id / customer_id を値として読む(D-053 解除。指示書AK 段階2)", () => {
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ group_id: "1", customer_id: "2" })
    );
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.groupId).toBe(1);
    expect(parsed.query.customerId).toBe(2);
  });

  it("group_id / customer_id 未指定は null(絞らない)", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.groupId).toBeNull();
    expect(parsed.query.customerId).toBeNull();
  });

  it.each(["0", "1abc", "-1", "1.5", "9007199254740992"])(
    "group_id=%s は422",
    (value) => {
      const parsed = parsePrecheckQuery(new URLSearchParams({ group_id: value }));
      expect(parsed.ok).toBe(false);
      if (parsed.ok) throw new Error("422を期待");
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.details).toEqual({
        group_id: ["1以上の整数で指定してください"],
      });
    }
  );

  it.each(["0", "1abc"])("customer_id=%s も同じ規則で422", (value) => {
    const parsed = parsePrecheckQuery(new URLSearchParams({ customer_id: value }));
    expect(parsed.ok).toBe(false);
    if (parsed.ok) throw new Error("422を期待");
    expect(parsed.status).toBe(422);
    expect(parsed.error.error.details).toEqual({
      customer_id: ["1以上の整数で指定してください"],
    });
  });

  it("statusが不正な値なら422", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams({ status: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.code).toBe("validation_failed");
    }
  });

  it("date_fromの形式が不正なら422", () => {
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_from: "2026/08/27" })
    );
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.code).toBe("validation_failed");
    }
  });

  it("date_toの形式が不正なら422", () => {
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_to: "not-a-date" })
    );
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.code).toBe("validation_failed");
    }
  });

  // ★どのパラメータが不正か呼び出し側に伝わること(details)を固定する★
  //   details が null に退化すると、画面側は「どこを直せばいいか」を示せない
  it("date_from不正のmessage/detailsは/clean-logsと同じ表現にする", () => {
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_from: "2026/08/27" })
    );
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.error.error.message).toBe("date_fromの形式が不正です");
      expect(parsed.error.error.details).toEqual({
        date_from: ["YYYY-MM-DD形式で指定してください"],
      });
    }
  });

  it("date_to不正のmessage/detailsは/clean-logsと同じ表現にする", () => {
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_to: "not-a-date" })
    );
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.error.error.message).toBe("date_toの形式が不正です");
      expect(parsed.error.error.details).toEqual({
        date_to: ["YYYY-MM-DD形式で指定してください"],
      });
    }
  });

  /**
   * ★★空文字は「指定なし」として `null` に正規化すること★★
   *
   * ここが `""` のまま下へ抜けると、`db.prechecks()` の実装で意味が割れます:
   *   - Prisma 版  … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★
   *   - fixtures 版 … `date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★
   * ★★`USE_FIXTURES` の true/false で件数が変わる★★(指示書AC 禁止事項6)。
   * ★`filterPrechecksByDate()` の truthy 判定とも意味が揃う★。
   */
  it("★date_from / date_to が空文字なら null(=指定なし)に正規化する★", () => {
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_from: "", date_to: "" })
    );
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.dateFrom).toBeNull();
    expect(parsed.query.dateTo).toBeNull();
  });

  it("★空文字と未指定でパース結果が一致する★", () => {
    const omitted = parsePrecheckQuery(new URLSearchParams());
    const empty = parsePrecheckQuery(
      new URLSearchParams({ date_from: "", date_to: "" })
    );
    expect(empty).toEqual(omitted);
  });

  it("空白だけの date_to は422(空文字の素通しと混同しない)", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams({ date_to: " " }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) expect(parsed.status).toBe(422);
  });

  it("status不正のdetailsは選択肢を列挙する", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams({ status: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.error.error.message).toBe("statusの値が不正です");
      expect(parsed.error.error.details).toEqual({
        status: [
          "open / unresolved / in_progress / resolved / all のいずれかを指定してください",
        ],
      });
    }
  });

  it("page/per_pageの既定値と上限", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.page).toBe(1);
    expect(parsed.query.perPage).toBe(50);

    const capped = parsePrecheckQuery(new URLSearchParams({ per_page: "9999" }));
    expect(capped.ok).toBe(true);
    if (!capped.ok) throw new Error("query");
    expect(capped.query.perPage).toBe(200);
  });

  it("openを明示しても通る", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams({ status: "open" }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.status).toBe("open");
  });
});

describe("filterPrechecksByStatus", () => {
  const items: PrecheckItem[] = [
    row({
      id: "1",
      severity: "precheck",
      precheck_minutes: PRECHECK_MINUTES_BEFORE[0],
      resolution_status: "unresolved",
    }),
    row({
      id: "2",
      severity: "precheck",
      precheck_minutes: PRECHECK_MINUTES_BEFORE[1],
      resolution_status: "in_progress",
    }),
    // ★設定値以外の分数(D-093 項2)★。旧: 出発失敗(`launch_failed`)の行だった(2026-10-29 に廃止)
    row({
      id: "3",
      severity: "precheck",
      precheck_minutes: 20,
      resolution_status: "resolved",
    }),
  ];

  it("全件を保持できる(status=all)", () => {
    const result = filterPrechecksByStatus(items, "all");
    expect(result.map((r) => r.id)).toEqual(["1", "2", "3"]);
    // ★分数は severity ではなく precheck_minutes が持つ(D-105)★
    expect(result.map((r) => r.precheck_minutes)).toEqual([
      PRECHECK_MINUTES_BEFORE[0],
      PRECHECK_MINUTES_BEFORE[1],
      20,
    ]);
  });

  it("status=open は対応中を含む(unresolved + in_progress)", () => {
    expect(filterPrechecksByStatus(items, "open").map((r) => r.id)).toEqual(["1", "2"]);
  });

  it("status=unresolved は未解消のみ", () => {
    expect(filterPrechecksByStatus(items, "unresolved").map((r) => r.id)).toEqual(["1"]);
  });

  it("status=in_progress は対応中のみ", () => {
    expect(filterPrechecksByStatus(items, "in_progress").map((r) => r.id)).toEqual(["2"]);
  });

  it("status=resolved は解消済みのみ", () => {
    expect(filterPrechecksByStatus(items, "resolved").map((r) => r.id)).toEqual(["3"]);
  });
});

describe("filterPrechecksByDate", () => {
  it("日付範囲で0件になる(fixturesの3日前は意図的にデータなし)", () => {
    const dated = [row({ id: "1", scheduled_at: "2026-08-27T02:00:00+09:00" })];
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_from: "2026-08-24", date_to: "2026-08-24" })
    );
    if (!parsed.ok) throw new Error("query");
    expect(filterPrechecksByDate(dated, parsed.query)).toHaveLength(0);
  });

  it("日付範囲内なら残る(両端含む)", () => {
    const dated = [
      row({ id: "1", scheduled_at: "2026-08-25T02:00:00+09:00" }),
      row({ id: "2", scheduled_at: "2026-08-27T02:00:00+09:00" }),
      row({ id: "3", scheduled_at: "2026-08-29T02:00:00+09:00" }),
    ];
    const parsed = parsePrecheckQuery(
      new URLSearchParams({ date_from: "2026-08-25", date_to: "2026-08-27" })
    );
    if (!parsed.ok) throw new Error("query");
    expect(filterPrechecksByDate(dated, parsed.query).map((r) => r.id)).toEqual([
      "1",
      "2",
    ]);
  });

  it("statusパラメータを見ない(日付だけで絞る)", () => {
    const dated = [row({ id: "1", scheduled_at: "2026-08-27T02:00:00+09:00" })];
    const openQuery = parsePrecheckQuery(
      new URLSearchParams({ status: "open", date_from: "2026-08-27", date_to: "2026-08-27" })
    );
    const resolvedQuery = parsePrecheckQuery(
      new URLSearchParams({ status: "resolved", date_from: "2026-08-27", date_to: "2026-08-27" })
    );
    if (!openQuery.ok || !resolvedQuery.ok) throw new Error("query");
    expect(filterPrechecksByDate(dated, openQuery.query)).toEqual(
      filterPrechecksByDate(dated, resolvedQuery.query)
    );
  });
});

describe("summaryはstatusフィルタから独立している(D-054)", () => {
  const items: PrecheckItem[] = [
    row({ id: "1", resolution_status: "unresolved" }),
    row({ id: "2", resolution_status: "unresolved" }),
    row({ id: "3", resolution_status: "in_progress" }),
    row({ id: "4", resolution_status: "resolved" }),
    row({ id: "5", resolution_status: "resolved" }),
  ];

  it("同じ日付範囲でstatusをopen/all/resolvedに変えてもsummaryは同一(一覧だけ変わる)", () => {
    const openQuery = parsePrecheckQuery(new URLSearchParams({ status: "open" }));
    const allQuery = parsePrecheckQuery(new URLSearchParams({ status: "all" }));
    const resolvedQuery = parsePrecheckQuery(new URLSearchParams({ status: "resolved" }));
    if (!openQuery.ok || !allQuery.ok || !resolvedQuery.ok) {
      throw new Error("query");
    }

    // route.ts と同じ組み立て順: 日付で絞る → summary → statusで絞る → 一覧
    const datedForOpen = filterPrechecksByDate(items, openQuery.query);
    const datedForAll = filterPrechecksByDate(items, allQuery.query);
    const datedForResolved = filterPrechecksByDate(items, resolvedQuery.query);

    const summaryOpen = summarizePrechecks(datedForOpen);
    const summaryAll = summarizePrechecks(datedForAll);
    const summaryResolved = summarizePrechecks(datedForResolved);

    expect(summaryOpen).toEqual({ unresolved: 3, resolved: 2, total: 5 });
    expect(summaryOpen).toEqual(summaryAll);
    expect(summaryOpen).toEqual(summaryResolved);

    // 一覧(items)はstatusで変わる。既定表示(open)で解消済みが0件になる旧バグの再発防止
    expect(filterPrechecksByStatus(datedForOpen, "open")).toHaveLength(3);
    expect(filterPrechecksByStatus(datedForAll, "all")).toHaveLength(5);
    expect(filterPrechecksByStatus(datedForResolved, "resolved")).toHaveLength(2);
  });

  it("summaryは日付範囲では変わる", () => {
    const dated = [
      row({ id: "1", scheduled_at: "2026-08-25T02:00:00+09:00", resolution_status: "unresolved" }),
      row({ id: "2", scheduled_at: "2026-08-27T02:00:00+09:00", resolution_status: "resolved" }),
      row({ id: "3", scheduled_at: "2026-08-29T02:00:00+09:00", resolution_status: "resolved" }),
    ];
    const wideQuery = parsePrecheckQuery(
      new URLSearchParams({ status: "all", date_from: "2026-08-25", date_to: "2026-08-29" })
    );
    const narrowQuery = parsePrecheckQuery(
      new URLSearchParams({ status: "all", date_from: "2026-08-25", date_to: "2026-08-25" })
    );
    if (!wideQuery.ok || !narrowQuery.ok) throw new Error("query");

    const wideSummary = summarizePrechecks(
      filterPrechecksByDate(dated, wideQuery.query)
    );
    const narrowSummary = summarizePrechecks(
      filterPrechecksByDate(dated, narrowQuery.query)
    );

    expect(wideSummary).toEqual({ unresolved: 1, resolved: 2, total: 3 });
    expect(narrowSummary).toEqual({ unresolved: 1, resolved: 0, total: 1 });
    expect(narrowSummary.total).toBeLessThan(wideSummary.total);
  });
});

describe("sortPrechecks", () => {
  it("detected_atの降順にする", () => {
    const items = [
      row({ id: "1", detected_at: "2026-08-27T01:00:00+09:00" }),
      row({ id: "2", detected_at: "2026-08-27T03:00:00+09:00" }),
      row({ id: "3", detected_at: "2026-08-27T02:00:00+09:00" }),
    ];
    expect(sortPrechecks(items).map((r) => r.id)).toEqual(["2", "3", "1"]);
  });

  it("detected_atが同値のときはidの昇順で安定させる(D-052)", () => {
    const items = [
      row({ id: "3", detected_at: "2026-08-27T01:00:00+09:00" }),
      row({ id: "1", detected_at: "2026-08-27T01:00:00+09:00" }),
      row({ id: "2", detected_at: "2026-08-27T01:00:00+09:00" }),
    ];
    expect(sortPrechecks(items).map((r) => r.id)).toEqual(["1", "2", "3"]);
  });

  // ★id は string(D-105)なので比較は辞書順になる★
  //   `error_id`(varchar(50))由来で数値である保証がないため、数値としての昇順は定義できない。
  //   ここで固定しているのは「安定した順序で並ぶこと」(D-052 の同値タイブレーク)
  it("idが文字列でも同値タイブレークが壊れない(辞書順)", () => {
    const items = [
      row({ id: "RER-1001-2", detected_at: "2026-08-27T01:00:00+09:00" }),
      row({ id: "RER-1001-1", detected_at: "2026-08-27T01:00:00+09:00" }),
      row({ id: "1001", detected_at: "2026-08-27T01:00:00+09:00" }),
    ];
    expect(sortPrechecks(items).map((r) => r.id)).toEqual([
      "1001",
      "RER-1001-1",
      "RER-1001-2",
    ]);
  });

  it("idが同値なら順序を入れ替えない(0を返す)", () => {
    const items = [
      row({ id: "same", detected_at: "2026-08-27T01:00:00+09:00", serial_number: "A" }),
      row({ id: "same", detected_at: "2026-08-27T01:00:00+09:00", serial_number: "B" }),
    ];
    expect(sortPrechecks(items).map((r) => r.serial_number)).toEqual(["A", "B"]);
  });
});

describe("summarizePrechecks", () => {
  it("unresolved + resolved === total になる", () => {
    const items = [
      row({ id: "1", resolution_status: "unresolved" }),
      row({ id: "2", resolution_status: "unresolved" }),
      row({ id: "3", resolution_status: "in_progress" }),
      row({ id: "4", resolution_status: "in_progress" }),
      row({ id: "5", resolution_status: "resolved" }),
    ];
    const summary = summarizePrechecks(items);
    expect(summary).toEqual({ unresolved: 4, resolved: 1, total: 5 });
    expect(summary.unresolved + summary.resolved).toBe(summary.total);
  });

  it("unresolvedは対応中を含む", () => {
    const summary = summarizePrechecks([
      row({ id: "1", resolution_status: "in_progress" }),
    ]);
    expect(summary.unresolved).toBe(1);
  });

  it("0件でも壊れない", () => {
    expect(summarizePrechecks([])).toEqual({
      unresolved: 0,
      resolved: 0,
      total: 0,
    });
  });
});

/**
 * 指示書AT §3-8 — 画面で1行の対応状況を変えたときに、サマリ(未解消/解消/合計)へ差分だけ反映する。
 * ★`summary.unresolved` は「未解消+対応中」★(D-054)なので、未解消↔対応中では動かない。
 */
describe("applyResolutionChange", () => {
  const summary = { unresolved: 4, resolved: 2, total: 6 };

  it.each([
    ["unresolved", "in_progress"],
    ["in_progress", "unresolved"],
    ["unresolved", "unresolved"],
    ["resolved", "resolved"],
  ] as const)("%s → %s は件数が動かない", (before, after) => {
    expect(applyResolutionChange(summary, before, after)).toEqual(summary);
  });

  it.each([
    ["unresolved", "resolved"],
    ["in_progress", "resolved"],
  ] as const)("%s → %s(解消に入る)は unresolved -1 / resolved +1", (before, after) => {
    expect(applyResolutionChange(summary, before, after)).toEqual({ unresolved: 3, resolved: 3, total: 6 });
  });

  it.each([
    ["resolved", "unresolved"],
    ["resolved", "in_progress"],
  ] as const)("%s → %s(解消から出る)は unresolved +1 / resolved -1", (before, after) => {
    expect(applyResolutionChange(summary, before, after)).toEqual({ unresolved: 5, resolved: 1, total: 6 });
  });

  it("total は常に不変で、元のオブジェクトを書き換えない", () => {
    const original = { ...summary };
    const next = applyResolutionChange(summary, "unresolved", "resolved");
    expect(next.total).toBe(summary.total);
    expect(summary).toEqual(original);
  });

  it("summarizePrechecks() で数え直した結果と一致する", () => {
    const items = [
      row({ id: "1", resolution_status: "unresolved" }),
      row({ id: "2", resolution_status: "in_progress" }),
      row({ id: "3", resolution_status: "resolved" }),
    ];
    const before = summarizePrechecks(items);
    const changed = items.map((item) =>
      item.id === "2" ? { ...item, resolution_status: "resolved" as const } : item
    );
    expect(applyResolutionChange(before, "in_progress", "resolved")).toEqual(summarizePrechecks(changed));
  });
});

// ★表8 P1・P2(指示書BB A9・D-182)★ — `date_from > date_to` は422(`/errors` と同じ文言・details なし)
describe("parsePrecheckQuery — date_from と date_to の前後", () => {
  it("★P1: date_from=2026-10-10&date_to=2026-10-01 → 422★", () => {
    const parsed = parsePrecheckQuery(new URLSearchParams({ date_from: "2026-10-10", date_to: "2026-10-01" }));
    expect(parsed.ok).toBe(false);
    if (parsed.ok) throw new Error("expected failure");
    expect(parsed.status).toBe(422);
    expect(parsed.error).toEqual({
      error: {
        code: "validation_failed",
        message: "date_fromはdate_to以前の日付を指定してください",
        details: null,
      },
    });
  });

  it("P2: date_from = date_to → 通る", () => {
    expect(parsePrecheckQuery(new URLSearchParams({ date_from: "2026-10-01", date_to: "2026-10-01" })).ok).toBe(true);
  });
});