Newer
Older
client_dwatch / lib / facilities / __tests__ / query.test.ts
/**
 * GET /facilities(F-08)のクエリ処理のテスト。
 *
 * ★期待値表(先に確定させたもの。実装より先にこの表を書いている)★
 *
 * | #  | ケース                        | 入力                                   | 期待                                        |
 * |----|-------------------------------|----------------------------------------|---------------------------------------------|
 * | 1  | 0件                           | items=[]                               | items=[] / meta.total=0(例外を投げない)   |
 * | 2  | 1件                           | items=[A]                              | そのまま A が返る                           |
 * | 3  | 部分一致(施設名)            | facility_name="バリューマート"         | 複数ヒット                                  |
 * | 4  | 大文字小文字違い              | "8111r"                                | 8111R5628060011 にヒット                    |
 * | 5  | 該当なし                      | q="ZZZ-NOMATCH"                        | items=[] / meta.total=0                     |
 * | 6  | ページング境界                | 12件 / per_page=5                      | p1=5件, p2=5件, p3=2件, p99=0件・total=12   |
 * | 7  | ロボット0台                   | robots=[] に serial 検索               | 落ちない・ヒットしない                      |
 * | 8  | 複数台                        | 3台目のシリアル                        | ヒットする                                  |
 * | 9  | q の OR                       | q=住所の一部(施設名は不一致)         | 住所でヒットする                            |
 * | 10 | 個別パラメータの AND          | facility_name + address                | 両方満たす施設だけ残る                      |
 * | 11 | per_page/page の丸め          | per_page=9999 / 0, page=-1             | 200 / 1, 1(422にはしない)                 |
 *
 * ★fixtures には依存しない★ — `row()` で合成データを組み立てる。
 *   `lib/fixtures/facilities.ts` を使うと、fixtures を1件足しただけで
 *   件数を固定したテストが落ちる(fixtures はデモ・画面確認用で、期待値の正ではない)。
 */
import { describe, expect, it } from "vitest";
import {
  filterFacilitiesByFields,
  filterFacilitiesByQ,
  paginateFacilities,
  parseFacilityQuery,
  sortFacilities,
  type FacilityQuery,
} from "@/lib/facilities/query";
import { isSupportedModel } from "@/lib/format/robot-model";
import type { Facility, RobotSummary } from "@/lib/types";

function robot(overrides: Partial<RobotSummary> = {}): RobotSummary {
  return {
    id: 101,
    maker: "PUDU",
    model: "CC1",
    serial_number: "8111R5628060011",
    ...overrides,
  };
}

function row(overrides: Partial<Facility> = {}): Facility {
  return {
    id: 1,
    facility_name: "メガバリューマート中央本店",
    // ★所属(指示書AK 段階5)★ — この関数群は絞り込み・並べ替え・ページングだけを見るので
    //   既定値は1つで足りる(分類での絞り込みは `app/api/__tests__/master-filter.test.ts`)。
    customer_id: 1,
    customer_name: "メガバリューマート",
    group_id: 1,
    group_name: "バリューホールディングス",
    prefecture: "東京都",
    address: "東京都○○区1-2-3",
    phone: "03-0000-0001",
    manager_name: "田中 一郎",
    charger_name: "小林 美咲",
    email: "store001@example.com",
    monitoring_enabled: true,
    d_security_enabled: true,
    robots: [robot()],
    ...overrides,
  };
}

/** 個別パラメータ用の引数。指定しないものは空文字(=指定なし) */
function fields(
  overrides: Partial<{ facilityName: string; serial: string; address: string }> = {}
) {
  return { facilityName: "", serial: "", address: "", ...overrides };
}

/**
 * ★422 にならない入力専用のヘルパー★
 *   `parseFacilityQuery()` は指示書AK 段階2 で `QueryParseResult` を返すようになった
 *   (`group_id` / `customer_id` がこのエンドポイント初の「不正値がありうるパラメータ」)。
 *   ★422 のケースはここを通さず、`parseFacilityQuery()` を直接呼んで検証すること★。
 */
