Newer
Older
client_dwatch / lib / fixtures / __tests__ / areas.test.ts
import { describe, expect, it } from "vitest";
import { areaCompletions, cleanLogs, cleanTaskReportId, cleanTaskReports } from "@/lib/fixtures";
import {
  buildAreaCompletions,
  selectAreaRowsForLog,
} from "@/lib/clean-logs/areas";
import { dateOnly } from "@/lib/fixtures/_helpers";
import { runCompletionRate } from "@/lib/judge";

/**
 * エリア別完了率のサンプルデータ(docs/fixtures.md §2.5)が
 * ★狙ったケースになっているか★ を固定するテスト。
 *
 * ★fixtures は実行日相対で日付を作る★ため、日をまたぐと `cleanLogs` との対応が
 * ズレる可能性がある。ズレたら `lib/fixtures/areas.ts` の組み立てが落ちるが、
 * 「落ちない」ことと「狙った数字になっている」ことは別なのでここで固定する。
 */

function groupOf(facilityId: number, daysBack: number) {
  return selectAreaRowsForLog(areaCompletions, {
    facility_id: facilityId,
    date: dateOnly(daysBack),
  });
}

describe("fixtures: areaCompletions", () => {
  it("すべての行が cleanLogs の既存の行から引かれている(id / 施設 / 日付)", () => {
    const byId = new Map(cleanLogs.map((l) => [l.id, l]));
    for (const row of areaCompletions) {
      const log = byId.get(row.clean_log_id);
      expect(log, `clean_log_id=${row.clean_log_id} が cleanLogs に無い`).toBeDefined();
      expect(log?.facility_id).toBe(row.facility_id);
      expect(log?.scheduled_at.slice(0, 10)).toBe(row.date);
      // 一覧の行とモーダルの行で完了率が食い違わないこと
      expect(log?.completion_rate ?? null).toBe(row.completion_rate);
    }
  });

  it("★全行で runCompletionRate(row) === row.completion_rate★(completion_rate が null の行を除く。D-155)", () => {
    const executed = areaCompletions.filter((r) => r.completion_rate !== null);
    expect(executed.length).toBeGreaterThan(0);
    for (const row of executed) {
      expect(runCompletionRate(row), `clean_log_id=${row.clean_log_id}`).toBe(row.completion_rate);
    }
  });

  it("★3エリアの施設(施設7)★ — 面積ベースと単純平均で判定が割れる", () => {
    const rows = groupOf(7, 1);
    expect(rows).toHaveLength(3);

    const res = buildAreaCompletions(rows, "all");
    // ★期待値を反転(指示書AQ §5-5: `overall_completion_rate` は面積比から単純平均に変わった。D-155 で平均する材料は1回ごとの完了率に変わった)★
    // 単純平均: (62 + 94 + 88) ÷ 3 = 81.33…%(○)
    const simpleAverage = (62 + 94 + 88) / 3;
    expect(res.overall_completion_rate).toBeCloseTo(simpleAverage, 6);
    expect(res.overall_grade).toBe("○");

    // 面積ベースだと (620 + 94 + 88) ÷ (1000 + 100 + 100) = 66.83…%(△)になる。★その実装に戻っていないことの確認★
    expect(res.overall_completion_rate).not.toBeCloseTo((802 / 1200) * 100, 3);
  });

  it("★履歴の行が無いエリアと正常終了のエリアが混在する日(施設5)★", () => {
    const rows = groupOf(5, 1);
    expect(rows).toHaveLength(2);

    const noHistory = rows.find((r) => r.completion_rate === null);
    expect(noHistory).toBeDefined();
    // 履歴が無いので分母にも分子にも載らない
    expect(noHistory?.task_area).toBe(0);
    expect(noHistory?.clean_area).toBe(0);

    const res = buildAreaCompletions(rows, "all");
    expect(res.overall_completion_rate).toBe(97);
    expect(res.overall_grade).toBe("◎");
    expect(res.total_count).toBe(2);
    expect(res.problem_count).toBe(1); // 履歴なし = judgeGrade(null) = ×
    // 未実施が先頭
    expect(res.items[0].completion_rate).toBeNull();
  });

  it("★全エリア未実施の日(施設12)は全体率が null★", () => {
    const rows = groupOf(12, 0);
    expect(rows).toHaveLength(1);
    const res = buildAreaCompletions(rows, "all");
    expect(res.overall_completion_rate).toBeNull();
    expect(res.overall_grade).toBe("×");
  });

  it.each([
    { facility_id: 1, daysBack: 0, overall: 90.1, grade: "◎" },
    // ★D-139(2026-09-16): 境界は上のランクに入る。90.0 は ◎、70.0 は ○★
    { facility_id: 2, daysBack: 0, overall: 90.0, grade: "◎" },
    { facility_id: 5, daysBack: 0, overall: 70.0, grade: "○" },
    { facility_id: 8, daysBack: 0, overall: 10.0, grade: "△" },
    { facility_id: 10, daysBack: 0, overall: 0, grade: "×" },
  ])(
    "境界値: 施設$facility_id の $daysBack 日前は 全体 $overall% = $grade",
    ({ facility_id, daysBack, overall, grade }) => {
      const res = buildAreaCompletions(groupOf(facility_id, daysBack), "all");
      expect(res.overall_completion_rate).toBeCloseTo(overall, 6);
      expect(res.overall_grade).toBe(grade);
    }
  );

  it("★全エリアが◎○の日(施設1の1日前)は filter=problem で空配列★", () => {
    const rows = groupOf(1, 1);
    const res = buildAreaCompletions(rows, "problem");
    expect(res.items).toEqual([]);
    expect(res.total_count).toBe(1);
    expect(res.problem_count).toBe(0);
  });

  it("section_name はシードと同じ形(実在のエリア名を入れない)", () => {
    for (const row of areaCompletions) {
      expect(row.section_name).toBe(`清掃エリア(ロボット${row.section_id})`);
    }
  });
});

