Newer
Older
client_dwatch / lib / dashboard / __tests__ / aggregate.test.ts
/**
 * ダッシュボード集計の期待値表(D-041: 実装より先にこの表を作る)
 *
 * 定義の正は `docs/decisions.md` D-049。特に事故りやすいのは次の3点。
 *   - `planned` はスケジュール由来。清掃ログの行数ではない
 *   - `completed` は「出発した件数」。成功件数ではない
 *   - 分母0では割らずに null を返す(NaN / Infinity を出さない)
 */
import { describe, expect, test } from "vitest";
import {
  computeScheduleProgress,
  computeSecurityBanner,
  computeSummary,
} from "@/lib/dashboard/aggregate";
import type { CleanLogItem, DsAlert, Grade, Schedule } from "@/lib/types";

let seq = 0;

function log(params: {
  departed: boolean;
  grade: Grade;
  completion_rate?: number | null;
  scheduled_at?: string;
  /** ★結果待ち★(D-173)。★`true` のときも `departed: false` / `grade: "×"` は今のまま★(呼び出し側で渡す) */
  result_pending?: boolean;
}): CleanLogItem {
  seq += 1;
  return {
    id: seq,
    facility_id: seq,
    facility_name: `施設${seq}`,
    model_name: "PUDU CC1",
    serial_number: `SER-${seq}`,
    scheduled_at: params.scheduled_at ?? "2026-08-28T02:00:00+09:00",
    status_1h: "待機中",
    status_1h_minutes: 60,
    status_30m: "待機中",
    status_30m_minutes: 30,
    departed: params.departed,
    // ★型追従(指示書AQ 追補5-2: `clean_error` → `task_status`)★。`departed` から導出する —
    //   出発していない = `clean_task_reports` の行が無い = `null`(指示書AQ §1-1)。
    //   出発した行の `4` は ★意味を持たない任意の生値★(CSV実測で最頻の値。status の意味は
    //   判定には使わない。D-155 項2)。★集計は `departed` しか読まない★ので、ここで値の意味は決めていない
    task_status: params.departed ? 4 : null,
    result_pending: params.result_pending ?? false,
    completion_rate: params.completion_rate ?? null,
    grade: params.grade,
    consecutive_problem_days: 0,
    escalation_level: null,
  };
}

function schedule(params: {
  weekdays: string;
  active: boolean;
  facility_id?: number;
}): Schedule {
  seq += 1;
  return {
    id: seq,
    serial_number: `SER-${seq}`,
    facility_id: params.facility_id ?? seq,
    facility_name: `施設${seq}`,
    weekdays: params.weekdays,
    weekdays_label: "テスト",
    start_time: "02:00",
    end_time: "03:00",
    active: params.active,
  };
}

function alert(params: {
  alert_type: DsAlert["alert_type"];
  resolution_status: DsAlert["resolution_status"];
  detected_at?: string;
}): DsAlert {
  seq += 1;
  return {
    id: seq,
    alert_type: params.alert_type,
    device_serial: `DS-${seq}`,
    facility_id: seq,
    facility_name: `施設${seq}`,
    section_name: "1F売り場",
    detected_at: params.detected_at ?? "2026-08-28T03:42:00+09:00",
    resolution_status: params.resolution_status,
    responded_at: null,
    resolved_at: params.resolution_status === "done" ? "2026-08-28T09:00:00+09:00" : null,
    memo: null,
  };
}

