Newer
Older
client_dwatch / lib / ds-devices / __tests__ / query.test.ts
import { describe, expect, it } from "vitest";
import {
  filterDsDevicesByQ,
  filterDsDevicesByStatus,
  parseDsDeviceQuery,
  sortDsDevices,
  summarizeDsDevices,
} from "@/lib/ds-devices/query";
import type { DsDevice } from "@/lib/types";

function row(overrides: Partial<DsDevice>): DsDevice {
  return {
    serial_number: "DS-001",
    facility_id: 1,
    facility_name: "メガバリューマート中央本店",
    connection_status: "ok",
    last_reported_at: "2026-08-27T09:00:00+09:00",
    note: null,
    ...overrides,
  };
}

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

  it("statusが不正な値なら422", () => {
    const parsed = parseDsDeviceQuery(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");
    }
  });

  // ★どのパラメータが不正か呼び出し側に伝わること(details)を固定する★
  it("status不正のdetailsは選択肢を列挙する", () => {
    const parsed = parseDsDeviceQuery(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: ["all / error / ok のいずれかを指定してください"],
      });
    }
  });

  /**
   * ★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 = parseDsDeviceQuery(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 = parseDsDeviceQuery(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 = parseDsDeviceQuery(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 = parseDsDeviceQuery(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("page/per_pageの既定値と上限", () => {
    const parsed = parseDsDeviceQuery(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 = parseDsDeviceQuery(new URLSearchParams({ per_page: "9999" }));
    expect(capped.ok).toBe(true);
    if (!capped.ok) throw new Error("query");
    expect(capped.query.perPage).toBe(200);
  });

  it("qは前後の空白を除去する", () => {
    const parsed = parseDsDeviceQuery(new URLSearchParams({ q: "  中央  " }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.q).toBe("中央");
  });

  /**
   * ★空白だけの入力は「記号だけ」ではなく「未指定」になる★(2026-09-14 に確認)
   *
   * 半角スペース・全角スペース・タブは ★`foldName` の除去対象17文字に含まれる★が、
   * ★`.trim()` の除去対象でもある★(JS の `trim()` は U+3000 もタブも落とす)。
   * そのため ★`includesFoldName` に届く前に空文字になり、素通し(全件)になる★。
   * ★`・` や `-` は `trim()` では落ちない★ので、記号だけの入力として 0件になる。
   * ★この違いはパーサ側で決まる★ので、`lib/format/search-text.ts` を読んでも分からない。
   */
  it("★空白だけの q は trim で空文字=未指定になる★(記号だけの 0件 とは別の道)", () => {
    for (const raw of [" ", " ", "\t", "  \t  "]) {
      const parsed = parseDsDeviceQuery(new URLSearchParams({ q: raw }));
      expect(parsed.ok).toBe(true);
      if (!parsed.ok) throw new Error("query");
      expect(parsed.query.q).toBe("");
    }

    // ★中点・ハイフンは trim で落ちない★ → 記号だけの入力としてフィルタまで届く
    for (const raw of ["・", "-", "_", "ー"]) {
      const parsed = parseDsDeviceQuery(new URLSearchParams({ q: raw }));
      expect(parsed.ok).toBe(true);
      if (!parsed.ok) throw new Error("query");
      expect(parsed.query.q).toBe(raw);
    }
  });
});

describe("filterDsDevicesByQ", () => {
  const items: DsDevice[] = [
    row({ serial_number: "DS-001", facility_name: "メガバリューマート中央本店" }),
    row({ serial_number: "DS-002", facility_name: "バリューマート西通り店" }),
  ];

  it("施設名の部分一致で絞り込む", () => {
    expect(filterDsDevicesByQ(items, "中央").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);
  });

  it("シリアルの部分一致で絞り込む", () => {
    expect(filterDsDevicesByQ(items, "DS-002").map((d) => d.serial_number)).toEqual([
      "DS-002",
    ]);
  });

  it("大文字小文字を区別しない", () => {
    expect(filterDsDevicesByQ(items, "ds-001").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);
  });

  it("q が空なら全件返す", () => {
    expect(filterDsDevicesByQ(items, "")).toHaveLength(2);
  });

  it("q=ZZZ-NOMATCH で0件(docs/fixtures.mdの再現条件)", () => {
    expect(filterDsDevicesByQ(items, "ZZZ-NOMATCH")).toHaveLength(0);
  });

  // ★記号ゆれ(指示書AG 段階3)★ 5系統すべてに1件ずつ置いている
  //   `ドソ・キホーテ` は中点入りの架空名(fixtures の施設名には記号が1文字も無く
  //   再現できないため合成データで組む。D-6。fixtures は変更しない)。
  it("施設名は中点を打たなくても引ける/シリアルの記号は除かない", () => {
    const symbolItems: DsDevice[] = [
      row({ serial_number: "DS-001", facility_name: "ドソ・キホーテ戸塚" }),
    ];
    expect(
      filterDsDevicesByQ(symbolItems, "ドソキホーテ").map((d) => d.serial_number)
    ).toEqual(["DS-001"]);
    // ★シリアルは記号を除かない★(除くと `DS001` になり、桁の境界が消える。D-2)
    expect(filterDsDevicesByQ(symbolItems, "DS001")).toHaveLength(0);
  });

  /**
   * ★記号だけの入力(2026-09-14 に反転。案A)と OR の関係★
   *
   * ★反転前は `-` と打つと全件が出ていた★(`includesFoldName` が素通ししていたため、
   *   施設名の側がすべての行に当たっていた)。
   * ★反転後は「施設名では当たらない・シリアルでは当たる」★になり、
   *   ★ハイフン入りのシリアルの行だけが出る★。これが狙った結果
   *   (★「全件」でも「0件」でもない★ことを、この OR の経路で固定する)。
   */
  it("★`-` は施設名では当たらず、ハイフン入りシリアルの行だけが出る(OR)★", () => {
    const mixed: DsDevice[] = [
      // ハイフン入りのシリアル(`includesFoldPlain` で当たる)
      row({ serial_number: "DS-001", facility_name: "ドソ・キホーテ戸塚" }),
      // ★記号を持たないシリアル★(施設名にもシリアルにも `-` が無い = 出てはいけない行)
      row({ serial_number: "DS002", facility_name: "バリューマート西通り店" }),
    ];

    expect(filterDsDevicesByQ(mixed, "-").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);

    // ★中点は施設名にしかないが、`foldPlain` は記号を残すのでシリアル側にも当たらない★
    //   = 記号だけの入力で施設名が素通ししていないことの裏取り(0件になる)
    expect(filterDsDevicesByQ(mixed, "・")).toHaveLength(0);
    expect(filterDsDevicesByQ(mixed, " ")).toHaveLength(0);

    // ★空文字(未指定)は反転していない★ — 素通しで全件
    expect(filterDsDevicesByQ(mixed, "")).toHaveLength(2);
  });
});

