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