Newer
Older
client_dwatch / lib / dashboard / __tests__ / aggregate.test.ts
/**
 * ダッシュボード集計の期待値表(D-041: 実装より先にこの表を作る)
 *
 * 定義の正は `docs/decisions.md` D-049。特に事故りやすいのは次の3点。
 *   - `planned` は対象日の本清掃の予定(清掃ログの行)の件数。★`clean_schedules` の曜日の設定ではない★
 *     (ユーザー判断 2026-10-29「案B」。旧 D-049 の「スケジュールマスタ由来」は廃止)
 *   - `completed` は「出発した件数」。成功件数ではない
 *   - 分母0では割らずに null を返す(NaN / Infinity を出さない)
 */
import { describe, expect, test } from "vitest";
import {
  computeScheduleProgress,
  computeSecurityBanner,
  computeSummary,
  scheduleProgressTargetDate,
} from "@/lib/dashboard/aggregate";
import type { CleanLogItem, DsAlert, Grade } 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;
  /**
   * ★予定外タスクの行★(指示書BG §2-1・§2-2)。`id: null`・`row_key: "rp-…"`・`departed: true`・`result_pending: false`・
   * 事前チェック4列は null(`unscheduledCleanLogItem()` と同じ形)
   */
  unscheduled?: boolean;
}): CleanLogItem {
  seq += 1;
  if (params.unscheduled) {
    const reportId = `92000000000000${String(seq).padStart(5, "0")}`;
    return {
      id: null,
      row_key: `rp-${reportId}`,
      report_id: reportId,
      unscheduled: true,
      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: null,
      status_1h_minutes: null,
      status_30m: null,
      status_30m_minutes: null,
      departed: true,
      task_status: 4,
      result_pending: false,
      completion_rate: params.completion_rate ?? null,
      grade: params.grade,
      consecutive_problem_days: 0,
      escalation_level: null,
    };
  }
  return {
    id: seq,
    row_key: `tt-${seq}`,
    report_id: null,
    unscheduled: false,
    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 alert(params: {
  alert_type: DsAlert["alert_type"];
  resolution_status: DsAlert["resolution_status"];
  detected_at?: string;
}): DsAlert {
  seq += 1;
  return {
    id: String(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 === "resolved" ? "2026-08-28T09:00:00+09:00" : null,
    note: 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);
  });
});

/**
 * ★スケジュール進捗(ユーザー判断 2026-10-29「案B」)★ — 期待値表(監督確定。実装より先に書く)。
 *
 * 対象日(`scheduleProgressTargetDate()`):
 * | # | now(JST) | date | 対象日 | 何を守るか |
 * |---|---|---|---|---|
 * | T1 | 2026-10-29 08:00 | 省略 | 2026-10-28 | 直近モードは前日(ダッシュボードの結果は昨夜の清掃) |
 * | T2 | 2026-10-29 00:00 | 省略 | 2026-10-28 | JST の日付の始まり(UTC では 10-28 15:00) |
 * | T3 | 2026-10-29 23:59:59 | 省略 | 2026-10-28 | JST の日付の終わり(UTC では 10-29 14:59) |
 * | T4 | 2027-01-01 09:00 | 省略 | 2026-12-31 | 月・年をまたぐ |
 * | T5 | 2026-10-29 01:00(UTC 10-28 16:00) | 省略 | 2026-10-28 | 実行環境の TZ に依存しない |
 * | T6 | 2026-10-29 08:00 | 2026-09-30 | 2026-09-30 | 日付を指定したらその日(前日にしない) |
 *
 * 件数(`computeScheduleProgress()`。対象日 2026-10-28):
 * | # | 対象日の行(出発 / 未実施 / 結果待ち)+他の日の行 | planned | completed | rate |
 * |---|---|---|---|---|
 * | Q1 | 出発6(◎○△×を含む)/ 未実施1(ステージング 2026-10-29 朝の形) | 7 | 6 | 600/7 |
 * | Q2 | 出発1 + 前日(10-27)・当日(10-29)の行 | 1 | 1 | 100 |
 * | Q3 | 出発1 / 結果待ち1 / 未実施1 | 3 | 1 | 100/3 |
 * | Q4 | 0行(他の日の行だけ) | 0 | 0 | null |
 * | Q5 | 出発した × | 1 | 1 | 100(completed は成功件数ではない) |
 *
 * ★`planned` は予定の表(`clean_timetables` + `clean_timetable_histories`。本清掃)から作った清掃ログの行数★ —
 *   ★`clean_schedules` の曜日の設定は使わない★(2026-10-29 ステージングで曜日の設定 7件・予定の表 5件と食い違った)。
 */
