import { describe, expect, it } from "vitest";
import {
buildFacilityPerformanceCsv,
facilityPerformanceCsvFilename,
facilityPerformanceCsvHeader,
} from "@/lib/reports/facility-performance-csv";
import type { FacilityPerformance, FacilityStatus, KpiValue } from "@/lib/types";
/**
* 施設別パフォーマンス CSV(D-163)。期待値は監督確定の表 C1〜C13 とファイル名(仕様 §8)。
* ★期待値を変えないこと★。
*/
function kpi(value: number | null, prev: number | null = null, diff: number | null = null): KpiValue {
return { value, prev, diff };
}
function item(overrides: Partial<FacilityPerformance> = {}): FacilityPerformance {
return {
facility_id: 1,
facility_name: "メガバリューマート中央本店",
serial_number: "PUDU-CC1-0001",
operation_rate: kpi(95, 93, 2),
launch_rate: kpi(96),
completion_rate: kpi(92, 91, 1),
error_rate: kpi(3),
status: "good",
consecutive_problem_days: 0,
...overrides,
};
}
/** BOM を外して CRLF で行に分け、末尾の空要素(最後の CRLF の後ろ)を落とす */
function lines(csv: string): string[] {
const body = csv.slice(1).split("\r\n");
expect(body.at(-1)).toBe("");
return body.slice(0, -1);
}
/** 1行をセルに分ける(テストの入力に `,` を含むのは C11 だけなので、それ以外で使う) */
function cells(line: string): string[] {
return line.split(",");
}
const COL = {
name: 0,
serial: 1,
op: 2,
opPrev: 3,
launch: 4,
completion: 5,
completionPrev: 6,
error: 7,
diff: 8,
status: 9,
} as const;
describe("ヘッダ(C1 / C2)", () => {
it("C1 monthly", () => {
expect(facilityPerformanceCsvHeader("monthly").join(",")).toBe(
"施設名,シリアル,稼働率,稼働率(前月),出発成功率,完了率,完了率(前月),エラー率,変化,ステータス"
);
expect(lines(buildFacilityPerformanceCsv([], "monthly"))[0]).toBe(
"施設名,シリアル,稼働率,稼働率(前月),出発成功率,完了率,完了率(前月),エラー率,変化,ステータス"
);
});
it("C2 weekly は(前週)", () => {
const h = facilityPerformanceCsvHeader("weekly");
expect(h[COL.opPrev]).toBe("稼働率(前週)");
expect(h[COL.completionPrev]).toBe("完了率(前週)");
});
it("C2 yearly は(前年)", () => {
const h = facilityPerformanceCsvHeader("yearly");
expect(h[COL.opPrev]).toBe("稼働率(前年)");
expect(h[COL.completionPrev]).toBe("完了率(前年)");
});
});
describe("率の表示(C3〜C5)— 丸めは表示のみ", () => {
function opCell(value: number | null, status: FacilityStatus = "warning"): string[] {
const csv = buildFacilityPerformanceCsv(
[item({ operation_rate: kpi(value), status })],
"monthly"
);
return cells(lines(csv)[1]);
}
it("C3 89.5 → 90%。ステータスは入力の status を表示名にするだけ(判定し直さない)", () => {
const row = opCell(89.5, "warning");
expect(row[COL.op]).toBe("90%");
expect(row[COL.status]).toBe("注意");
});
it("C4 91.4 → 91%", () => {
expect(opCell(91.4)[COL.op]).toBe("91%");
});
// ★C5 は D-164 で変更★(ユーザー指示 2026-10-06「値がないとこは空欄で」)。
// 旧: null → "'-"(displayRate の "-" に csvCell の数式対策で ' が付き、Excel に `'-` と見えていた)
it("C5 null → 空欄(csvRate。清掃詳細ログと同じ。D-164)", () => {
expect(opCell(null)[COL.op]).toBe("");
});
it("C5 6列の率がどれも null なら全部空欄", () => {
const csv = buildFacilityPerformanceCsv(
[
item({
operation_rate: kpi(null),
launch_rate: kpi(null),
completion_rate: kpi(null),
error_rate: kpi(null),
}),
],
"monthly"
);
const row = cells(lines(csv)[1]);
for (const col of [
COL.op,
COL.opPrev,
COL.launch,
COL.completion,
COL.completionPrev,
COL.error,
]) {
expect(row[col]).toBe("");
}
});
it("前期・出発成功率・完了率・エラー率の各列も csvRate を通る", () => {
const row = cells(lines(buildFacilityPerformanceCsv([item()], "monthly"))[1]);
expect(row).toEqual([
"メガバリューマート中央本店",
"PUDU-CC1-0001",
"95%",
"93%",
"96%",
"92%",
"91%",
"3%",
"▲2",
"良好",
]);
});
});
describe("変化(C6〜C9)", () => {
const cases: Array<[string, number | null, number | null, string]> = [
["C6 2.4・1.0 → ▲2", 2.4, 1.0, "▲2"],
["C7 -0.4・0.2 → →", -0.4, 0.2, "→"],
["C8 稼働率の diff が null → →", null, 3, "→"],
["C8 完了率の diff が null → →", 3, null, "→"],
["C9 -3・-2 → ▼2", -3, -2, "▼2"],
];
it.each(cases)("%s", (_name, op, cv, expected) => {
const csv = buildFacilityPerformanceCsv(
[item({ operation_rate: kpi(90, 90, op), completion_rate: kpi(90, 90, cv) })],
"weekly"
);
expect(cells(lines(csv)[1])[COL.diff]).toBe(expected);
});
});
describe("ステータス(C10)", () => {
const cases: Array<[FacilityStatus, string]> = [
["good", "良好"],
["warning", "注意"],
["bad", "要改善"],
["no_data", "判定なし"],
];
it.each(cases)("C10 %s → %s", (status, expected) => {
const csv = buildFacilityPerformanceCsv([item({ status })], "monthly");
expect(cells(lines(csv)[1])[COL.status]).toBe(expected);
});
});
describe("数式インジェクション(C11)", () => {
it('C11 facility_name = =HYPERLINK("x") → "\'=HYPERLINK(""x"")"', () => {
const csv = buildFacilityPerformanceCsv(
[item({ facility_name: '=HYPERLINK("x")' })],
"monthly"
);
expect(lines(csv)[1].startsWith('"\'=HYPERLINK(""x"")",')).toBe(true);
});
it("シリアルも csvCell を通る", () => {
const csv = buildFacilityPerformanceCsv([item({ serial_number: "+1" })], "monthly");
expect(cells(lines(csv)[1])[COL.serial]).toBe("'+1");
});
});
describe("文書の形(C12 / C13)", () => {
it("C12 0件は BOM + ヘッダ + CRLF だけ", () => {
expect(buildFacilityPerformanceCsv([], "monthly")).toBe(
`${facilityPerformanceCsvHeader("monthly").join(",")}\r\n`
);
});
it("C13 先頭 BOM・CRLF 区切り・末尾 CRLF・各行のセル数 = 10", () => {
const csv = buildFacilityPerformanceCsv(
[
item({ facility_id: 1, operation_rate: kpi(null) }),
item({ facility_id: 2, facility_name: "南口店", status: "bad" }),
item({ facility_id: 3, facility_name: "北口店", status: "no_data" }),
],
"yearly"
);
expect(csv.startsWith("")).toBe(true);
expect(csv.endsWith("\r\n")).toBe(true);
// CR を伴わない LF が無い(行区切りは全部 CRLF)
expect(/(?<!\r)\n/.test(csv)).toBe(false);
const rows = lines(csv);
expect(rows).toHaveLength(4);
for (const row of rows) {
expect(cells(row)).toHaveLength(10);
}
});
it("並べ替えない(渡された順のまま出す。並び順はルートが決める)", () => {
const csv = buildFacilityPerformanceCsv(
[
item({ facility_id: 2, facility_name: "B店", operation_rate: kpi(99) }),
item({ facility_id: 1, facility_name: "A店", operation_rate: kpi(10) }),
],
"monthly"
);
expect(lines(csv).slice(1).map((l) => cells(l)[COL.name])).toEqual(["B店", "A店"]);
});
});
describe("ファイル名(仕様 §8)", () => {
const cases: Array<[string, Parameters<typeof facilityPerformanceCsvFilename>[0], string]> = [
[
"weekly",
{ period: "weekly", year: 2026, month: 10, week: 1 },
"facility-performance_weekly_2026-10-w1.csv",
],
[
"monthly(月は2桁)",
{ period: "monthly", year: 2026, month: 9, week: 0 },
"facility-performance_monthly_2026-09.csv",
],
[
"yearly(month / week は 0 で、使わない)",
{ period: "yearly", year: 2025, month: 0, week: 0 },
"facility-performance_yearly_2025.csv",
],
[
"weekly の1桁月も2桁",
{ period: "weekly", year: 2026, month: 8, week: 6 },
"facility-performance_weekly_2026-08-w6.csv",
],
];
it.each(cases)("%s", (_name, q, expected) => {
expect(facilityPerformanceCsvFilename(q)).toBe(expected);
});
it("ASCII のみ", () => {
for (const [, q] of cases) {
expect(/^[\x20-\x7E]+$/.test(facilityPerformanceCsvFilename(q))).toBe(true);
}
});
});