describe("filterDsDevicesByStatus", () => {
  const items: DsDevice[] = [
    row({ serial_number: "DS-001", connection_status: "ok" }),
    row({ serial_number: "DS-002", connection_status: "error" }),
    row({ serial_number: "DS-003", connection_status: "error" }),
  ];

  it("2状態すべてをstatus=allで保持できる", () => {
    expect(filterDsDevicesByStatus(items, "all")).toHaveLength(3);
  });

  it("status=error は異常のみ(通信断は異常に含まれる。D-065)", () => {
    expect(
      filterDsDevicesByStatus(items, "error").map((d) => d.serial_number)
    ).toEqual(["DS-002", "DS-003"]);
  });

  it("status=ok は正常のみ", () => {
    expect(filterDsDevicesByStatus(items, "ok").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);
  });
});

describe("sortDsDevices", () => {
  it("異常→正常の順になる", () => {
    const items: DsDevice[] = [
      row({ serial_number: "DS-003", connection_status: "ok" }),
      row({ serial_number: "DS-002", connection_status: "error" }),
      row({ serial_number: "DS-001", connection_status: "error" }),
    ];
    expect(sortDsDevices(items).map((d) => d.connection_status)).toEqual([
      "error",
      "error",
      "ok",
    ]);
  });

  it("同一状態内はserial_numberの昇順になる", () => {
    const items: DsDevice[] = [
      row({ serial_number: "DS-003", connection_status: "ok" }),
      row({ serial_number: "DS-001", connection_status: "ok" }),
      row({ serial_number: "DS-002", connection_status: "ok" }),
    ];
    expect(sortDsDevices(items).map((d) => d.serial_number)).toEqual([
      "DS-001",
      "DS-002",
      "DS-003",
    ]);
  });
});

describe("summarizeDsDevices", () => {
  it("total === ok + error になる", () => {
    const items: DsDevice[] = [
      row({ serial_number: "DS-001", connection_status: "ok" }),
      row({ serial_number: "DS-002", connection_status: "ok" }),
      row({ serial_number: "DS-003", connection_status: "error" }),
      row({ serial_number: "DS-004", connection_status: "error" }),
    ];
    const summary = summarizeDsDevices(items);
    expect(summary).toEqual({ total: 4, ok: 2, error: 2 });
    expect(summary.ok + summary.error).toBe(summary.total);
  });

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

  // NOTE: 「total - ok を error として、未知の3状態目を黙って異常扱いする」バグを検出する
  // ケースを追加したかったが、DsConnectionStatus は "ok" | "error" の
  // 閉じたunion(lib/types/index.ts。D-065 で2状態に確定)で、3つ目の値を持つ DsDevice は
  // any キャスト無しでは作れない(CLAUDE.md で any 禁止)。よって summarizeDsDevices 側は
  // total - ok ではなく error を明示的に数える実装にすることで対処し、テストは
  // 「ok + error === total」を固定するのみに留める。3状態目が実際に増える時は、
  // 型を先に拡張してからこのテストにケースを足すこと。
});

describe("summaryはq/statusフィルタから独立している(D-054と同じ規則、D-055の説明対象)", () => {
  const items: DsDevice[] = [
    row({ serial_number: "DS-001", facility_name: "メガバリューマート中央本店", connection_status: "ok" }),
    row({ serial_number: "DS-002", facility_name: "バリューマート西通り店", connection_status: "error" }),
    row({ serial_number: "DS-003", facility_name: "バリューマート北町店", connection_status: "error" }),
  ];

  it("qやstatusを変えてもsummaryは同一(全デバイスに対する集計)", () => {
    // route.ts と同じ組み立て順: summaryは全件(フィルタ前)に対して計算する
    const summaryBase = summarizeDsDevices(items);
    const summaryAfterQ = summarizeDsDevices(items);
    const summaryAfterStatus = summarizeDsDevices(items);

    expect(summaryBase).toEqual({ total: 3, ok: 1, error: 2 });
    expect(summaryBase).toEqual(summaryAfterQ);
    expect(summaryBase).toEqual(summaryAfterStatus);

    // 一覧側はq/statusで変わる
    expect(filterDsDevicesByQ(items, "西通り")).toHaveLength(1);
    expect(filterDsDevicesByStatus(items, "ok")).toHaveLength(1);
  });
});