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