Newer
Older
client_dwatch / lib / clean-logs / __tests__ / csv.test.ts
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`);
  });
});