import { describe, expect, it } from "vitest";
import {
buildCleanLogCsv,
CLEAN_LOG_CSV_HEADER,
csvCell,
} from "@/lib/clean-logs/csv";
import { displayRate } from "@/lib/judge";
import type { CleanLogItem } from "@/lib/types";
describe("csvCell — RFC 4180 のエスケープ", () => {
const cases: Array<[string, string, string]> = [
["そのまま出す値は囲まない", "メガバリューマート中央本店", "メガバリューマート中央本店"],
["空文字はそのまま", "", ""],
["カンマを含むなら囲む", "中央店, 本館", '"中央店, 本館"'],
['"を含むなら囲んで""にする', 'PUDU "CC1"', '"PUDU ""CC1"""'],
["LFを含むなら囲む", "1行目\n2行目", '"1行目\n2行目"'],
["CRを含むなら囲む", "1行目\r2行目", '"1行目\r2行目"'],
];
it.each(cases)("%s", (_name, input, expected) => {
expect(csvCell(input)).toBe(expected);
});
});
describe("csvCell — 数式インジェクション対策(指示書AE 段階2-4 / D-125)", () => {
const cases: Array<[string, string, string]> = [
["先頭が = なら ' を前置", "=SUM(1)", "'=SUM(1)"],
["先頭が + なら ' を前置", "+1", "'+1"],
["先頭が - なら ' を前置", "-1", "'-1"],
["先頭が @ なら ' を前置", "@import", "'@import"],
["先頭が = の1文字だけでも前置", "=", "'="],
["先頭以外の = には何もしない", "A=B", "A=B"],
["先頭以外の - には何もしない", "PUDU-CC1", "PUDU-CC1"],
["通常の施設名には何もしない", "メガバリューマート中央本店", "メガバリューマート中央本店"],
["シリアルには何もしない", "8111R5628060011", "8111R5628060011"],
];
it.each(cases)("%s", (_name, input, expected) => {
expect(csvCell(input)).toBe(expected);
});
it("★前置してから囲む★ — ' が引用符の内側に入る", () => {
// ★順序が逆だと `'` が引用符の外に出て、対策が効かなくなる★
expect(csvCell("=A,B")).toBe("\"'=A,B\"");
});
it("★前置してから囲む★ — \" を含む場合も ' は内側", () => {
expect(csvCell('=A"B')).toBe('"\'=A""B"');
});
});
/**
* ★TAB(0x09) / CR(0x0D) の先頭★(査読指摘。OWASP の CSV Injection 対策)
*
* 表計算ソフトは取り込み時に先頭の空白類を落とすため、`\t=SUM(1)` は
* `=SUM(1)` として評価されうる。しかも
* - TAB始まりは引用判定(`/[",\n\r]/`)に掛からず★何の加工も受けない★
* - CR始まりは引用はされるが、★引用符を外してから評価される★ので引用だけでは止まらない
* ★過剰防御になっていないこと(値の途中の TAB は素通し)も対で固定する★
*/
describe("csvCell — TAB / CR 始まり", () => {
const cases: Array<[string, string, string]> = [
["先頭がTABなら ' を前置", "\t=SUM(1)", "'\t=SUM(1)"],
["TAB1文字だけでも前置", "\t", "'\t"],
["TABのあとが普通の文字でも前置(判定は先頭文字だけを見る)", "\t中央店", "'\t中央店"],
// CR は引用もされる。★' は引用符の内側★(前置→引用の順序)
["先頭がCRなら ' を前置したうえで囲む", "\r=SUM(1)", "\"'\r=SUM(1)\""],
["CRLF始まりも同じ", "\r\n=SUM(1)", "\"'\r\n=SUM(1)\""],
// ★ここから下は「加工されないこと」の確認★
["途中のTABには何もしない(囲みもしない)", "PUDU\tCC1", "PUDU\tCC1"],
["末尾のTABにも何もしない", "PUDU CC1\t", "PUDU CC1\t"],
["先頭がLFのときは囲むだけ(LFは数式の起点ではない)", "\n=SUM(1)", '"\n=SUM(1)"'],
];
it.each(cases)("%s", (_name, input, expected) => {
expect(csvCell(input)).toBe(expected);
});
});
/**
* ★全列が同じ関門(`csvCell()`)を通ること★(査読指摘)
*
* 「出発 / 完了率 / 判定」の3列は固定文言に見えるが、`displayRate()` は
* 未実施(`completion_rate: null`)で ★`-` を返す★ ので、素通しだと
* ★先頭が `-` のセルが無防備に出る★。列ごとに安全かどうかを判断させない。
* (★完了率列はいまは `csvRate()` で null を空欄にしている★(D-164)が、関門は全列に残す)
*/
describe("buildCleanLogCsv — 全列が csvCell() を通る", () => {
/** 危険な値を含まない土台。各テストで必要な列だけ差し替える */
const base: CleanLogItem = {
id: 1,
facility_id: 10,
facility_name: "メガバリューマート中央本店",
model_name: "PUDU CC1",
serial_number: "8111R5628060011",
scheduled_at: "2026-09-13T02:00:00+09:00",
status_1h: "正常",
status_1h_minutes: 60,
status_30m: "正常",
status_30m_minutes: 30,
departed: true,
task_status: 4,
result_pending: false,
completion_rate: 95.2,
grade: "◎",
consecutive_problem_days: 0,
escalation_level: null,
};
/** ヘッダ行を除いた1行目をカンマで割る */
function cells(row: CleanLogItem): string[] {
const lines = buildCleanLogCsv([row]).split("\r\n");
return lines[1].split(",");
}
it("未実施(completion_rate: null)の完了率は★空欄★(D-164)", () => {
// ★経緯★: 以前は displayRate(null) の "-" を出しており、csvCell() の数式対策(D-125)で
// "'-" になって Excel に `'-` と見えていた。ユーザー指示 2026-10-06「値がないとこは空欄で」で
// csvRate()(lib/csv.ts。null → "")に替えた。★画面用の displayRate(null) は "-" のまま★
expect(displayRate(null)).toBe("-");
const row: CleanLogItem = {
...base,
departed: false,
task_status: null,
completion_rate: null,
grade: "×",
};
expect(cells(row)[11]).toBe("");
});
it("通常の完了率(例 95%)は加工されない", () => {
expect(cells(base)[11]).toBe("95%");
});
it("出発列・判定列も csvCell() を通っている", () => {
// 現状の値("出発" / "未出発" / ◎○△×)は加工対象ではないので素通しになる。
// ★ここで確かめたいのは「通っていること」★なので、下の総当たりと対で読むこと
expect(cells(base)[9]).toBe("出発");
expect(cells(base)[12]).toBe("◎");
expect(cells({ ...base, departed: false })[9]).toBe("未出発");
});
it("★どの列に危険な値が来ても素通ししない★", () => {
// DB由来の文字列カラムに数式が混ざった場合。列を足したときに漏れないことの担保
// ★清掃結果列([10])は数値の生値なので文字列の数式は入れられない★ —
// 負の値(`-` 始まり)が csvCell() を通ることは下の 3-7 で確かめる
const row: CleanLogItem = {
...base,
facility_name: "=SUM(1)",
model_name: "+1",
serial_number: "@import",
status_1h: "\t=cmd",
status_30m: "-1",
completion_rate: null,
};
const c = cells(row);
expect(c[0]).toBe("'=SUM(1)");
expect(c[1]).toBe("'+1");
expect(c[2]).toBe("'@import");
expect(c[5]).toBe("'\t=cmd");
expect(c[7]).toBe("'-1");
// 完了率 null は空欄(D-164)。空文字は csvCell() を通っても空のまま
expect(c[11]).toBe("");
});
it("★事前チェックの予定が無い(status_1h / status_30m が null)列は空のセル★(D-171)", () => {
// ★「-」を入れると csvCell() の数式対策で `'-` になる★(D-164 と同じ理由)。値が無いことは空のセルで表す
const row: CleanLogItem = {
...base,
status_1h: null,
status_1h_minutes: null,
status_30m: null,
status_30m_minutes: null,
};
const c = cells(row);
expect(c[5]).toBe("");
expect(c[6]).toBe("");
expect(c[7]).toBe("");
expect(c[8]).toBe("");
// 列はずれない
expect(c).toHaveLength(CLEAN_LOG_CSV_HEADER.length);
expect(c[9]).toBe("出発");
});
it("片方だけ null なら、その列だけ空のセル(もう片方は値のまま)", () => {
// ★順番で割り当てる(D-178)ので API が「1回目 null・2回目あり」を返すことは無いが、CSV は列ごとに独立して出す★
const c = cells({ ...base, status_1h: null, status_1h_minutes: null, status_30m: "Standby." });
expect(c[5]).toBe("");
expect(c[6]).toBe("");
expect(c[7]).toBe("Standby.");
expect(c[8]).toBe("30");
});
/**
* ★事前チェックの分数の列(D-178)★
*
* | # | 1回目 / 分前 | 2回目 / 分前 | セル[5] [6] [7] [8] |
* |----|---------------------|-------------------|--------------------------------------|
* | M1 | 正常 / 60 | 正常 / 30 | 正常 60 正常 30 |
* | M2 | 正常 / 60 | 正常 / 10 | 正常 60 正常 10(設定に無い分数も出す) |
* | M3 | 正常 / 30 | null / null | 正常 30 "" "" |
*/
describe("事前チェックの分数の列(D-178)", () => {
it("見出しは「事前チェック1回目 / 1回目(分前) / 事前チェック2回目 / 2回目(分前)」で、予定時刻と出発の間", () => {
expect(CLEAN_LOG_CSV_HEADER.slice(4, 10)).toEqual([
"予定時刻",
"事前チェック1回目",
"1回目(分前)",
"事前チェック2回目",
"2回目(分前)",
"出発",
]);
});
it("M1: 60 / 30 は数値のまま", () => {
expect(cells(base).slice(5, 9)).toEqual(["正常", "60", "正常", "30"]);
});
it("M2: 設定に無い分数(10)も2回目の分前に出す", () => {
expect(cells({ ...base, status_30m_minutes: 10 }).slice(5, 9)).toEqual(["正常", "60", "正常", "10"]);
});
it("M3: 事前チェックが1回だけ(30分前)なら1回目に 30、2回目は空のセル", () => {
const c = cells({
...base,
status_1h_minutes: 30,
status_30m: null,
status_30m_minutes: null,
});
expect(c.slice(5, 9)).toEqual(["正常", "30", "", ""]);
expect(c).toHaveLength(CLEAN_LOG_CSV_HEADER.length);
});
});
it("ヘッダ行・BOM・CRLF・列数は従来どおり", () => {
const csv = buildCleanLogCsv([base]);
expect(csv.startsWith("\uFEFF")).toBe(true);
expect(csv.endsWith("\r\n")).toBe(true);
const lines = csv.slice(1).split("\r\n");
expect(lines[0]).toBe(CLEAN_LOG_CSV_HEADER.join(","));
expect(cells(base)).toHaveLength(CLEAN_LOG_CSV_HEADER.length);
});
/**
* ★清掃結果列(`task_status` = `clean_task_reports.status` の日本語の表示名)★(指示書AS 段階5 / D-155 項2)
*
* 画面(`CleanLogTable`)と同じ `出発` と `完了率` の間(index 10)に入れる。
* ★ラベルは `formatCleanTaskStatus()` の返り値(日本語の表示名)をそのまま出す★ — CSV は言語を
* 受け取らないので日本語固定(指示書AS D11)。旧コード表(`CLEAN_ERROR`)にも7分類にも通さない。
* ★旧: 生値の数字をそのまま出していた(値の意味が未受領。指示書AQ §1-2)★。
*
* ★期待値表★(表3。第10回 N-2 の原文)
*
* | # | task_status | セル[10] | 意図 |
* |-----|-------------|------------------|------|
* | 3-1 | 3 | `清掃キャンセル` | CSV実測値(2件) |
* | 3-2 | 4 | `清掃正常終了` | CSV実測値(161件。最多) |
* | 3-3 | 5 | `清掃異常終了` | CSV実測値(4件) |
* | 3-4 | 6 | `6` | CSV実測値(22件)。★意味は未受領なので数字のまま★ |
* | 3-5 | null | `出発失敗` | レポート行が無い(指示書AQ §1-1) |
* | 3-6 | ★0★ | ★`清掃開始前`★ | ★`??` を `||` にすると「出発失敗」に化ける値★ |
* | 3-7 | -1 | `'-1` | 表に無い負の値も csvCell() を通る(数式対策) |
*
* (CSV実測値の出所: ユーザー提供 `clean_task_reports.csv` 189行。2026-09-30 受領)
* ★旧コード表の日本語(正常 / 異常(…))にならないことも確かめる★(別のテーブルの別のコード表)。
*/
describe("清掃結果列", () => {
const labelCases: Array<[string, number, string]> = [
["3-1", 3, "清掃キャンセル"],
["3-2", 4, "清掃正常終了"],
["3-3", 5, "清掃異常終了"],
["3-4", 6, "6"],
];
it.each(labelCases)("%s: task_status: %i は日本語の表示名 %s", (_id, status, expected) => {
const cell = cells({ ...base, task_status: status })[10];
expect(cell).toBe(expected);
// ★旧コード表(`CLEAN_ERROR_LABELS`)に寄せない・「その他」「不明」に潰さない★(D-089 項3 / D-104 項2)
expect(cell).not.toContain("正常(");
expect(cell).not.toContain("異常(");
expect(cell).not.toContain("その他");
expect(cell).not.toContain("不明");
});
it("3-5: task_status: null は「出発失敗」(レポート行が無い)", () => {
expect(
cells({ ...base, departed: false, task_status: null, completion_rate: null, grade: "×" })[10]
).toBe("出発失敗");
});
it("3-6: ★task_status: 0 は「清掃開始前」★(「出発失敗」に化けない)", () => {
// ★`??` を `||` にするとここが「出発失敗」になる★(lib/format/clean-result.ts)
const cell = cells({ ...base, task_status: 0 })[10];
expect(cell).toBe("清掃開始前");
expect(cell).not.toBe("出発失敗");
});
it("3-7: 負の生値(-1)も csvCell() を通って ' が付く", () => {
expect(cells({ ...base, task_status: -1 })[10]).toBe("'-1");
});
it("3-8: ヘッダの8番目が「清掃結果」(出発の直後・完了率の直前)", () => {
expect(CLEAN_LOG_CSV_HEADER[10]).toBe("清掃結果");
expect(CLEAN_LOG_CSV_HEADER[9]).toBe("出発");
expect(CLEAN_LOG_CSV_HEADER[11]).toBe("完了率");
});
});
/**
* ★結果待ち(D-173)の行★(ユーザー判断 2026-10-07。表2 C4)
*
* 結果待ち(R)は `departed: false` / `task_status: null` / `completion_rate: null` / `grade: "×"` のまま
* なので、★旗(`result_pending`)を見ないと「未出発・出発失敗・×」と出てしまう★。
* ★値の無いセルは空★(「-」を入れない。`csvCell()` の数式対策で `'-` になる。D-164)。
*
* | # | 行 | 出発[9] | 清掃結果[10] | 完了率[11] | 判定[12] |
* |------|----|-----------|-------------|-----------|----------|
* | C4-1 | R | 結果待ち | 結果待ち | 空 | ★空★ |
* | C4-2 | F | 未出発 | 出発失敗 | 空 | × |
*/
describe("結果待ち(表2 C4。D-173)", () => {
const notDeparted = {
departed: false,
task_status: null,
completion_rate: null,
grade: "×",
} as const;
it("C4-1: 結果待ちは 出発・清掃結果が「結果待ち」、完了率・判定は空のセル", () => {
const c = cells({ ...base, ...notDeparted, result_pending: true });
expect(c[9]).toBe("結果待ち");
expect(c[10]).toBe("結果待ち");
expect(c[11]).toBe("");
expect(c[12]).toBe("");
expect(c).toHaveLength(CLEAN_LOG_CSV_HEADER.length);
});
it("C4-2: 確定した出発失敗は今までどおり(未出発・出発失敗・空・×)", () => {
const c = cells({ ...base, ...notDeparted, result_pending: false });
expect(c[9]).toBe("未出発");
expect(c[10]).toBe("出発失敗");
expect(c[11]).toBe("");
expect(c[12]).toBe("×");
});
});
it("0件でもヘッダ行だけは出る", () => {
expect(buildCleanLogCsv([])).toBe(`\uFEFF${CLEAN_LOG_CSV_HEADER.join(",")}\r\n`);
});
});