describe("computeSummary", () => {
  test("◎○ は success、出発した△× は failed、未出発は not_executed", () => {
    const logs = [
      log({ departed: true, grade: "◎" }),
      log({ departed: true, grade: "○" }),
      log({ departed: true, grade: "△" }),
      log({ departed: true, grade: "×" }),
      log({ departed: false, grade: "×" }),
    ];
    const s = computeSummary(logs);
    expect(s.success).toBe(2);
    expect(s.failed).toBe(2);
    expect(s.not_executed).toBe(1);
    expect(s.success_rate).toBe(40);
  });

  test("未出発の × は failed に数えない(二重計上しない)", () => {
    const logs = [log({ departed: false, grade: "×" })];
    const s = computeSummary(logs);
    expect(s.failed).toBe(0);
    expect(s.not_executed).toBe(1);
    expect(s.success_rate).toBe(0);
  });

  test("success_rate = 100.0(全件成功)", () => {
    const logs = [
      log({ departed: true, grade: "◎" }),
      log({ departed: true, grade: "○" }),
    ];
    expect(computeSummary(logs).success_rate).toBe(100);
  });

  test("success_rate = 0.0(全件未実施)", () => {
    const logs = [
      log({ departed: false, grade: "×" }),
      log({ departed: false, grade: "×" }),
    ];
    expect(computeSummary(logs).success_rate).toBe(0);
  });

  test("ログが0件なら success_rate は null(NaN にしない)", () => {
    const s = computeSummary([]);
    expect(s.success).toBe(0);
    expect(s.failed).toBe(0);
    expect(s.not_executed).toBe(0);
    expect(s.pending).toBe(0);
    expect(s.success_rate).toBeNull();
    expect(Number.isNaN(s.success_rate as number)).toBe(false);
  });

  // ★事前チェックの結果・有無で成功を失敗にしない★(ユーザー指示 2026-10-07「事前チェックを行っていない清掃や
  //   事前チェックが失敗した清掃でも本清掃が成功していれば成功にしてください」。D-170 / D-048)。
  //   `status_1h` / `status_30m` は表示だけの列で、集計は `departed` と `grade` しか読まない — それを固定する
  test.each([
    ["事前チェックで異常(1h前・30分前とも)", "EmergencyStop", "Offline"],
    ["事前チェックで異常(1h前だけ。30分前に復旧)", "Handrail Lifted", "Standby."],
    ["事前チェックの予定はあり異常なし", "Standby.", "Standby."],
    // ★予定が無い回は null★(D-171。それまでは Standby. と区別できなかった)
    ["事前チェックの予定が無い(null)", null, null],
  ])("%s でも、本清掃が ◎○ なら success", (_label, status1h, status30m) => {
    const logs = [
      { ...log({ departed: true, grade: "◎", completion_rate: 95 }), status_1h: status1h, status_30m: status30m },
      { ...log({ departed: true, grade: "○", completion_rate: 75 }), status_1h: status1h, status_30m: status30m },
    ];
    const s = computeSummary(logs);
    expect(s.success).toBe(2);
    expect(s.failed).toBe(0);
    expect(s.not_executed).toBe(0);
  });

  // ★結果待ち(D-173。ユーザー判断 2026-10-07)★ — 表2 C5(監督確定):
  //   R(結果待ち)は `pending` に数え、success / failed / not_executed に入れない。
  //   ★success_rate の分母に pending を入れない★(分母0なら null)。
  //   ★R の行は `departed: false` / `grade: "×"` のまま★(不変条件を壊さない)— 旗を見ずに数えると未実施に入ってしまう
  describe("結果待ち(表2 C5。D-173)", () => {
    /** 結果待ちの行 R(★`departed: false` / `completion_rate: null` / `grade: "×"` は確定した出発失敗と同じ★) */
    const pendingRow = () => log({ departed: false, grade: "×", result_pending: true });

    test("C5 R・F・S が1件ずつ → pending 1 / not_executed 1 / success 1、success_rate は 50(分母に R を入れない)", () => {
      const s = computeSummary([
        pendingRow(),
        log({ departed: false, grade: "×" }), // F(確定した出発失敗)
        log({ departed: true, grade: "◎", completion_rate: 95 }), // S
      ]);
      expect(s).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 1, success_rate: 50 });
    });

    test("C5 R は failed にも数えない", () => {
      const s = computeSummary([pendingRow(), log({ departed: true, grade: "△", completion_rate: 50 })]);
      expect(s).toEqual({ success: 0, failed: 1, not_executed: 0, pending: 1, success_rate: 0 });
    });

    test("C5 S と R だけ → success_rate は 100(R で下がらない)", () => {
      const s = computeSummary([log({ departed: true, grade: "○", completion_rate: 75 }), pendingRow(), pendingRow()]);
      expect(s.pending).toBe(2);
      expect(s.success_rate).toBe(100);
    });

    test("C5 全部が R → success_rate は null(分母0。0 にしない)", () => {
      const s = computeSummary([pendingRow(), pendingRow()]);
      expect(s).toEqual({ success: 0, failed: 0, not_executed: 0, pending: 2, success_rate: null });
      expect(Number.isNaN(s.success_rate as number)).toBe(false);
    });

    test("C5 結果待ちが無ければ pending は 0(旗が false の未出発は今までどおり未実施)", () => {
      const s = computeSummary([log({ departed: false, grade: "×", result_pending: false })]);
      expect(s).toEqual({ success: 0, failed: 0, not_executed: 1, pending: 0, success_rate: 0 });
    });
  });

  test("success_rate は丸めない(表示層で丸める。CLAUDE.md §4.3)", () => {
    const logs = [
      log({ departed: true, grade: "◎" }),
      log({ departed: true, grade: "△" }),
      log({ departed: true, grade: "△" }),
    ];
    expect(computeSummary(logs).success_rate).toBeCloseTo(33.333333, 5);
  });
});

