/**
* GET /schedules のクエリ処理のテスト。
*
* ★fixtures には依存しない★
* `lib/fixtures/schedules.ts` は「どの曜日でも active な行が常に合計10件になる」よう
* 補完ペアで精密に組まれており(ダッシュボード集計 `planned` と清掃ログ10行の一致用)、
* ここから参照すると「fixtures を1件足したらこのテストが落ちる」結合を生む。
* 逆に、このテストの都合で fixtures をいじると**エラーもテスト失敗も出ないまま**
* ダッシュボードが壊れる。よって合成データ(`row()`)だけでテストする。
*
* ------------------------------------------------------------------------
* ★期待値表★(実装前に確定させたもの。CLAUDE.md §6.2)
* ------------------------------------------------------------------------
* | # | ケース | 入力 | 期待 |
* |----|-------------------------------|-----------------------------------|---------------------------------------|
* | 1 | 0件 | items=[] | [] / total=0 |
* | 2 | 1件 | items=[A] | [A] そのまま / total=1 |
* | 3 | 部分一致(施設名) | q="中央" | 中央本店のみヒット |
* | 4 | 大文字小文字違い | q="8111r" | "8111R..." にヒット |
* | 5 | 該当なし | q="ZZZ-NOMATCH" | [] |
* | 6 | ページング境界 | 5件 / per_page=2 | p1=[1,2] p2=[3,4] p3=[5] p4=[] total=5|
* | 7 | active:false も返る | 有効1 + 無効1 | 2件(APIはactiveで絞らない) |
* | 8 | q の OR | q=シリアルの一部 | 施設名が違う行がヒット |
* | 9 | per_page の丸め | per_page=9999 / 0 / page=-1 | 200 / 1 / 1 |
* ------------------------------------------------------------------------
*/
import { describe, expect, it } from "vitest";
import {
filterSchedules,
filterSchedulesByQ,
paginateSchedules,
parseScheduleQuery,
sortSchedules,
} from "@/lib/schedules/query";
import type { Schedule } from "@/lib/types";
function row(overrides: Partial<Schedule> = {}): Schedule {
return {
id: 1,
serial_number: "8111R5628060011",
facility_id: 1,
facility_name: "メガバリューマート中央本店",
weekdays: "1111111",
weekdays_label: "毎日",
start_time: "02:00",
end_time: "03:00",
active: true,
...overrides,
};
}
/** ルートと同じ組み立て順(filter → sort → paginate)。総件数は paginate 前の件数 */
function list(
items: Schedule[],
searchParams: Record<string, string> = {}
): { items: Schedule[]; total: number } {
const parsed = parseScheduleQuery(new URLSearchParams(searchParams));
if (!parsed.ok) throw new Error("query");
const sorted = sortSchedules(filterSchedules(items, parsed.query));
return {
items: paginateSchedules(sorted, parsed.query.page, parsed.query.perPage),
total: sorted.length,
};
}
/**
* ★group_id / customer_id(指示書AK 段階2)★
*
* ★★このファイルには D-053 の「無視する」コメントも既存テストも無かった★★ —
* `d-watch-api.yaml` は最初からこの2つを宣言していて、★パーサが黙って捨てていた★。
* だからここは「反転」ではなく ★新規追加★(他の5本の兄弟APIと同じ期待値表に揃える)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | 2-1 | `group_id=1` | `1`(★値まで見る★) |
* | 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 |
*
* ★同時指定は AND★(`d-watch-api.yaml` の `GroupId`)。★どちらか片方を捨てない★ —
* ここでは両方が値として残ることだけを確かめる(施設への解決は段階3、反映は段階4)。
*/
describe("parseScheduleQuery — group_id / customer_id(指示書AK 段階2)", () => {
it("両方を値として読む(AND。★片方を捨てない★)", () => {
const parsed = parseScheduleQuery(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("未指定は null(絞らない)", () => {
const parsed = parseScheduleQuery(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 = parseScheduleQuery(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 = parseScheduleQuery(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以上の整数で指定してください"],
});
});
});
describe("parseScheduleQuery", () => {
it("省略時は q / facility_name / serial が空、group_id / customer_id は null、page=1、per_page=50", () => {
const parsed = parseScheduleQuery(new URLSearchParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query).toEqual({
q: "",
facilityName: "",
serial: "",
groupId: null,
customerId: null,
page: 1,
perPage: 50,
});
});
it("q / facility_name / serial は前後の空白を除去する", () => {
const parsed = parseScheduleQuery(
new URLSearchParams({
q: " 中央 ",
facility_name: " 西通り ",
serial: " 8111R ",
})
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.q).toBe("中央");
expect(parsed.query.facilityName).toBe("西通り");
expect(parsed.query.serial).toBe("8111R");
});
/**
* ★空白だけの入力は「記号だけ」ではなく「未指定」になる★(2026-09-14 に確認)
*
* 半角スペース・全角スペース・タブは ★`foldName` の除去対象17文字に含まれる★が、
* ★`.trim()` の除去対象でもある★(JS の `trim()` は U+3000 もタブも落とす)。
* → ★`includesFoldName` に届く前に空文字になり、素通し(全件)になる★。
* ★`・` や `-` は `trim()` では落ちない★ので、記号だけの入力として 0件になる。
*/
it("★空白だけの入力は trim で空文字=未指定になる★(記号だけの 0件 とは別の道)", () => {
const parsed = parseScheduleQuery(
new URLSearchParams({ q: " ", facility_name: " ", serial: "\t" })
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.q).toBe("");
expect(parsed.query.facilityName).toBe("");
expect(parsed.query.serial).toBe("");
// ★中点・ハイフンは trim で落ちない★ → 記号だけの入力としてフィルタまで届く
const symbols = parseScheduleQuery(
new URLSearchParams({ q: "・", facility_name: "-", serial: "_" })
);
expect(symbols.ok).toBe(true);
if (!symbols.ok) throw new Error("query");
expect(symbols.query.q).toBe("・");
expect(symbols.query.facilityName).toBe("-");
expect(symbols.query.serial).toBe("_");
});
// 表#9: 不正な page / per_page は 422 にせず丸める(parseDsDeviceQuery と同じ)
it("per_page は上限200に丸める", () => {
const parsed = parseScheduleQuery(new URLSearchParams({ per_page: "9999" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.perPage).toBe(200);
});
it("per_page=0 は1に丸める", () => {
const parsed = parseScheduleQuery(new URLSearchParams({ per_page: "0" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.perPage).toBe(1);
});
it("page=-1 は1に丸める", () => {
const parsed = parseScheduleQuery(new URLSearchParams({ page: "-1" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.page).toBe(1);
});
it("数値でない page / per_page は既定値に落とす", () => {
const parsed = parseScheduleQuery(
new URLSearchParams({ page: "abc", per_page: "abc" })
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.page).toBe(1);
expect(parsed.query.perPage).toBe(50);
});
});
describe("filterSchedulesByQ", () => {
const items: Schedule[] = [
row({
id: 1,
serial_number: "8111R5628060011",
facility_name: "メガバリューマート中央本店",
}),
row({
id: 2,
serial_number: "9222K7788990022",
facility_name: "バリューマート西通り店",
}),
];
// 表#3
it("施設名の部分一致でヒットする", () => {
expect(filterSchedulesByQ(items, "中央").map((s) => s.id)).toEqual([1]);
});
// 表#4: 大文字小文字を無視する
it("大文字小文字を区別しない(8111r で 8111R... にヒット)", () => {
expect(filterSchedulesByQ(items, "8111r").map((s) => s.id)).toEqual([1]);
});
// 表#8: q の中だけが OR(施設名が一致しなくてもシリアルで拾える)
it("シリアルの一部で、施設名が違う行がヒットする(q は OR)", () => {
expect(filterSchedulesByQ(items, "7788990").map((s) => s.id)).toEqual([2]);
});
it("q が空なら全件返す", () => {
expect(filterSchedulesByQ(items, "")).toHaveLength(2);
});
// 表#5
it("該当なしなら空配列", () => {
expect(filterSchedulesByQ(items, "ZZZ-NOMATCH")).toEqual([]);
});
// ★記号ゆれ(指示書AG 段階3)★ 5系統すべてに1件ずつ置いている
// `ドソ・キホーテ` は中点入りの架空名(fixtures の施設名には記号が1文字も無く
// 再現できないため合成データで組む。D-6。fixtures は変更しない)。
it("施設名は中点を打たなくても引ける/シリアルの記号は除かない", () => {
const symbolItems: Schedule[] = [
row({
id: 10,
facility_name: "ドソ・キホーテ戸塚",
serial_number: "PUDU-CC1-0001",
}),
];
expect(filterSchedulesByQ(symbolItems, "ドソキホーテ").map((s) => s.id)).toEqual(
[10]
);
// ★シリアルは記号を除かない(除くと `10001` が誤ヒットする。D-2)★
expect(filterSchedulesByQ(symbolItems, "10001")).toEqual([]);
expect(filterSchedulesByQ(symbolItems, "cc1-0001").map((s) => s.id)).toEqual([
10,
]);
});
/**
* ★記号だけの入力(2026-09-14 に反転。案A)と OR の関係★
*
* ★反転前は `-` と打つと全件が出ていた★(施設名の照合が素通ししていたため)。
* ★反転後は「施設名では当たらない・シリアルでは当たる」★になり、
* ★ハイフン入りのシリアルの行だけが出る★。★「全件」でも「0件」でもない★のが狙い。
*/
it("★`-` は施設名では当たらず、ハイフン入りシリアルの行だけが出る(OR)★", () => {
const mixed: Schedule[] = [
row({
id: 20,
facility_name: "ドソ・キホーテ戸塚",
serial_number: "PUDU-CC1-0001", // ハイフン入り
}),
row({
id: 21,
facility_name: "バリューマート西通り店",
serial_number: "8111R5628060022", // ★記号を持たない★(出てはいけない行)
}),
];
expect(filterSchedulesByQ(mixed, "-").map((s) => s.id)).toEqual([20]);
// ★中点は施設名にしかない★ — 施設名が素通ししていないので 0件
expect(filterSchedulesByQ(mixed, "・")).toEqual([]);
expect(filterSchedulesByQ(mixed, " ")).toEqual([]);
// ★空文字(未指定)は反転していない★ — 素通しで全件
expect(filterSchedulesByQ(mixed, "")).toHaveLength(2);
});
});
describe("filterSchedules(q / facility_name / serial は互いに AND)", () => {
const items: Schedule[] = [
row({
id: 1,
serial_number: "8111R5628060011",
facility_name: "メガバリューマート中央本店",
}),
row({
id: 2,
serial_number: "8111R5628060022",
facility_name: "バリューマート西通り店",
}),
row({
id: 3,
serial_number: "9222K7788990033",
facility_name: "バリューマート西通り店",
}),
];
function query(overrides: Record<string, string> = {}) {
const parsed = parseScheduleQuery(new URLSearchParams(overrides));
if (!parsed.ok) throw new Error("query");
return parsed.query;
}
// ★記号ゆれ(指示書AG 段階3)★ ★個別パラメータも q と完全に同じ照合規則である
// という約束(罠10)を、個別パラメータ側の経路でも固定する★
// q 側にだけ固定があると、個別パラメータ側だけ壊れたときに赤くならない。
// `ドソ・キホーテ` は中点入りの架空名(fixtures の施設名には記号が1文字も無く
// 再現できないため合成データで組む。D-6。fixtures は変更しない)。
it("facility_name は中点を打たなくても引ける/serial の記号は除かない", () => {
const symbolItems: Schedule[] = [
row({
id: 10,
facility_name: "ドソ・キホーテ戸塚",
serial_number: "PUDU-CC1-0001",
}),
];
expect(
filterSchedules(symbolItems, query({ facility_name: "ドソキホーテ" })).map(
(s) => s.id
)
).toEqual([10]);
// ★serial は記号を除かない(除くと `CC1` の `1` と `0001` が繋がって
// `10001` が誤ヒットする。D-2)★
expect(filterSchedules(symbolItems, query({ serial: "10001" }))).toEqual([]);
expect(
filterSchedules(symbolItems, query({ serial: "cc1-0001" })).map((s) => s.id)
).toEqual([10]);
});
/**
* ★記号だけの入力(2026-09-14 に反転。案A)★ — 個別パラメータ側でも同じ規則にする。
* ★反転前は `facility_name=-` で全件が出ていた★。
* ★serial は `includesFoldPlain` なので反転していない★(記号をそのまま探す)。
*/
it("★facility_name に記号だけを入れると 0件★/★serial は記号をそのまま探す★", () => {
const symbolItems: Schedule[] = [
row({
id: 10,
facility_name: "ドソ・キホーテ戸塚",
serial_number: "PUDU-CC1-0001",
}),
];
for (const needle of ["・", "-", "_", "ー", "--"]) {
expect(filterSchedules(symbolItems, query({ facility_name: needle }))).toEqual(
[]
);
}
// ★空文字(未指定)は反転していない★ — 素通し
expect(
filterSchedules(symbolItems, query({ facility_name: "" })).map((s) => s.id)
).toEqual([10]);
// ★serial は `includesFoldPlain` のまま★ — `-` は実際に当たる
expect(
filterSchedules(symbolItems, query({ serial: "-" })).map((s) => s.id)
).toEqual([10]);
});
it("facility_name で絞り込む", () => {
expect(
filterSchedules(items, query({ facility_name: "西通り" })).map((s) => s.id)
).toEqual([2, 3]);
});
it("serial で絞り込む(1レコード1シリアルなので横断はしない)", () => {
expect(
filterSchedules(items, query({ serial: "9222K" })).map((s) => s.id)
).toEqual([3]);
});
it("facility_name と serial は AND", () => {
expect(
filterSchedules(
items,
query({ facility_name: "西通り", serial: "8111R" })
).map((s) => s.id)
).toEqual([2]);
});
it("q は facility_name / serial と AND(q の内部だけが OR)", () => {
// q="8111R" は 1 と 2 にヒットするが、facility_name="中央" との AND で 1 のみ
expect(
filterSchedules(items, query({ q: "8111R", facility_name: "中央" })).map(
(s) => s.id
)
).toEqual([1]);
});
});
describe("sortSchedules", () => {
it("id の昇順になる", () => {
const items: Schedule[] = [row({ id: 3 }), row({ id: 1 }), row({ id: 2 })];
expect(sortSchedules(items).map((s) => s.id)).toEqual([1, 2, 3]);
});
it("元の配列を破壊しない", () => {
const items: Schedule[] = [row({ id: 3 }), row({ id: 1 })];
sortSchedules(items);
expect(items.map((s) => s.id)).toEqual([3, 1]);
});
});
describe("一覧の組み立て(ルートと同じ filter → sort → paginate)", () => {
// 表#1
it("0件なら [] / total=0", () => {
expect(list([])).toEqual({ items: [], total: 0 });
});
// 表#2
it("1件ならそのまま返る", () => {
const only = row({ id: 7 });
const result = list([only]);
expect(result.total).toBe(1);
expect(result.items).toEqual([only]);
});
// 表#7: ★一覧は有効・無効の両方を返す(画面が「有効」列で見せ分ける)★
it("active:false の行も返る(APIルート側でフィルタしない)", () => {
const items: Schedule[] = [
row({ id: 1, active: true }),
row({ id: 2, active: false }),
];
const result = list(items);
expect(result.total).toBe(2);
expect(result.items.map((s) => s.active)).toEqual([true, false]);
});
it("active:false だけでも返る(絞り込みで消えない)", () => {
const result = list([row({ id: 1, active: false })]);
expect(result.total).toBe(1);
expect(result.items.map((s) => s.id)).toEqual([1]);
});
// 表#6: 最終ページは端数、範囲外は空だが total は全件数のまま
describe("ページング境界(5件 / per_page=2)", () => {
const items: Schedule[] = [1, 2, 3, 4, 5].map((id) => row({ id }));
it("1ページ目は2件", () => {
expect(list(items, { page: "1", per_page: "2" })).toEqual({
items: [items[0], items[1]],
total: 5,
});
});
it("2ページ目は2件", () => {
expect(list(items, { page: "2", per_page: "2" }).items.map((s) => s.id)).toEqual(
[3, 4]
);
});
it("最終ページは端数の1件", () => {
expect(list(items, { page: "3", per_page: "2" }).items.map((s) => s.id)).toEqual(
[5]
);
});
it("範囲外のページは空だが total は全件数", () => {
const result = list(items, { page: "4", per_page: "2" });
expect(result.items).toEqual([]);
expect(result.total).toBe(5);
});
it("絞り込みがあると total も絞り込み後の件数になる", () => {
const mixed: Schedule[] = [
row({ id: 1, facility_name: "メガバリューマート中央本店" }),
row({ id: 2, facility_name: "バリューマート西通り店" }),
row({ id: 3, facility_name: "バリューマート西通り店" }),
];
const result = list(mixed, { q: "西通り", per_page: "2" });
expect(result.total).toBe(2);
expect(result.items.map((s) => s.id)).toEqual([2, 3]);
});
});
});
describe("paginateSchedules", () => {
const items: Schedule[] = [1, 2, 3].map((id) => row({ id }));
it("per_page が件数より大きければ全件返す", () => {
expect(paginateSchedules(items, 1, 50)).toHaveLength(3);
});
it("0件の配列でも壊れない", () => {
expect(paginateSchedules([], 3, 10)).toEqual([]);
});
});
// ★表2 R8(指示書BB U2・D-182)★ — シリアルが null(清掃エリアに有効なロボットがいない)の行は、q / serial の部分一致に当たらない
describe("filterSchedules — シリアルが null の行(指示書BB 表2 R8)", () => {
const items: Schedule[] = [
row({ id: 1, serial_number: "8111R5628060011", facility_name: "メガバリューマート中央本店" }),
{ ...row({ id: 2, serial_number: "x", facility_name: "バリューマート西通り店" }), serial_number: null },
];
it("q=abc(シリアルの部分一致)→ null の行は当たらない(施設名には当たる)", () => {
expect(filterSchedulesByQ(items, "8111").map((s) => s.id)).toEqual([1]);
expect(filterSchedulesByQ(items, "西通り").map((s) => s.id)).toEqual([2]);
});
it("serial=… → null の行は当たらない。絞り込みなしなら出る", () => {
const parsed = parseScheduleQuery(new URLSearchParams({ serial: "8111" }));
if (!parsed.ok) throw new Error("query");
expect(filterSchedules(items, parsed.query).map((s) => s.id)).toEqual([1]);
const none = parseScheduleQuery(new URLSearchParams());
if (!none.ok) throw new Error("query");
expect(filterSchedules(items, none.query).map((s) => s.id)).toEqual([1, 2]);
});
});