Newer
Older
client_dwatch / lib / fixtures / __tests__ / clean-task-reports.test.ts
import { describe, expect, it } from "vitest";
import {
  CLEAN_TASK_REPORT_ID_DIGITS,
  CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH,
  CLEAN_TASK_REPORT_STATUS_DISTRIBUTION,
  cleanTaskReportCleanArea,
  cleanTaskReportId,
  cleanTaskReportNullTimetableLogIds,
  cleanTaskReportStatus,
  cleanTaskReportTaskArea,
  cleanTaskReports,
} from "@/lib/fixtures/clean-task-reports";
import { runCompletionRate } from "@/lib/judge";
import { boundaryCases, cleanLogs, escalationCases, statusCases } from "@/lib/fixtures/clean-logs";

/**
 * `clean_task_reports` のサンプルデータ(docs/fixtures.md §2.6)が
 * ★狙ったケースになっているか★ を固定するテスト。
 *
 * ★「落ちない」ことと「狙った数字になっている」ことは別★なのでここで固定する
 * (`lib/fixtures/__tests__/areas.test.ts` と同じ考え方)。
 */

describe("fixtures: cleanTaskReports", () => {
  it("未実施(completion_rate が null)の清掃には行を作らない(指示書AQ 1-1)", () => {
    const notExecuted = cleanLogs.filter((log) => log.completion_rate === null);
    // ★未実施のケースが fixtures から消えていないこと★(消えるとこのテストが無意味になる)
    expect(notExecuted.length).toBeGreaterThan(0);

    const reportedLogIds = new Set(
      cleanTaskReports.map((report) => report.clean_timetable_id)
    );
    for (const log of notExecuted) {
      expect(reportedLogIds.has(log.id), `未実施の id=${log.id} に行がある`).toBe(false);
    }
  });

  it("実施済みの清掃はすべて1行持つ(clean_timetable_id を NULL にした行も含む)", () => {
    const executed = cleanLogs.filter((log) => log.completion_rate !== null);
    expect(cleanTaskReports).toHaveLength(executed.length);
  });

  it("report_id は19桁の数値文字列で重複しない(varchar(20))", () => {
    for (const report of cleanTaskReports) {
      expect(report.report_id).toMatch(/^\d+$/);
      expect(report.report_id).toHaveLength(CLEAN_TASK_REPORT_ID_DIGITS);
    }
    expect(new Set(cleanTaskReports.map((r) => r.report_id)).size).toBe(
      cleanTaskReports.length
    );
  });

  it("clean_ratio は対応する清掃ログの completion_rate と同じ(境界値10ケースを壊さない)", () => {
    const byId = new Map(cleanLogs.map((log) => [log.id, log]));
    for (const report of cleanTaskReports) {
      // ★NULL にした行は clean_timetable_id から引けない★ので report_id 側から引く
      const logId = report.clean_timetable_id;
      if (logId === null) continue;
      expect(report.clean_ratio).toBe(byId.get(logId)?.completion_rate);
    }
  });

  it("docs/fixtures.md §2.1 の境界値がすべて clean_ratio として存在する", () => {
    const ratios = new Set(cleanTaskReports.map((r) => r.clean_ratio));
    // ★未実施(null) は「行が無い」ことで表すので、ここには出てこない(上の1件目のテストが担保)★
    for (const value of [90.1, 90.0, 89.5, 70.1, 70.0, 10.1, 10.0, 9.9, 0]) {
      expect(ratios.has(value), `完了率 ${value} の行が無い`).toBe(true);
    }
  });

  it("status は実測の4種(3 / 4 / 5 / 6)がすべて出る", () => {
    const seen = new Set(cleanTaskReports.map((r) => r.status));
    for (const { status } of CLEAN_TASK_REPORT_STATUS_DISTRIBUTION) {
      expect(seen.has(status), `status=${status} の行が無い`).toBe(true);
    }
    expect([...seen].sort((a, b) => a - b)).toEqual([3, 4, 5, 6]);
  });

  it("status の巡回列はCSV実測189行の分布そのもの(3:2 / 4:161 / 5:4 / 6:22)", () => {
    expect(CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH).toBe(189);
    const counts: Record<number, number> = {};
    for (let i = 0; i < CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH; i += 1) {
      const status = cleanTaskReportStatus(i);
      counts[status] = (counts[status] ?? 0) + 1;
    }
    for (const { status, count } of CLEAN_TASK_REPORT_STATUS_DISTRIBUTION) {
      expect(counts[status], `status=${status}`).toBe(count);
    }
  });

  it("status の巡回列は塊にならず散っている(先頭189件が全部 4 にならない)", () => {
    const firstTwenty = Array.from({ length: 20 }, (_, i) => cleanTaskReportStatus(i));
    expect(new Set(firstTwenty).size).toBeGreaterThan(1);
  });

  it("ended_at が null の行がある(落とし穴3。順序キーは ended_at ?? started_at)", () => {
    const nullEnded = cleanTaskReports.filter((r) => r.ended_at === null);
    expect(nullEnded.length).toBeGreaterThan(0);
    // ★ended_at が null でも clean_ratio は入っている★(判定は壊れない)
    for (const report of nullEnded) {
      expect(report.clean_ratio).not.toBeNull();
      // ★終了時刻が無い行の clean_time は 0★
      expect(report.clean_time).toBe(0);
    }
  });

  it("clean_timetable_id が null の行がある(落とし穴1。fail-silent の検証用)", () => {
    const nullTimetable = cleanTaskReports.filter((r) => r.clean_timetable_id === null);
    expect(nullTimetable.length).toBeGreaterThan(0);
    expect(nullTimetable).toHaveLength(cleanTaskReportNullTimetableLogIds.size);
  });

  it("★境界値・エスカレーション・ステータスのケースには clean_timetable_id の NULL を当てない★", () => {
    // NULL にすると「未実施」として集計され、§2.1 / §2.2 のケースが画面で確認できなくなる
    const protectedIds = [...boundaryCases, ...escalationCases, ...statusCases].map(
      (log) => log.id
    );
    for (const id of protectedIds) {
      expect(
        cleanTaskReportNullTimetableLogIds.has(id),
        `id=${id}(境界値/エスカレーション/ステータスのケース)が NULL 対象になっている`
      ).toBe(false);
    }
  });

  it("clean_time が 0 の行がある(実測に 0 の行がある)", () => {
    expect(cleanTaskReports.some((r) => r.clean_time === 0)).toBe(true);
    expect(cleanTaskReports.some((r) => r.clean_time > 0)).toBe(true);
  });

  it("task_area は清掃エリア(section_id)ごとに一定", () => {
    const bySection = new Map<number, Set<number>>();
    for (const report of cleanTaskReports) {
      const set = bySection.get(report.section_id) ?? new Set<number>();
      set.add(report.task_area);
      bySection.set(report.section_id, set);
    }
    expect(bySection.size).toBeGreaterThan(1);
    for (const [sectionId, values] of bySection) {
      expect(values.size, `section_id=${sectionId} の task_area が一定でない`).toBe(1);
      expect([...values][0]).toBe(cleanTaskReportTaskArea(sectionId));
    }
  });

  it("task_area は清掃エリアごとに違う値になる(全エリア一律にしない)", () => {
    const areas = new Set(cleanTaskReports.map((r) => r.task_area));
    expect(areas.size).toBeGreaterThan(1);
  });

  // ★★D-155(指示書AS 段階6-1): 完了率は面積から `runCompletionRate()` で決まる★★ —
  //   ★旧: 「過半数の行で task_area を超える(実測 68.3%)」「clean_ratio は面積比と一致しない」
  //   「面積比100%超で clean_ratio が低い行がある」の3件は、いまの仕様と逆なので削除した★
  it("★全行で runCompletionRate(row) が対応する清掃ログの completion_rate と一致する★(D-155)", () => {
    // ★`clean_timetable_id` を NULL にした行も含めるため `report_id`(元の清掃 id から作る)で引く★
    const byReportId = new Map(cleanLogs.map((log) => [cleanTaskReportId(log.id), log]));
    expect(cleanTaskReports.length).toBeGreaterThan(0);
    for (const report of cleanTaskReports) {
      const log = byReportId.get(report.report_id);
      expect(log, `report_id=${report.report_id} の清掃ログが無い`).toBeDefined();
      expect(runCompletionRate(report), `report_id=${report.report_id}`).toBe(log?.completion_rate);
    }
  });

  it("clean_area は変動する(エリア・完了率ごとに違う値)", () => {
    expect(new Set(cleanTaskReports.map((r) => r.clean_area)).size).toBeGreaterThan(1);
  });

  it("★完了率100の清掃は task_area を超える面積になる(上限100の確認)★", () => {
    // ★fixtures の清掃ログには完了率100が無い(最大 99.5)ので、関数を直接確かめる★
    const taskArea = 878.524992;
    for (const id of [1, 2, 3, 101, 999]) {
      const cleanArea = cleanTaskReportCleanArea(taskArea, 100, id);
      expect(cleanArea).toBeGreaterThanOrEqual(taskArea);
      expect(cleanArea).toBeLessThanOrEqual(taskArea * 1.4 + 1e-6);
      expect(runCompletionRate({ clean_area: cleanArea, task_area: taskArea })).toBe(100);
    }
  });

  it("started_at / ended_at は JST の ISO 8601(+09:00)", () => {
    for (const report of cleanTaskReports) {
      expect(report.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$/);
      if (report.ended_at !== null) {
        expect(report.ended_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$/);
        expect(new Date(report.ended_at).getTime()).toBeGreaterThan(
          new Date(report.started_at).getTime()
        );
      }
    }
  });

  it("section_id と robot_id は同値(P1は1清掃エリア=1ロボット)", () => {
    for (const report of cleanTaskReports) {
      expect(report.section_id).toBe(report.robot_id);
    }
  });
});