describe("computeScheduleProgress", () => {
  // 2026-08-28 は金曜日(weekdays の index 5)
  const friday = "2026-08-28";
  // 2026-08-30 は日曜日(index 0)
  const sunday = "2026-08-30";

  test("planned は active かつ当日曜日が 1 の件数", () => {
    const schedules = [
      schedule({ weekdays: "1111111", active: true }), // 毎日 → 該当
      schedule({ weekdays: "0111110", active: true }), // 平日 → 金曜は該当
      schedule({ weekdays: "1000001", active: true }), // 土日 → 金曜は非該当
      schedule({ weekdays: "1111111", active: false }), // 無効 → 数えない
    ];
    const p = computeScheduleProgress([], schedules, friday);
    expect(p.planned).toBe(2);
  });

  test("曜日の並びは日月火水木金土(index 0 が日曜)", () => {
    const schedules = [schedule({ weekdays: "1000001", active: true })];
    expect(computeScheduleProgress([], schedules, sunday).planned).toBe(1);
    expect(computeScheduleProgress([], schedules, friday).planned).toBe(0);
  });

  test("completed は departed === true の件数(成功件数ではない)", () => {
    const logs = [
      log({ departed: true, grade: "◎" }),
      log({ departed: true, grade: "×" }), // 出発したが失敗 → completed に入る
      log({ departed: false, grade: "×" }),
    ];
    const schedules = [
      schedule({ weekdays: "1111111", active: true }),
      schedule({ weekdays: "1111111", active: true }),
      schedule({ weekdays: "1111111", active: true }),
      schedule({ weekdays: "1111111", active: true }),
    ];
    const p = computeScheduleProgress(logs, schedules, friday);
    expect(p.completed).toBe(2);
    expect(p.planned).toBe(4);
    expect(p.rate).toBe(50);
  });

  test("planned = 0(全施設が定休日)なら rate は null", () => {
    const schedules = [schedule({ weekdays: "1000001", active: true })];
    const p = computeScheduleProgress([], schedules, friday);
    expect(p.planned).toBe(0);
    expect(p.rate).toBeNull();
    // ★HTTP 越しでは NaN も null にシリアライズされて区別できない★
    // 値の段階で NaN でないことを確かめられるのはここだけ
    expect(Number.isNaN(p.rate as number)).toBe(false);
    expect(Number.isFinite(p.rate as number)).toBe(false);
    expect(JSON.parse(JSON.stringify(p)).rate).toBeNull();
  });

  test("planned = 0 でログがある異常データでも NaN / Infinity を返さない", () => {
    const logs = [log({ departed: true, grade: "◎" })];
    const p = computeScheduleProgress(logs, [], friday);
    expect(p.planned).toBe(0);
    expect(p.completed).toBe(1);
    expect(p.rate).toBeNull();
  });

  test("スケジュールが0件でも落ちない", () => {
    const p = computeScheduleProgress([], [], friday);
    expect(p).toEqual({ planned: 0, completed: 0, rate: null });
  });

  test("rate は丸めない", () => {
    const logs = [log({ departed: true, grade: "◎" })];
    const schedules = [
      schedule({ weekdays: "1111111", active: true }),
      schedule({ weekdays: "1111111", active: true }),
      schedule({ weekdays: "1111111", active: true }),
    ];
    expect(computeScheduleProgress(logs, schedules, friday).rate).toBeCloseTo(
      33.333333,
      5
    );
  });

  test("日付文字列から曜日を決める(実行環境のTZに依存しない)", () => {
    const schedules = [schedule({ weekdays: "0000010", active: true })];
    // 金曜のみ = index 5
    expect(computeScheduleProgress([], schedules, friday).planned).toBe(1);
    expect(computeScheduleProgress([], schedules, "2026-08-27").planned).toBe(0);
  });
});

describe("computeSecurityBanner", () => {
  test("security_off かつ未解消のものだけ返す", () => {
    const alerts = [
      alert({ alert_type: "security_off", resolution_status: "pending" }),
      alert({ alert_type: "security_off", resolution_status: "in_progress" }),
      alert({ alert_type: "security_off", resolution_status: "done" }),
      alert({ alert_type: "disconnected", resolution_status: "pending" }),
    ];
    const banner = computeSecurityBanner(alerts);
    expect(banner).toHaveLength(2);
  });

  test("0件なら空配列", () => {
    const alerts = [
      alert({ alert_type: "disconnected", resolution_status: "pending" }),
      alert({ alert_type: "security_off", resolution_status: "done" }),
    ];
    expect(computeSecurityBanner(alerts)).toEqual([]);
  });

  test("since は detected_at、自由記述(detail / memo)は含めない", () => {
    const alerts = [
      alert({
        alert_type: "security_off",
        resolution_status: "pending",
        detected_at: "2026-08-28T03:42:00+09:00",
      }),
    ];
    const [item] = computeSecurityBanner(alerts);
    expect(item.since).toBe("2026-08-28T03:42:00+09:00");
    expect(item.section_name).toBe("1F売り場");
    expect(item).not.toHaveProperty("detail");
    expect(item).not.toHaveProperty("memo");
    expect(Object.keys(item).sort()).toEqual([
      "facility_name",
      "section_name",
      "since",
    ]);
  });
});