/**
 * ★`cleanLogs[].task_status` と `cleanTaskReports[].status` の一致★(指示書AQ §5-2 / docs/fixtures.md §2.6)。
 *
 * ★ここに置いている理由★: 担当J2 の時点では `lib/fixtures/clean-logs.ts` が循環 import を避けるため
 *   `cleanTaskReportStatus()` の規則を★写しとして★持っており、その写しのズレを検出するためにここへ置いた
 *   (本来は `lib/fixtures/__tests__/clean-task-reports.test.ts` の領分だが、担当J2 の所有範囲外だった)。
 * ★その後(担当M)★: 規則は `lib/fixtures/task-status-rule.ts` の1箇所に切り出し、両ファイルがそこから
 *   import する形になった(写しは削除済み)。★ルールが1箇所になっても、2経路の値が一致することの確認として
 *   期待値を変えずに残している★(例: どちらかが引数に別の id を渡し始めたら落ちる)。
 */
describe("fixtures: cleanLogs.task_status = cleanTaskReports.status", () => {
  const statusByReportId = new Map(cleanTaskReports.map((r) => [r.report_id, r.status]));

  it("★実施済みの清掃は同じ清掃 id のレポート行と同じ status、未実施は null★", () => {
    for (const log of cleanLogs) {
      const expected = statusByReportId.get(cleanTaskReportId(log.id)) ?? null;
      expect(log.task_status, `clean_log id=${log.id}`).toBe(expected);
    }
  });

  it("★不変条件: `task_status === null` ⟺ `departed === false`★(未実施はレポートの行が無い。指示書AQ §1-1)", () => {
    for (const log of cleanLogs) {
      expect(log.task_status === null, `clean_log id=${log.id}`).toBe(!log.departed);
    }
  });

  it("status 4種(3 / 4 / 5 / 6)がすべて画面に出る(塊になっていない)", () => {
    const seen = new Set(cleanLogs.map((l) => l.task_status).filter((v) => v !== null));
    expect([...seen].sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([3, 4, 5, 6]);
  });
});