Newer
Older
client_dwatch / lib / reports / __tests__ / facility-performance-csv.test.ts
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);
    }
  });
});