describe("scheduleProgressTargetDate", () => {
  test.each([
    ["T1", "2026-10-29T08:00:00+09:00", null, "2026-10-28"],
    ["T2", "2026-10-29T00:00:00+09:00", null, "2026-10-28"],
    ["T3", "2026-10-29T23:59:59+09:00", null, "2026-10-28"],
    ["T4", "2027-01-01T09:00:00+09:00", null, "2026-12-31"],
    ["T5", "2026-10-28T16:00:00Z", null, "2026-10-28"],
    ["T6", "2026-10-29T08:00:00+09:00", "2026-09-30", "2026-09-30"],
  ] as const)("%s now=%s date=%s → %s", (_id, now, date, expected) => {
    expect(scheduleProgressTargetDate(new Date(now), date)).toBe(expected);
  });
});

describe("computeScheduleProgress", () => {
  const target = "2026-10-28";
  const on = (time: string) => `${target}T${time}:00+09:00`;

  test("Q1 出発6・未実施1 → 7件中6件(ステージング 2026-10-29 朝の形)", () => {
    const logs = [
      log({ departed: true, grade: "◎", scheduled_at: on("01:00") }),
      log({ departed: true, grade: "○", scheduled_at: on("01:30") }),
      log({ departed: true, grade: "△", scheduled_at: on("02:00") }),
      log({ departed: true, grade: "×", scheduled_at: on("02:30") }),
      log({ departed: true, grade: "◎", scheduled_at: on("03:00") }),
      log({ departed: true, grade: "◎", scheduled_at: on("03:30") }),
      log({ departed: false, grade: "×", scheduled_at: on("04:00") }),
    ];
    const p = computeScheduleProgress(logs, target);
    expect(p).toEqual({ date: target, planned: 7, completed: 6, rate: (6 / 7) * 100 });
  });

  test("Q2 対象日以外の行は数えない(前日・翌日)", () => {
    const logs = [
      log({ departed: true, grade: "◎", scheduled_at: on("02:00") }),
      log({ departed: true, grade: "◎", scheduled_at: "2026-10-27T23:59:00+09:00" }),
      log({ departed: false, grade: "×", scheduled_at: "2026-10-29T00:00:00+09:00" }),
    ];
    expect(computeScheduleProgress(logs, target)).toEqual({
      date: target,
      planned: 1,
      completed: 1,
      rate: 100,
    });
  });

  test("Q3 結果待ち・未実施は planned にだけ入る(completed に入れない)", () => {
    const logs = [
      log({ departed: true, grade: "◎", scheduled_at: on("01:00") }),
      log({ departed: false, grade: "×", result_pending: true, scheduled_at: on("02:00") }),
      log({ departed: false, grade: "×", scheduled_at: on("03:00") }),
    ];
    const p = computeScheduleProgress(logs, target);
    expect(p.planned).toBe(3);
    expect(p.completed).toBe(1);
    // ★丸めない★(表示層で丸める。CLAUDE.md §4.3)
    expect(p.rate).toBeCloseTo(33.333333, 5);
  });

  test("Q4 対象日の行が0件なら rate は null(NaN / Infinity にしない)", () => {
    const logs = [log({ departed: true, grade: "◎", scheduled_at: "2026-10-27T02:00:00+09:00" })];
    const p = computeScheduleProgress(logs, target);
    expect(p).toEqual({ date: target, planned: 0, completed: 0, rate: null });
    // ★HTTP 越しでは NaN も null にシリアライズされて区別できない★
    // 値の段階で NaN でないことを確かめられるのはここだけ
    expect(Number.isNaN(p.rate as number)).toBe(false);
    expect(JSON.parse(JSON.stringify(p)).rate).toBeNull();
    expect(computeScheduleProgress([], target)).toEqual({
      date: target,
      planned: 0,
      completed: 0,
      rate: null,
    });
  });

  test("Q5 completed は departed === true の件数(成功件数ではない。出発した × も入る)", () => {
    const logs = [log({ departed: true, grade: "×", completion_rate: 0, scheduled_at: on("02:00") })];
    expect(computeScheduleProgress(logs, target)).toMatchObject({ planned: 1, completed: 1, rate: 100 });
  });

  // ★表U3 U3-3(指示書BG §2-2・A案。監督が書いた。数字を変えない)★ —
  //   対象日に始まった予定外タスクを、予定件数にも出発件数にも1件ずつ足す
  test("U3-3 対象日 10-28: 予定7(出発6)+ 予定外2 → 予定 9・出発 8", () => {
    const logs = [
      log({ departed: true, grade: "◎", scheduled_at: on("01:00") }),
      log({ departed: true, grade: "○", scheduled_at: on("01:30") }),
      log({ departed: true, grade: "△", scheduled_at: on("02:00") }),
      log({ departed: true, grade: "×", scheduled_at: on("02:30") }),
      log({ departed: true, grade: "◎", scheduled_at: on("03:00") }),
      log({ departed: true, grade: "◎", scheduled_at: on("03:30") }),
      log({ departed: false, grade: "×", scheduled_at: on("04:00") }),
      log({ unscheduled: true, departed: true, grade: "◎", completion_rate: 95, scheduled_at: on("21:00") }),
      log({ unscheduled: true, departed: true, grade: "△", completion_rate: 40, scheduled_at: on("23:00") }),
      // ★対象日の外の予定外は数えない(始めた日=`started_at` の日付で振り分ける)★
      log({ unscheduled: true, departed: true, grade: "◎", completion_rate: 95, scheduled_at: "2026-10-29T00:10:00+09:00" }),
    ];
    expect(computeScheduleProgress(logs, target)).toEqual({
      date: target,
      planned: 9,
      completed: 8,
      rate: (8 / 9) * 100,
    });
  });
});

describe("computeSummary — 予定外タスク(指示書BG §2-2)", () => {
  test("予定外は成功(◎○)か失敗(△×)のどちらか。未実施・結果待ちにはならない", () => {
    const logs = [
      log({ unscheduled: true, departed: true, grade: "◎", completion_rate: 95 }),
      log({ unscheduled: true, departed: true, grade: "○", completion_rate: 75 }),
      log({ unscheduled: true, departed: true, grade: "△", completion_rate: 40 }),
      // ★表U1 U1-2: `clean_area` 0 → 完了率 0・× ・出発済み(未実施ではない)★
      log({ unscheduled: true, departed: true, grade: "×", completion_rate: 0 }),
    ];
    expect(computeSummary(logs)).toEqual({
      success: 2,
      failed: 2,
      not_executed: 0,
      pending: 0,
      success_rate: 50,
    });
  });
});

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

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

  test("since は detected_at、自由記述(detail / note / memo)は含めない", () => {
    const alerts = [
      alert({
        alert_type: "security_off",
        resolution_status: "unresolved",
        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(item).not.toHaveProperty("note");
    expect(Object.keys(item).sort()).toEqual([
      "facility_name",
      "section_name",
      "since",
    ]);
  });
});