function parsed(params: URLSearchParams): FacilityQuery {
  const result = parseFacilityQuery(params);
  if (!result.ok) throw new Error("422を期待していない入力で422になった");
  return result.query;
}

describe("parseFacilityQuery", () => {
  it("省略時はすべて空文字・group_id / customer_id は null・page=1・per_page=50", () => {
    const query = parsed(new URLSearchParams());
    expect(query).toEqual({
      q: "",
      facilityName: "",
      serial: "",
      address: "",
      groupId: null,
      customerId: null,
      page: 1,
      perPage: 50,
    });
  });

  it("q / facility_name / serial / address を読む", () => {
    const query = parsed(
      new URLSearchParams({
        q: "中央",
        facility_name: "バリューマート",
        serial: "8111R",
        address: "東京都",
      })
    );
    expect(query.q).toBe("中央");
    expect(query.facilityName).toBe("バリューマート");
    expect(query.serial).toBe("8111R");
    expect(query.address).toBe("東京都");
  });

  it("文字列パラメータは前後の空白を除去する", () => {
    const query = parsed(
      new URLSearchParams({
        q: "  中央  ",
        facility_name: " バリューマート ",
        serial: " 8111R ",
        address: " 東京都 ",
      })
    );
    expect(query.q).toBe("中央");
    expect(query.facilityName).toBe("バリューマート");
    expect(query.serial).toBe("8111R");
    expect(query.address).toBe("東京都");
  });

  /**
   * ★空白だけの入力は「記号だけ」ではなく「未指定」になる★(2026-09-14 に確認)
   *
   * 半角スペース・全角スペース・タブは ★`foldName` の除去対象17文字に含まれる★が、
   * ★`.trim()` の除去対象でもある★(JS の `trim()` は U+3000 もタブも落とす)。
   * → ★`includesFoldName` に届く前に空文字になり、素通し(全件)になる★。
   * ★`・` や `-` は `trim()` では落ちない★ので、記号だけの入力として 0件になる。
   */
  it("★空白だけの入力は trim で空文字=未指定になる★(記号だけの 0件 とは別の道)", () => {
    const query = parsed(
      new URLSearchParams({ q: " ", facility_name: " ", serial: "\t", address: "  " })
    );
    expect(query.q).toBe("");
    expect(query.facilityName).toBe("");
    expect(query.serial).toBe("");
    expect(query.address).toBe("");

    // ★中点・ハイフンは trim で落ちない★ → 記号だけの入力としてフィルタまで届く
    const symbols = parsed(
      new URLSearchParams({ q: "・", facility_name: "-", serial: "_", address: "ー" })
    );
    expect(symbols.q).toBe("・");
    expect(symbols.facilityName).toBe("-");
    expect(symbols.serial).toBe("_");
    expect(symbols.address).toBe("ー");
  });

  // ケース11: 不正値でも422にせず既定値・上限へ丸める(parseDsDeviceQuery と同じ挙動)
  it("per_page=9999 は maxPerPage(200) に丸める", () => {
    expect(parsed(new URLSearchParams({ per_page: "9999" })).perPage).toBe(
      200
    );
  });

  it("per_page=0 は 1 に丸める", () => {
    expect(parsed(new URLSearchParams({ per_page: "0" })).perPage).toBe(1);
  });

  it("page=-1 は 1 に丸める", () => {
    expect(parsed(new URLSearchParams({ page: "-1" })).page).toBe(1);
  });

  it("数値でない page / per_page は既定値になる", () => {
    const query = parsed(
      new URLSearchParams({ page: "abc", per_page: "xyz" })
    );
    expect(query.page).toBe(1);
    expect(query.perPage).toBe(50);
  });

  /**
   * ★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 |
   *
   * ★★このエンドポイント初の 422★★ — 以前は「不正値がありえない」ため
   *   `parseFacilityQuery()` は素の型を返していた(YAML にも 422 を追記済み)。
   */
  it("group_id / customer_id を値として読む(D-053 解除。指示書AK 段階2)", () => {
    const query = parsed(new URLSearchParams({ group_id: "1", customer_id: "2" }));
    expect(query.groupId).toBe(1);
    expect(query.customerId).toBe(2);
  });

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

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

// ★fixturesからはKeenon/Phantasを外したが、メーカー混在・複数台の境界ケースは
//   ここの合成データ(row()/robot())で担保している(指示書N / D-087)。
//   fixturesに無いメーカーのテストだからといって不要と判断して消さないこと。
describe("filterFacilitiesByQ", () => {
  const items: Facility[] = [
    row({
      id: 1,
      facility_name: "メガバリューマート中央本店",
      prefecture: "東京都",
      address: "東京都○○区1-2-3",
      robots: [robot({ id: 101, serial_number: "8111R5628060011" })],
    }),
    row({
      id: 2,
      facility_name: "フレッシュタウン湖畔店",
      prefecture: "三重県",
      address: "三重県○○市○○町12-4",
      robots: [
        robot({ id: 102, maker: "Keenon", model: "T5", serial_number: "9333K7412050111" }),
      ],
    }),
  ];

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

  // ★記号ゆれ(指示書AG 段階3)★ ★F-08 の検索欄が実際に呼ぶのはこの q の側★
  //   個別パラメータ側(`filterFacilitiesByFields`)にも同じ固定があるが、
  //   画面が使う経路に無いと、q 側だけ壊れたときに赤くならない組み合わせが残る。
  //   `ドソ・キホーテ` は中点入りの架空名(fixtures の施設名には記号が1文字も無く
  //   再現できないため合成データで組む。D-6。fixtures は変更しない)。
  it("施設名は中点を打たなくても引ける/シリアルの記号は除かない", () => {
    const symbolItems: Facility[] = [
      row({
        id: 10,
        facility_name: "ドソ・キホーテ戸塚",
        robots: [robot({ id: 110, serial_number: "PUDU-CC1-0001" })],
      }),
    ];

    expect(filterFacilitiesByQ(symbolItems, "ドソキホーテ").map((f) => f.id)).toEqual([
      10,
    ]);

    // ★シリアルは記号を除かない(除くと `CC1` の `1` と `0001` が繋がって
    //   `10001` が誤ヒットする。D-2)★
    expect(filterFacilitiesByQ(symbolItems, "10001")).toEqual([]);
    expect(filterFacilitiesByQ(symbolItems, "cc1-0001").map((f) => f.id)).toEqual([10]);
  });

  /**
   * ★記号だけの入力(2026-09-14 に反転。案A)と OR の関係★
   *
   * ★反転前は `-` と打つと全件が出ていた★(施設名の照合が素通ししていたため)。
   * ★反転後は「施設名・都道府県では当たらない・住所やシリアルでは当たる」★になり、
   *   ★ハイフンを含む行だけが出る★。★「全件」でも「0件」でもない★のが狙い。
   */
  it("★`-` は施設名では当たらず、ハイフンを含む住所・シリアルの行だけが出る(OR)★", () => {
    const mixed: Facility[] = [
      row({
        id: 20,
        facility_name: "ドソ・キホーテ戸塚",
        prefecture: "東京都",
        // ★住所・シリアルの両方にハイフンが無い行★(出てはいけない)
        address: "東京都○○区○○町三丁目二番一号",
        robots: [robot({ id: 120, serial_number: "8111R5628060011" })],
      }),
      row({
        id: 21,
        facility_name: "バリューマート西通り店",
        prefecture: "東京都",
        address: "東京都○○区4-5-6", // ハイフン入りの住所
        robots: [robot({ id: 121, serial_number: "8111R5628060022" })],
      }),
      row({
        id: 22,
        facility_name: "フレッシュタウン湖畔店",
        prefecture: "三重県",
        address: "三重県○○市○○町一番地",
        robots: [robot({ id: 122, serial_number: "PUDU-CC1-0001" })], // ハイフン入りのシリアル
      }),
    ];

    // ★施設名(`includesFoldName`)では1件も当たらず、住所・シリアル側だけが当たる★
    expect(filterFacilitiesByQ(mixed, "-").map((f) => f.id)).toEqual([21, 22]);

    // ★中点は施設名にしかない★ — 施設名が素通ししていないので 0件
    expect(filterFacilitiesByQ(mixed, "・")).toEqual([]);
    expect(filterFacilitiesByQ(mixed, " ")).toEqual([]);
    expect(filterFacilitiesByQ(mixed, "・ -")).toEqual([]);

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

  // ケース3
  it("施設名の部分一致で絞り込む", () => {
    expect(filterFacilitiesByQ(items, "中央").map((f) => f.id)).toEqual([1]);
  });

  // ケース9: 施設名が一致しなくても住所でヒットする(qの内部はOR)
  it("住所の部分一致でヒットする(施設名は一致しない)", () => {
    const hit = filterFacilitiesByQ(items, "○○市○○町12-4");
    expect(hit.map((f) => f.id)).toEqual([2]);
    expect(hit[0].facility_name).not.toContain("○○市");
  });

  it("都道府県の部分一致でヒットする", () => {
    expect(filterFacilitiesByQ(items, "三重県").map((f) => f.id)).toEqual([2]);
  });

  it("シリアルの部分一致でヒットする", () => {
    expect(filterFacilitiesByQ(items, "5628060011").map((f) => f.id)).toEqual([1]);
  });

  it("メーカーの部分一致でヒットする", () => {
    expect(filterFacilitiesByQ(items, "Keenon").map((f) => f.id)).toEqual([2]);
  });

  it("機種の部分一致でヒットする", () => {
    expect(filterFacilitiesByQ(items, "CC1").map((f) => f.id)).toEqual([1]);
  });

  // ケース4
  it("大文字小文字を区別しない(8111r で 8111R5628060011 にヒット)", () => {
    expect(filterFacilitiesByQ(items, "8111r").map((f) => f.id)).toEqual([1]);
  });

  // ケース5
  it("該当なしなら空配列", () => {
    expect(filterFacilitiesByQ(items, "ZZZ-NOMATCH")).toEqual([]);
  });

  // ケース7: ロボット0台でも落ちない
  it("ロボット0台の施設があっても落ちない(施設名では拾える)", () => {
    const withEmpty: Facility[] = [
      ...items,
      row({ id: 3, facility_name: "フレッシュタウン未設置店", robots: [] }),
    ];
    expect(() => filterFacilitiesByQ(withEmpty, "8111R")).not.toThrow();
    expect(filterFacilitiesByQ(withEmpty, "8111R").map((f) => f.id)).toEqual([1]);
    expect(filterFacilitiesByQ(withEmpty, "未設置").map((f) => f.id)).toEqual([3]);
  });

  // ケース8: 3台目のシリアルでもヒットする(先頭1台しか見ない実装を検出する)
  it("複数台の施設は3台目のシリアルでもヒットする", () => {
    const multi: Facility[] = [
      row({
        id: 7,
        facility_name: "メガバリューマート緑野店",
        robots: [
          robot({ id: 107, serial_number: "8111R5628060099" }),
          robot({ id: 108, serial_number: "8111R5628060100" }),
          robot({ id: 109, maker: "Keenon", model: "T5", serial_number: "9333K7412050111" }),
        ],
      }),
    ];
    expect(filterFacilitiesByQ(multi, "9333K7412050111").map((f) => f.id)).toEqual([7]);
  });
});

describe("filterFacilitiesByFields", () => {
  const items: Facility[] = [
    row({
      id: 1,
      facility_name: "バリューマート西通り店",
      prefecture: "東京都",
      address: "東京都○○区4-5-6",
      robots: [robot({ id: 101, serial_number: "8111R5628060011" })],
    }),
    row({
      id: 2,
      facility_name: "バリューマート川下店",
      prefecture: "神奈川県",
      address: "神奈川県○○市○○区11-2-3",
      robots: [robot({ id: 102, serial_number: "8111R5628060155" })],
    }),
    row({
      id: 3,
      facility_name: "フレッシュタウン湖畔店",
      prefecture: "神奈川県",
      address: "神奈川県○○市○○町12-4",
      robots: [],
    }),
  ];

  it("すべて空なら全件返す", () => {
    expect(filterFacilitiesByFields(items, fields())).toHaveLength(3);
  });

  // ★記号ゆれ(指示書AG 段階3)★ 5系統すべてに1件ずつ置いている
  //   ★施設名は記号を無視し、住所は無視しない★(D-2)。
  //   `ドソ・キホーテ` は中点入りの架空名(fixtures の施設名には記号が1文字も無く
  //   再現できないため合成データで組む。D-6。fixtures は変更しない)。
  //   ★この個別パラメータはどの画面からも送られていない★が、API 契約上は存在するので
  //   q と同じ規則にしてある(罠10)。
  it("施設名は中点を打たなくても引ける/住所のハイフンは除かない", () => {
    const symbolItems: Facility[] = [
      row({
        id: 10,
        facility_name: "ドソ・キホーテ戸塚",
        address: "東京都○○区1-2-3",
      }),
    ];

    expect(
      filterFacilitiesByFields(
        symbolItems,
        fields({ facilityName: "ドソキホーテ" })
      ).map((f) => f.id)
    ).toEqual([10]);

    // ★住所は `1-2-3` の `-` が番地の区切りなので除かない★
    //   除く実装にすると `東京都○○区123` になり `23` が誤ヒットする
    expect(
      filterFacilitiesByFields(symbolItems, fields({ address: "23" }))
    ).toHaveLength(0);
    expect(
      filterFacilitiesByFields(symbolItems, fields({ address: "1-2-3" })).map(
        (f) => f.id
      )
    ).toEqual([10]);
  });

  /**
   * ★記号だけの入力(2026-09-14 に反転。案A)★ — 個別パラメータ側でも同じ規則にする。
   * ★反転前は `facility_name=-` で全件が出ていた★。
   * ★住所・シリアルは `includesFoldPlain` なので反転していない★(記号をそのまま探す)。
   */
  it("★facility_name に記号だけを入れると 0件★/★address・serial は記号をそのまま探す★", () => {
    const symbolItems: Facility[] = [
      row({
        id: 10,
        facility_name: "ドソ・キホーテ戸塚",
        address: "東京都○○区1-2-3",
        robots: [robot({ id: 110, serial_number: "PUDU-CC1-0001" })],
      }),
    ];

    for (const needle of ["・", "-", "_", "ー", "・ー"]) {
      expect(
        filterFacilitiesByFields(symbolItems, fields({ facilityName: needle }))
      ).toHaveLength(0);
    }

    // ★空文字(未指定)は反転していない★ — 素通し
    expect(
      filterFacilitiesByFields(symbolItems, fields({ facilityName: "" }))
    ).toHaveLength(1);

    // ★address / serial は `includesFoldPlain` のまま★ — `-` は実際に当たる
    expect(
      filterFacilitiesByFields(symbolItems, fields({ address: "-" })).map((f) => f.id)
    ).toEqual([10]);
    expect(
      filterFacilitiesByFields(symbolItems, fields({ serial: "-" })).map((f) => f.id)
    ).toEqual([10]);
  });

  // ケース3
  it("facility_name の部分一致で複数ヒットする", () => {
    expect(
      filterFacilitiesByFields(items, fields({ facilityName: "バリューマート" })).map(
        (f) => f.id
      )
    ).toEqual([1, 2]);
  });

  it("address の部分一致で絞り込む", () => {
    expect(
      filterFacilitiesByFields(items, fields({ address: "神奈川県" })).map((f) => f.id)
    ).toEqual([2, 3]);
  });

  it("serial の部分一致で絞り込む", () => {
    expect(
      filterFacilitiesByFields(items, fields({ serial: "5628060155" })).map((f) => f.id)
    ).toEqual([2]);
  });

  // ケース4
  it("大文字小文字を区別しない(8111r で 8111R… にヒット)", () => {
    expect(
      filterFacilitiesByFields(items, fields({ serial: "8111r" })).map((f) => f.id)
    ).toEqual([1, 2]);
  });

  // ケース7
  it("ロボット0台の施設は serial 検索で落ちないしヒットもしない", () => {
    expect(() =>
      filterFacilitiesByFields(items, fields({ serial: "8111R" }))
    ).not.toThrow();
    expect(
      filterFacilitiesByFields(items, fields({ serial: "8111R" })).map((f) => f.id)
    ).not.toContain(3);
  });

  // ケース8
  // ★このシリアル(9333K7412050111)はfixturesから外したKeenon T5由来の値だが、
  //   ここでは合成データとして意図的に残している(指示書N / D-087)。
  //   3台目のシリアルでヒットするかという回帰テストの主旨であり、fixturesの実態とは無関係。
  it("複数台の施設は3台目のシリアルでもヒットする", () => {
    const multi: Facility[] = [
      row({
        id: 7,
        robots: [
          robot({ id: 107, serial_number: "8111R5628060099" }),
          robot({ id: 108, serial_number: "8111R5628060100" }),
          robot({ id: 109, serial_number: "9333K7412050111" }),
        ],
      }),
    ];
    expect(
      filterFacilitiesByFields(multi, fields({ serial: "9333K7412050111" })).map(
        (f) => f.id
      )
    ).toEqual([7]);
  });

  // ケース10
  it("facility_name と address は AND(両方満たす施設だけ残る)", () => {
    expect(
      filterFacilitiesByFields(
        items,
        fields({ facilityName: "バリューマート", address: "神奈川県" })
      ).map((f) => f.id)
    ).toEqual([2]);
  });

  // ケース5
  it("AND の結果0件なら空配列", () => {
    expect(
      filterFacilitiesByFields(
        items,
        fields({ facilityName: "フレッシュタウン", address: "東京都" })
      )
    ).toEqual([]);
  });
});

describe("q と個別パラメータの関係(AND。OR は q の内部だけ)", () => {
  const items: Facility[] = [
    row({ id: 1, facility_name: "バリューマート西通り店", address: "東京都○○区4-5-6" }),
    row({ id: 2, facility_name: "バリューマート川下店", address: "神奈川県○○市11-2-3" }),
  ];

  it("q が住所で一致しても facility_name が一致しなければ落ちる", () => {
    // route.ts と同じ組み立て順(q → 個別パラメータ)
    const filtered = filterFacilitiesByFields(
      filterFacilitiesByQ(items, "神奈川県"),
      fields({ facilityName: "西通り" })
    );
    expect(filtered).toEqual([]);
  });

  it("両方満たすものだけ残る", () => {
    const filtered = filterFacilitiesByFields(
      filterFacilitiesByQ(items, "神奈川県"),
      fields({ facilityName: "バリューマート" })
    );
    expect(filtered.map((f) => f.id)).toEqual([2]);
  });
});

describe("sortFacilities", () => {
  it("id の昇順になる", () => {
    const items: Facility[] = [row({ id: 12 }), row({ id: 2 }), row({ id: 7 })];
    expect(sortFacilities(items).map((f) => f.id)).toEqual([2, 7, 12]);
  });

  it("元の配列を書き換えない(純粋関数)", () => {
    const items: Facility[] = [row({ id: 3 }), row({ id: 1 })];
    sortFacilities(items);
    expect(items.map((f) => f.id)).toEqual([3, 1]);
  });

  it("0件でも落ちない", () => {
    expect(sortFacilities([])).toEqual([]);
  });
});

describe("paginateFacilities", () => {
  // ケース6: 12件 / per_page=5 → 3ページ(最終ページが端数の2件)
  const items: Facility[] = Array.from({ length: 12 }, (_, i) => row({ id: i + 1 }));

  // ケース1
  it("0件でも落ちない(items=[] / meta.total=0)", () => {
    expect(paginateFacilities([], 1, 50)).toEqual({
      items: [],
      meta: { total: 0, page: 1, per_page: 50 },
    });
  });

  // ケース2
  it("1件ならそのまま返る", () => {
    const one = [row({ id: 5 })];
    const body = paginateFacilities(one, 1, 50);
    expect(body.items).toEqual(one);
    expect(body.meta).toEqual({ total: 1, page: 1, per_page: 50 });
  });

  it("page=1 は先頭5件", () => {
    const body = paginateFacilities(items, 1, 5);
    expect(body.items.map((f) => f.id)).toEqual([1, 2, 3, 4, 5]);
    expect(body.meta).toEqual({ total: 12, page: 1, per_page: 5 });
  });

  it("page=2 は6〜10件目", () => {
    const body = paginateFacilities(items, 2, 5);
    expect(body.items.map((f) => f.id)).toEqual([6, 7, 8, 9, 10]);
    expect(body.meta.total).toBe(12);
  });

  it("page=3(最終ページ)は端数の2件", () => {
    const body = paginateFacilities(items, 3, 5);
    expect(body.items.map((f) => f.id)).toEqual([11, 12]);
    expect(body.meta.total).toBe(12);
  });

  it("範囲外の page=99 は空だが meta.total は全件数のまま", () => {
    const body = paginateFacilities(items, 99, 5);
    expect(body.items).toEqual([]);
    expect(body.meta).toEqual({ total: 12, page: 99, per_page: 5 });
  });

  // ケース5: フィルタ後0件でも meta の形は変わらない
  it("フィルタ後0件なら items=[] / meta.total=0", () => {
    const filtered = filterFacilitiesByQ(items, "ZZZ-NOMATCH");
    expect(paginateFacilities(filtered, 1, 50)).toEqual({
      items: [],
      meta: { total: 0, page: 1, per_page: 50 },
    });
  });

  // ★meta.total は「フィルタ後・ページング前」の件数★(ページング後の件数ではない)
  it("meta.total はページング後の件数ではなくフィルタ後の総件数", () => {
    const body = paginateFacilities(items, 1, 5);
    expect(body.items).toHaveLength(5);
    expect(body.meta.total).toBe(12);
  });
});

// ★境界ケース: 未対応機種を持つ施設を合成し、isSupportedModel()がfalseを返すことの確認
//   (指示書N 4-2。fixturesからはKeenon/Phantasを外したため、この合成データが
//   「未対応機種の検出」を担保する唯一の場所になる)
describe("isSupportedModel との組み合わせ(未対応機種の検出。指示書N / D-087)", () => {
  it("Keenon T5(fixturesには存在しない機種)を持つ施設を合成すると、そのロボットは未対応と判定される", () => {
    const facility = row({
      id: 7,
      facility_name: "メガバリューマート緑野店",
      robots: [
        robot({ id: 109, maker: "Keenon", model: "T5", serial_number: "9333K7412050111" }),
      ],
    });
    expect(isSupportedModel(facility.robots[0].maker, facility.robots[0].model)).toBe(
      false
    );
  });

  it("対応済み機種(PUDU CC1)を持つ施設は未対応と判定されない", () => {
    const facility = row({ robots: [robot({ maker: "PUDU", model: "CC1" })] });
    expect(isSupportedModel(facility.robots[0].maker, facility.robots[0].model)).toBe(
      true
    );
  });
});