import { describe, expect, it } from "vitest";
import { CONSECUTIVE_DAYS_DISPLAY, ESCALATION_THRESHOLDS } from "@/lib/config";
import { judgeEscalationLevel } from "@/lib/judge";
import {
capFacilityPerformance,
filterContinuousProblems,
filterFacilityPerformanceByName,
filterFacilityPerformanceBySerial,
filterFacilityPerformanceByStatus,
parseReportQuery,
selectContinuousProblemRows,
selectErrorBreakdownRows,
selectFacilityPerformanceRows,
selectKpiRow,
sortContinuousProblems,
sortErrorBreakdown,
sortFacilityPerformance,
} from "@/lib/reports/query";
import type {
ContinuousProblem,
ContinuousProblemRow,
ErrorBreakdownItem,
ErrorBreakdownRow,
FacilityPerformance,
FacilityPerformanceRow,
KpiValue,
ReportKpi,
ReportKpiRow,
} from "@/lib/types";
function kpiValue(value: number | null): KpiValue {
return { value, prev: null, diff: null };
}
function performanceRow(
overrides: Partial<FacilityPerformance>
): FacilityPerformance {
return {
facility_id: 1,
facility_name: "メガバリューマート中央本店",
serial_number: "PUDU-CC1-0001",
operation_rate: kpiValue(90),
launch_rate: kpiValue(90),
completion_rate: kpiValue(90),
error_rate: kpiValue(1),
status: "good",
consecutive_problem_days: 0,
...overrides,
};
}
function problemRow(overrides: Partial<ContinuousProblem>): ContinuousProblem {
return {
facility_id: 1,
facility_name: "メガバリューマート中央本店",
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: "2026-08-27",
summary: "2日連続で完了率△",
...overrides,
};
}
function breakdownRow(
overrides: Partial<ErrorBreakdownItem>
): ErrorBreakdownItem {
return {
type: "EmergencyStop",
label: "EmergencyStop",
count: 1,
rate: 10,
...overrides,
};
}
function kpi(operationRate: number): ReportKpi {
return {
operation_rate: kpiValue(operationRate),
launch_rate: kpiValue(90),
completion_rate: kpiValue(85),
error_rate: kpiValue(5),
};
}
function kpiRow(overrides: Partial<ReportKpiRow>): ReportKpiRow {
return {
period: "weekly",
year: 2026,
month: 3,
week: 4,
kpi: kpi(91.4),
...overrides,
};
}
/** parse に通る最小のパラメータ(必須のみ) */
function weeklyParams(extra: Record<string, string> = {}): URLSearchParams {
return new URLSearchParams({
period: "weekly",
year: "2026",
month: "3",
week: "4",
...extra,
});
}
describe("parseReportQuery", () => {
it("必須パラメータが揃っていればパースできる", () => {
const parsed = parseReportQuery(weeklyParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query).toEqual({
period: "weekly",
year: 2026,
month: 3,
week: 4,
facilityName: "",
serial: "",
status: "all",
// ★絞り込まない(指示書AK 段階2 で足った2つ。`null` = 条件なし)★
groupId: null,
customerId: null,
limit: 10,
});
});
// ★必須パラメータ欠落4種。details に項目名が載ることまで固定する★
it("period欠落なら422(detailsに選択肢)", () => {
const parsed = parseReportQuery(
new URLSearchParams({ year: "2026", month: "3", week: "4" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.code).toBe("validation_failed");
expect(parsed.error.error.message).toBe("periodの値が不正です");
expect(parsed.error.error.details).toEqual({
// ★yearly が選択肢に入った(2026-09-30 ユーザー指示。指示書AQ 決定1)★
period: ["weekly / monthly / yearly のいずれかを指定してください"],
});
});
it("year欠落なら422", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "weekly", month: "3", week: "4" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("yearの値が不正です");
expect(parsed.error.error.details).toEqual({
year: ["西暦を整数で指定してください"],
});
});
it("month欠落なら422", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "weekly", year: "2026", week: "4" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("monthの値が不正です");
expect(parsed.error.error.details).toEqual({
month: ["1〜12で指定してください"],
});
});
it("period=weeklyでweek欠落なら422", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "weekly", year: "2026", month: "3" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("weekの値が不正です");
expect(parsed.error.error.details).toEqual({
week: ["period=weekly のときは1〜6で指定してください"],
});
});
// ★このテストは以前「yearlyは範囲外」を固定していた★(2026-09-30 に反転。指示書AQ 決定1)。
// yearly は enum に入ったので、enum 外の例を `daily` に差し替えてある。
// ★yearly が通ること自体は下の describe("period=yearly …") で固定する★
it("periodがenum外なら422(daily は受け付けない)", () => {
for (const period of ["daily", "annual", "WEEKLY", ""]) {
const parsed = parseReportQuery(
new URLSearchParams({ period, year: "2026", month: "3" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("periodの値が不正です");
}
});
it("yearが数値でなければ422", () => {
const parsed = parseReportQuery(weeklyParams({ year: "2026" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("yearの値が不正です");
});
// ------------------------------------------------------------------ year の範囲
// ★期待値は `d-watch-api.yaml` の `Year`(minimum: 2000 / maximum: 2100)=
// `lib/config` の `REPORT_YEAR` を正とし、★数値を直書きして固定する★
// (範囲を動かしたらここが落ちて、YAML と揃えに来ることを強制する)。
// 範囲が無かった頃の実測(2026-09-14 / develop 3e48d30):
// year=-1(monthly)→ 200(空のKPI)/ year=-1・0・1000000(weekly)→ 500(RangeError)
// year=99999999999999999999 → 500(Prisma の 64bit 超過。値がサーバログに載る)
it("yearは2000〜2100の境界ちょうどなら通る(下限・上限)", () => {
for (const year of ["2000", "2100"]) {
const parsed = parseReportQuery(weeklyParams({ year }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.year).toBe(Number(year));
}
});
it("yearが2000〜2100の外なら422(下限−1 / 上限+1)", () => {
for (const year of ["1999", "2101"]) {
const parsed = parseReportQuery(weeklyParams({ year }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("yearの値が不正です");
expect(parsed.error.error.details).toEqual({
year: ["2000〜2100で指定してください"],
});
}
});
// ★負の年・0・桁あふれ★ — 整数ではあるので `toIntStrict()` は通ってしまう。
// ★範囲チェックが最後の関門★(`year=99999999999999999999` は `parseInt` で 1e20 になる)
it("year=-1 / 0 / 1000000 / 99999999999999999999 は422(500や200にしない)", () => {
for (const year of ["-1", "0", "1000000", "99999999999999999999"]) {
const parsed = parseReportQuery(weeklyParams({ year }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
year: ["2000〜2100で指定してください"],
});
}
});
// ★monthly でも同じ★(実測で200が返っていたのは monthly の経路)
it("period=monthlyでもyearの範囲外は422", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "monthly", year: "-1", month: "9" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("yearの値が不正です");
});
it("monthが1〜12の外なら422", () => {
for (const month of ["0", "13"]) {
const parsed = parseReportQuery(weeklyParams({ month }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("monthの値が不正です");
}
});
// ★D-071 の下では第6週が発生する(1日が金曜/土曜の30日・31日月)。
// `d-watch-api.yaml` の Week は maximum: 6★
it("week=6は通る(上限。★D-071 では第6週が存在する★)", () => {
const parsed = parseReportQuery(weeklyParams({ week: "6" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.week).toBe(6);
});
it("week=7は422で弾かれる(第7週は発生しない)", () => {
const parsed = parseReportQuery(weeklyParams({ week: "7" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
week: ["period=weekly のときは1〜6で指定してください"],
});
});
// ★`week=0` は先方のサマリで「月の集計値」を表す予約値。クエリで受け付けてはいけない★
// 内部表現が 0(monthly)になっても `WEEK_MIN = 1` は下げない(D-072 / 指示書H 段階5)
it("week=0は422(下限。★内部表現が0になっても API では通さない★)", () => {
const parsed = parseReportQuery(weeklyParams({ week: "0" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
week: ["period=weekly のときは1〜6で指定してください"],
});
});
// ★monthly の week は読み捨てて 0 にする(D-072。旧実装は null)★
it("period=monthlyならweekは無視され、内部表現は0になる(付いていても422にしない)", () => {
const parsed = parseReportQuery(
new URLSearchParams({
period: "monthly",
year: "2026",
month: "3",
week: "9",
})
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.week).toBe(0);
});
it("period=monthlyはweek欠落でもエラーにならない(week は0)", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "monthly", year: "2026", month: "3" })
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.week).toBe(0);
});
it("statusが不正な値なら422", () => {
const parsed = parseReportQuery(weeklyParams({ status: "bogus" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("statusの値が不正です");
expect(parsed.error.error.details).toEqual({
// no_data(判定なし)は決定1(2026-09-11 ユーザー確定)で追加した4値目
status: ["good / warning / bad / no_data / all のいずれかを指定してください"],
});
});
it("limitの既定値は10、上限はPAGINATION.maxPerPage、下限は1", () => {
const defaulted = parseReportQuery(weeklyParams());
expect(defaulted.ok).toBe(true);
if (!defaulted.ok) throw new Error("query");
expect(defaulted.query.limit).toBe(10);
const capped = parseReportQuery(weeklyParams({ limit: "9999" }));
expect(capped.ok).toBe(true);
if (!capped.ok) throw new Error("query");
expect(capped.query.limit).toBe(200);
const floored = parseReportQuery(weeklyParams({ limit: "0" }));
expect(floored.ok).toBe(true);
if (!floored.ok) throw new Error("query");
expect(floored.query.limit).toBe(1);
// 数値でない limit は失敗させず既定値へ(/clean-logs の per_page と同じ挙動)
const fallback = parseReportQuery(weeklyParams({ limit: "bogus" }));
expect(fallback.ok).toBe(true);
if (!fallback.ok) throw new Error("query");
expect(fallback.query.limit).toBe(10);
});
it("facility_name / serial はtrimして取り込む", () => {
const parsed = parseReportQuery(
weeklyParams({ facility_name: " 中央本店 ", serial: " PUDU-001 " })
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.facilityName).toBe("中央本店");
expect(parsed.query.serial).toBe("PUDU-001");
});
/**
* ★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 = parseReportQuery(weeklyParams({ 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 = parseReportQuery(weeklyParams());
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 = parseReportQuery(weeklyParams({ 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 = parseReportQuery(weeklyParams({ 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以上の整数で指定してください"],
});
});
});
/**
* ★period=yearly の期待値表★(2026-09-30 ユーザー指示。指示書AQ 決定1 / 落とし穴6)
*
* 年次のサマリは ★`month=0 AND week=0`★ で表される(CLAUDE.md §4.7)。
* API の内部表現もそれに揃え、★`month` の正解を period で切り替える★。
* ★`MONTH_MIN = 1` は下げていない★ — 下げると monthly/weekly の `month=0` まで通る。
*
* | # | period | year | month | week | 期待 |
* |------|---------|------|--------|------|-----------------------------------------|
* | Y-1 | yearly | 2026 | 0 | 無し | ok。`{month:0, week:0}` |
* | Y-2 | yearly | 2026 | 0 | 3 | ok。★week は読み捨てて 0★ |
* | Y-3 | yearly | 2026 | 9 | 無し | ★422★(year以外が0でない) |
* | Y-4 | yearly | 2026 | 1 | 無し | ★422★(境界。MONTH_MIN ちょうどでも不可)|
* | Y-5 | yearly | 2026 | 12 | 無し | ★422★(境界。MONTH_MAX ちょうどでも不可)|
* | Y-6 | yearly | 2026 | 欠落 | 無し | ★422★(month は required) |
* | Y-7 | yearly | 2026 | -1 | 無し | ★422★ |
* | Y-8 | monthly | 2026 | 0 | 無し | ★422★(従来どおり。緩めていない) |
* | Y-9 | weekly | 2026 | 0 | 4 | ★422★(従来どおり) |
* | Y-10 | yearly | 1999 | 0 | 無し | ★422(year)★ — 年の範囲は period 非依存 |
* | Y-11 | yearly | 2026 | 0 | 無し | status / limit などは週次・月次と同じ扱い |
*/
describe("parseReportQuery(period=yearly)", () => {
function yearlyParams(extra: Record<string, string> = {}): URLSearchParams {
return new URLSearchParams({
period: "yearly",
year: "2026",
month: "0",
...extra,
});
}
it("Y-1 period=yearly&year=2026&month=0 は通る(week は 0)", () => {
const parsed = parseReportQuery(yearlyParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query).toEqual({
period: "yearly",
year: 2026,
month: 0,
week: 0,
facilityName: "",
serial: "",
status: "all",
groupId: null,
customerId: null,
limit: 10,
});
});
it("Y-2 yearly の week は付いていても読み捨てて 0(422にしない)", () => {
const parsed = parseReportQuery(yearlyParams({ week: "3" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.week).toBe(0);
});
// ★Y-3〜Y-5: 「yearly なら month を何でも通す」にしないこと★ —
// 通すと年次の要求が月次の期間キーを持ったままデータ層へ流れる(fail-silent)
it("Y-3/Y-4/Y-5 yearly で month が 0 以外なら422(1 と 12 の境界も不可)", () => {
for (const month of ["9", "1", "12"]) {
const parsed = parseReportQuery(yearlyParams({ month }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.code).toBe("validation_failed");
expect(parsed.error.error.message).toBe("monthの値が不正です");
expect(parsed.error.error.details).toEqual({
month: ["period=yearly のときは0を指定してください"],
});
}
});
it("Y-6 yearly でも month 欠落は422(required)", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "yearly", year: "2026" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
month: ["period=yearly のときは0を指定してください"],
});
});
it("Y-7 yearly で month=-1 は422", () => {
const parsed = parseReportQuery(yearlyParams({ month: "-1" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
});
// ★Y-8 / Y-9: `MONTH_MIN` を 0 に下げていないことの裏取り★
it("Y-8 period=monthly で month=0 は422(従来どおり。緩めていない)", () => {
const parsed = parseReportQuery(
new URLSearchParams({ period: "monthly", year: "2026", month: "0" })
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("monthの値が不正です");
expect(parsed.error.error.details).toEqual({
month: ["1〜12で指定してください"],
});
});
it("Y-9 period=weekly で month=0 は422(従来どおり)", () => {
const parsed = parseReportQuery(weeklyParams({ month: "0" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
month: ["1〜12で指定してください"],
});
});
it("Y-10 yearly でも year の範囲外は422(年の範囲は period に依存しない)", () => {
for (const year of ["1999", "2101"]) {
const parsed = parseReportQuery(yearlyParams({ year }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected failure");
expect(parsed.status).toBe(422);
expect(parsed.error.error.message).toBe("yearの値が不正です");
}
});
it("Y-11 status / limit / 施設フィルタは週次・月次と同じ扱い", () => {
const parsed = parseReportQuery(
yearlyParams({
status: "bad",
limit: "5",
facility_name: " バリューマート ",
group_id: "2",
})
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.status).toBe("bad");
expect(parsed.query.limit).toBe(5);
expect(parsed.query.facilityName).toBe("バリューマート");
expect(parsed.query.groupId).toBe(2);
});
});
describe("filterFacilityPerformanceByStatus", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 1, status: "good" }),
performanceRow({ facility_id: 2, status: "warning" }),
performanceRow({ facility_id: 3, status: "bad" }),
];
it("status=all は全件保持する", () => {
expect(filterFacilityPerformanceByStatus(items, "all")).toHaveLength(3);
});
it("status=bad で絞り込む", () => {
expect(
filterFacilityPerformanceByStatus(items, "bad").map((i) => i.facility_id)
).toEqual([3]);
});
it("status=warning で絞り込む", () => {
expect(
filterFacilityPerformanceByStatus(items, "warning").map(
(i) => i.facility_id
)
).toEqual([2]);
});
});
describe("filterFacilityPerformanceByName", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 1, facility_name: "バリューマート南丘駅前店" }),
performanceRow({ facility_id: 2, facility_name: "メガバリューマート中央本店" }),
];
it("部分一致で絞り込む", () => {
expect(
filterFacilityPerformanceByName(items, "南丘").map((i) => i.facility_id)
).toEqual([1]);
});
it("空文字なら素通し", () => {
expect(filterFacilityPerformanceByName(items, "")).toHaveLength(2);
});
it("一致しなければ0件", () => {
expect(filterFacilityPerformanceByName(items, "存在しない店")).toHaveLength(
0
);
});
// ★記号ゆれ(指示書AG 段階3)★ 5系統すべてに1件ずつ置いている
// `ドソ・キホーテ` は中点入りの架空名(`lib/fixtures/` の施設名には記号が
// 1文字も無く再現できないため、合成データで組む。D-6。fixtures は変更しない)。
it("施設名は中点を打たなくても引ける(大文字小文字も区別しない)", () => {
const symbolItems: FacilityPerformance[] = [
performanceRow({ facility_id: 10, facility_name: "ドソ・キホーテ戸塚" }),
performanceRow({ facility_id: 11, facility_name: "MEGAドソ・キホーテ 港南" }),
];
expect(
filterFacilityPerformanceByName(symbolItems, "ドソキホーテ").map(
(i) => i.facility_id
)
).toEqual([10, 11]);
expect(
filterFacilityPerformanceByName(symbolItems, "megaドソキホーテ").map(
(i) => i.facility_id
)
).toEqual([11]);
});
/**
* ★記号だけの入力(2026-09-14 に反転。案A)★
*
* ★反転前は `・` や `-` と打つと全件が出ていた★
* (`foldName("・") === ""` → `includes("")` が常に true だったため)。
* ★空文字(未指定)は反転していない★ — 素通しのまま。
*
* ★空白だけの入力はここに届かない★ — `filterFacilityPerformanceByName` 自身が
* ★引数を `.trim()` してから空判定している★(`parseReportQuery` 側でも trim 済み)ので、
* ★半角・全角スペースとタブは空文字=未指定として素通しする★。
*/
it("★記号だけの入力は 0件★/★空白だけは trim で未指定になり素通し★(2026-09-14 に反転)", () => {
const symbolItems: FacilityPerformance[] = [
performanceRow({ facility_id: 10, facility_name: "ドソ・キホーテ戸塚" }),
performanceRow({ facility_id: 11, facility_name: "MEGAドソ・キホーテ 港南" }),
];
for (const needle of ["・", "-", "_", "ー", "・ー", "--", "・ -"]) {
expect(filterFacilityPerformanceByName(symbolItems, needle)).toHaveLength(0);
}
// ★空白だけは `.trim()` で空文字になり、未指定として素通しする★
for (const raw of [" ", " ", "\t", " \t "]) {
expect(filterFacilityPerformanceByName(symbolItems, raw)).toHaveLength(2);
}
// ★空文字(未指定)は反転していない★
expect(filterFacilityPerformanceByName(symbolItems, "")).toHaveLength(2);
// ★記号を含むが記号だけではない入力は従来どおり当たる★
expect(
filterFacilityPerformanceByName(symbolItems, "ドソ・").map((i) => i.facility_id)
).toEqual([10, 11]);
});
});
describe("filterFacilityPerformanceBySerial", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 1, serial_number: "PUDU-CC1-0001" }),
performanceRow({ facility_id: 2, serial_number: "PUDU-SH1-0002" }),
];
it("部分一致で絞り込む", () => {
expect(
filterFacilityPerformanceBySerial(items, "SH1").map((i) => i.facility_id)
).toEqual([2]);
});
it("空文字なら素通し", () => {
expect(filterFacilityPerformanceBySerial(items, "")).toHaveLength(2);
});
// ★シリアルは大文字小文字だけを無視する(記号は無視しない)★指示書AG D-2
it("小文字でも引けるが、ハイフンを省くと引けない", () => {
expect(
filterFacilityPerformanceBySerial(items, "sh1").map((i) => i.facility_id)
).toEqual([2]);
// 記号を除く実装にすると `PUDUCC10001` になり `10001` が誤ヒットする。そうしていない
expect(filterFacilityPerformanceBySerial(items, "10001")).toHaveLength(0);
expect(filterFacilityPerformanceBySerial(items, "puducc1")).toHaveLength(0);
});
/**
* ★シリアル側は 2026-09-14 の反転の対象外★(★揃えないのが正しい★)。
* `includesFoldPlain` は記号を残すので、★`-` は実際にシリアルに当たる意味のある検索★。
* ★ここに「記号だけなら 0件」を足さないこと★ — ハイフンでシリアルを探せなくなる。
*/
it("★`-` だけの入力はシリアルに当たる★(`includesFoldPlain` は変えていない)", () => {
const mixed: FacilityPerformance[] = [
performanceRow({ facility_id: 1, serial_number: "PUDU-CC1-0001" }),
performanceRow({ facility_id: 2, serial_number: "8111R5628060011" }),
];
expect(
filterFacilityPerformanceBySerial(mixed, "-").map((i) => i.facility_id)
).toEqual([1]);
// 空白だけは `.trim()` で未指定になり素通し
expect(filterFacilityPerformanceBySerial(mixed, " ")).toHaveLength(2);
});
});
describe("sortFacilityPerformance", () => {
it("稼働率の昇順(ワースト順)になる", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 1, operation_rate: kpiValue(91.4) }),
performanceRow({ facility_id: 2, operation_rate: kpiValue(68) }),
performanceRow({ facility_id: 3, operation_rate: kpiValue(80.2) }),
];
expect(sortFacilityPerformance(items).map((i) => i.facility_id)).toEqual([
2, 3, 1,
]);
});
it("稼働率が同値なら facility_id の昇順で安定させる(D-052と同じ規則)", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 3, operation_rate: kpiValue(80) }),
performanceRow({ facility_id: 1, operation_rate: kpiValue(80) }),
performanceRow({ facility_id: 2, operation_rate: kpiValue(80) }),
];
expect(sortFacilityPerformance(items).map((i) => i.facility_id)).toEqual([
1, 2, 3,
]);
});
// ★null(分母0 = 清掃予定が1件もない期間)は「稼働率0%」ではないので末尾★
it("operation_rate.value が null の行は末尾に来る", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 1, operation_rate: kpiValue(null) }),
performanceRow({ facility_id: 2, operation_rate: kpiValue(0) }),
performanceRow({ facility_id: 3, operation_rate: kpiValue(95.5) }),
];
expect(sortFacilityPerformance(items).map((i) => i.facility_id)).toEqual([
2, 3, 1,
]);
});
it("nullが複数あっても facility_id の昇順で安定させる", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 5, operation_rate: kpiValue(null) }),
performanceRow({ facility_id: 2, operation_rate: kpiValue(null) }),
performanceRow({ facility_id: 3, operation_rate: kpiValue(70) }),
];
expect(sortFacilityPerformance(items).map((i) => i.facility_id)).toEqual([
3, 2, 5,
]);
});
it("元の配列を破壊しない", () => {
const items: FacilityPerformance[] = [
performanceRow({ facility_id: 1, operation_rate: kpiValue(90) }),
performanceRow({ facility_id: 2, operation_rate: kpiValue(10) }),
];
sortFacilityPerformance(items);
expect(items.map((i) => i.facility_id)).toEqual([1, 2]);
});
});
describe("filterContinuousProblems", () => {
it("1日(L1・初回)の行は落とし、2日以上だけ通す", () => {
const items: ContinuousProblem[] = [
problemRow({
facility_id: 1,
consecutive_problem_days: 1,
escalation_level: "L1",
}),
problemRow({
facility_id: 2,
consecutive_problem_days: 2,
escalation_level: "L2",
}),
problemRow({
facility_id: 3,
consecutive_problem_days: 5,
escalation_level: "L3",
}),
];
expect(filterContinuousProblems(items).map((i) => i.facility_id)).toEqual([
2, 3,
]);
});
it("0日も落とす", () => {
const items: ContinuousProblem[] = [
problemRow({ facility_id: 1, consecutive_problem_days: 0 }),
];
expect(filterContinuousProblems(items)).toHaveLength(0);
});
});
describe("sortContinuousProblems", () => {
it("連続日数の降順になる", () => {
const items: ContinuousProblem[] = [
problemRow({ facility_id: 1, consecutive_problem_days: 2 }),
problemRow({ facility_id: 2, consecutive_problem_days: 7 }),
problemRow({ facility_id: 3, consecutive_problem_days: 3 }),
];
expect(sortContinuousProblems(items).map((i) => i.facility_id)).toEqual([
2, 3, 1,
]);
});
it("同値なら facility_id の昇順で安定させる(D-052と同じ規則)", () => {
const items: ContinuousProblem[] = [
problemRow({ facility_id: 3, consecutive_problem_days: 3 }),
problemRow({ facility_id: 1, consecutive_problem_days: 3 }),
problemRow({ facility_id: 2, consecutive_problem_days: 3 }),
];
expect(sortContinuousProblems(items).map((i) => i.facility_id)).toEqual([
1, 2, 3,
]);
});
});
describe("sortErrorBreakdown", () => {
it("件数の降順になる", () => {
const items: ErrorBreakdownItem[] = [
breakdownRow({ type: "Offline", count: 3 }),
breakdownRow({ type: "EmergencyStop", count: 12 }),
breakdownRow({ type: "BrushError", count: 5 }),
];
expect(sortErrorBreakdown(items).map((i) => i.type)).toEqual([
"EmergencyStop",
"BrushError",
"Offline",
]);
});
it("件数が同値なら type(生値)の文字列昇順で安定させる", () => {
const items: ErrorBreakdownItem[] = [
breakdownRow({ type: "Zebra", count: 4 }),
breakdownRow({ type: "Apple", count: 4 }),
breakdownRow({ type: "Mango", count: 4 }),
];
expect(sortErrorBreakdown(items).map((i) => i.type)).toEqual([
"Apple",
"Mango",
"Zebra",
]);
});
it("件数降順が文字列順より優先される", () => {
const items: ErrorBreakdownItem[] = [
breakdownRow({ type: "Apple", count: 1 }),
breakdownRow({ type: "Zebra", count: 9 }),
];
expect(sortErrorBreakdown(items).map((i) => i.type)).toEqual([
"Zebra",
"Apple",
]);
});
it("実在する値でも同じ規則で同値時タイブレークする", () => {
const items: ErrorBreakdownItem[] = [
breakdownRow({ type: "EmergencyStop", count: 4 }),
breakdownRow({ type: "CleanWaterEmpty", count: 4 }),
];
expect(sortErrorBreakdown(items).map((i) => i.type)).toEqual([
"CleanWaterEmpty",
"EmergencyStop",
]);
});
it("★localeCompareと結果が変わる箇所。大文字小文字混在はASCII順で大文字が先になる★", () => {
const items: ErrorBreakdownItem[] = [
breakdownRow({ type: "apple", count: 4 }),
breakdownRow({ type: "Apple", count: 4 }),
];
expect(sortErrorBreakdown(items).map((i) => i.type)).toEqual([
"Apple",
"apple",
]);
});
it("空文字が来ても落ちない", () => {
const items: ErrorBreakdownItem[] = [
breakdownRow({ type: "", count: 4 }),
breakdownRow({ type: "Apple", count: 4 }),
];
expect(sortErrorBreakdown(items).map((i) => i.type)).toEqual([
"",
"Apple",
]);
});
it("0件でも壊れない", () => {
expect(sortErrorBreakdown([])).toEqual([]);
});
});
describe("selectKpiRow", () => {
const rows: ReportKpiRow[] = [
kpiRow({ period: "weekly", year: 2026, month: 3, week: 4, kpi: kpi(91.4) }),
kpiRow({ period: "weekly", year: 2026, month: 3, week: 5, kpi: kpi(88.1) }),
// ★月次の行は week=0(D-072。サマリ4テーブルの「月の集計値」と同じ表し方)★
kpiRow({
period: "monthly",
year: 2026,
month: 3,
week: 0,
kpi: kpi(89.7),
}),
];
function query(overrides: Partial<ReturnType<typeof baseQuery>>) {
return { ...baseQuery(), ...overrides };
}
function baseQuery() {
const parsed = parseReportQuery(weeklyParams());
if (!parsed.ok) throw new Error("query");
return parsed.query;
}
it("period/year/month/week が一致する行のKPIを返す", () => {
expect(selectKpiRow(rows, query({}))?.operation_rate.value).toBe(91.4);
expect(
selectKpiRow(rows, query({ week: 5 }))?.operation_rate.value
).toBe(88.1);
});
it("monthly は week=0 の行を返す(月次の期間キー。D-072)", () => {
const monthly = query({ period: "monthly", week: 0 });
expect(selectKpiRow(rows, monthly)?.operation_rate.value).toBe(89.7);
});
/**
* ★`matchesPeriod` は period による場合分けを持たない(段階5で単純比較に簡略化)★
*
* 旧実装は「monthly のときは week を null に読み替えて比較する」分岐を持っていた。
* monthly の内部表現が 0 に揃った(D-072)ので、4項目の単純比較で足りる。
* その裏取りとして、monthly の要求に週次の週番号を混ぜても行が当たらないことを固定する。
*/
it("monthly の要求に週番号が混ざっていても、week=0 の行は当たらない(単純比較)", () => {
expect(selectKpiRow(rows, query({ period: "monthly", week: 4 }))).toBeNull();
});
it("該当行が無ければ null(404にはしない)", () => {
expect(selectKpiRow(rows, query({ year: 2025 }))).toBeNull();
expect(selectKpiRow(rows, query({ month: 4 }))).toBeNull();
expect(selectKpiRow(rows, query({ week: 1 }))).toBeNull();
expect(selectKpiRow([], query({}))).toBeNull();
});
it("weekly の要求に monthly の行を当てない(periodも一致条件)", () => {
const onlyMonthly: ReportKpiRow[] = [
kpiRow({ period: "monthly", year: 2026, month: 3, week: 0 }),
];
expect(selectKpiRow(onlyMonthly, query({}))).toBeNull();
});
// ------------------------------------------------------------------ 年次
// ★年次の期間キーは `month=0 AND week=0`(CLAUDE.md §4.7)★。
// `matchesPeriod()` は4項目の単純比較のままで、年次も当たる。
describe("年次(period=yearly)", () => {
const yearlyRows: ReportKpiRow[] = [
kpiRow({ period: "yearly", year: 2026, month: 0, week: 0, kpi: kpi(87.3) }),
kpiRow({ period: "yearly", year: 2025, month: 0, week: 0, kpi: kpi(75.2) }),
kpiRow({
period: "monthly",
year: 2026,
month: 3,
week: 0,
kpi: kpi(89.7),
}),
];
function yearlyQuery(overrides: Partial<ReturnType<typeof baseQuery>> = {}) {
const parsed = parseReportQuery(
new URLSearchParams({ period: "yearly", year: "2026", month: "0" })
);
if (!parsed.ok) throw new Error("query");
return { ...parsed.query, ...overrides };
}
it("year が一致する年次行のKPIを返す", () => {
expect(selectKpiRow(yearlyRows, yearlyQuery())?.operation_rate.value).toBe(
87.3
);
expect(
selectKpiRow(yearlyRows, yearlyQuery({ year: 2025 }))?.operation_rate
.value
).toBe(75.2);
});
it("★年次の要求に月次の行を当てない★(指示書AQ 落とし穴2 の逆側)", () => {
const onlyMonthly: ReportKpiRow[] = [
kpiRow({ period: "monthly", year: 2026, month: 0, week: 0 }),
];
expect(selectKpiRow(onlyMonthly, yearlyQuery())).toBeNull();
});
it("月次の要求に年次の行を当てない(month も一致条件)", () => {
const onlyYearly: ReportKpiRow[] = [
kpiRow({ period: "yearly", year: 2026, month: 0, week: 0 }),
];
const monthly = query({ period: "monthly", month: 3, week: 0 });
expect(selectKpiRow(onlyYearly, monthly)).toBeNull();
});
it("該当年が無ければ null(404にはしない)", () => {
expect(selectKpiRow(yearlyRows, yearlyQuery({ year: 2024 }))).toBeNull();
expect(selectKpiRow([], yearlyQuery())).toBeNull();
});
});
});
describe("selectFacilityPerformanceRows / selectContinuousProblemRows / selectErrorBreakdownRows", () => {
function query(overrides: Partial<ReturnType<typeof parseWeekly>>) {
return { ...parseWeekly(), ...overrides };
}
function parseWeekly() {
const parsed = parseReportQuery(weeklyParams());
if (!parsed.ok) throw new Error("query");
return parsed.query;
}
it("facility-performance: 一致する期間の item だけを取り出す", () => {
const rows: FacilityPerformanceRow[] = [
{
period: "weekly",
year: 2026,
month: 3,
week: 4,
item: performanceRow({ facility_id: 1 }),
},
{
period: "weekly",
year: 2026,
month: 3,
week: 5,
item: performanceRow({ facility_id: 2 }),
},
{
period: "monthly",
year: 2026,
month: 3,
week: 0,
item: performanceRow({ facility_id: 3 }),
},
];
const items = selectFacilityPerformanceRows(rows, query({}));
expect(items.map((i) => i.facility_id)).toEqual([1]);
});
it("continuous-problems: 該当が無ければ空配列(nullにしない)", () => {
const rows: ContinuousProblemRow[] = [
{
period: "weekly",
year: 2026,
month: 3,
week: 5,
item: problemRow({ facility_id: 1 }),
},
];
expect(selectContinuousProblemRows(rows, query({}))).toEqual([]);
});
it("error-breakdown: monthly は week=0 の行だけ取り出す(D-072)", () => {
const rows: ErrorBreakdownRow[] = [
{
period: "monthly",
year: 2026,
month: 3,
week: 0,
item: breakdownRow({ type: "EmergencyStop" }),
},
{
period: "weekly",
year: 2026,
month: 3,
week: 4,
item: breakdownRow({ type: "LowBatteryLevel" }),
},
];
const monthlyQuery = query({ period: "monthly", week: 0 });
const items = selectErrorBreakdownRows(rows, monthlyQuery);
expect(items.map((i) => i.type)).toEqual(["EmergencyStop"]);
});
});
// ★指示書AF 段階6 — /reports/facility-performance にも同じ表示上限を効かせる★
describe("capFacilityPerformance", () => {
it("#1 上限ちょうど(30 / 上限30)はそのまま。capped:false", () => {
const [item] = capFacilityPerformance(
[performanceRow({ consecutive_problem_days: 30 })],
30
);
expect(item.consecutive_problem_days).toBe(30);
expect(item.consecutive_problem_days_capped).toBe(false);
});
it("#2 上限+1(31 / 上限30)は 30 / capped:true", () => {
const [item] = capFacilityPerformance(
[performanceRow({ consecutive_problem_days: 31 })],
30
);
expect(item.consecutive_problem_days).toBe(30);
expect(item.consecutive_problem_days_capped).toBe(true);
});
it("#3 ★status は要改善のまま★(判定は生値。指示書AF 禁止事項9)", () => {
const [item] = capFacilityPerformance(
[performanceRow({ consecutive_problem_days: 45, status: "bad" })],
ESCALATION_THRESHOLDS.level3Days
);
expect(item.consecutive_problem_days).toBe(ESCALATION_THRESHOLDS.level3Days);
expect(item.consecutive_problem_days_capped).toBe(true);
expect(item.status).toBe("bad");
// 生値で判定した L3 は変わらない
expect(judgeEscalationLevel(45)).toBe("L3");
});
it("#4 空配列は空配列(例外にしない)", () => {
expect(capFacilityPerformance([], 30)).toEqual([]);
});
it("#5 連続0日は 0 / capped:false", () => {
const [item] = capFacilityPerformance(
[performanceRow({ consecutive_problem_days: 0 })],
30
);
expect(item.consecutive_problem_days).toBe(0);
expect(item.consecutive_problem_days_capped).toBe(false);
});
it("★並べ替え(稼働率ワースト順)の後にかけても順序が変わらない★", () => {
const sorted = sortFacilityPerformance([
performanceRow({ facility_id: 1, operation_rate: kpiValue(20), consecutive_problem_days: 45 }),
performanceRow({ facility_id: 2, operation_rate: kpiValue(10), consecutive_problem_days: 31 }),
performanceRow({ facility_id: 3, operation_rate: kpiValue(95), consecutive_problem_days: 2 }),
]);
const capped = capFacilityPerformance(sorted, 30);
expect(capped.map((i) => i.facility_id)).toEqual([2, 1, 3]);
expect(capped.map((i) => i.consecutive_problem_days)).toEqual([30, 30, 2]);
expect(capped.map((i) => i.consecutive_problem_days_capped)).toEqual([
true,
true,
false,
]);
});
it("連続日数以外のフィールドは書き換えず、入力も書き換えない(純粋関数)", () => {
const before = performanceRow({ consecutive_problem_days: 31, status: "warning" });
const [item] = capFacilityPerformance([before], 30);
expect(item.facility_name).toBe(before.facility_name);
expect(item.serial_number).toBe(before.serial_number);
expect(item.operation_rate).toEqual(before.operation_rate);
expect(item.status).toBe("warning");
expect(before.consecutive_problem_days).toBe(31);
expect(before.consecutive_problem_days_capped).toBeUndefined();
});
it("★絞り込み(2日以上)は生値で行われる★ — 上限を先にかけても件数が変わらないこと", () => {
// filterContinuousProblems は ESCALATION_THRESHOLDS.level2Days で絞る。
// 上限は必ずその閾値以上(CONSECUTIVE_DAYS_DISPLAY.minMax = level3Days >= level2Days)
// なので、頭打ちにしても絞り込みの結果は変わらない。その関係をテストで固定する。
const raw = [
problemRow({ facility_id: 1, consecutive_problem_days: 45 }),
problemRow({ facility_id: 2, consecutive_problem_days: 1 }),
];
expect(filterContinuousProblems(raw).map((i) => i.facility_id)).toEqual([1]);
expect(CONSECUTIVE_DAYS_DISPLAY.minMax).toBeGreaterThanOrEqual(
ESCALATION_THRESHOLDS.level2Days
);
});
});