Newer
Older
client_dwatch / lib / db / __tests__ / fixture-clean-area.test.ts
import { describe, expect, it } from "vitest";

import { aggregateCleanArea } from "@/lib/clean-area/aggregate";
import { cleanAreaStatsFromFixtureAreas } from "@/lib/db/fixture-clean-area";
import { areaCompletions, dateOnly, facilities } from "@/lib/fixtures";
import type { AreaCompletionRow, CleanAreaDbQuery } from "@/lib/types";

/**
 * 指示書AN ★段階3★ §6-4 —
 * fixtures 版の集計材料づくり。
 *
 * ★★`USE_FIXTURES` の true/false で数字が変わってはいけない★★(指示書AC 禁止事項6)ので、
 *   Prisma 版の `where`(`started_at` の範囲・`facility_id IN`)と ★同じ意味★ を固定する。
 */

function query(overrides: Partial<CleanAreaDbQuery> = {}): CleanAreaDbQuery {
  return { date_from: null, date_to: null, facility_ids: null, ...overrides };
}

function areaRow(
  facilityId: number,
  date: string,
  cleanArea: number,
  taskArea: number,
  completionRate: number | null = 50
): AreaCompletionRow {
  return {
    clean_log_id: facilityId * 1000,
    facility_id: facilityId,
    date,
    section_id: facilityId,
    section_name: `清掃エリア(ロボット${facilityId})`,
    completion_rate: completionRate,
    clean_area: cleanArea,
    task_area: taskArea,
    result_pending: false,
  };
}

describe("cleanAreaStatsFromFixtureAreas(指示書AN 段階3 §6-4)", () => {
  it("施設ごとに合算し、行数を `executed_count` にする", () => {
    const stats = cleanAreaStatsFromFixtureAreas(
      [
        areaRow(1, "2026-09-01", 100, 200),
        areaRow(1, "2026-09-02", 50.5, 100),
        areaRow(2, "2026-09-01", 10, 20),
      ],
      query()
    );
    // ★`clean_ratio_sum` / `clean_ratio_count` は1回ごとの完了率(面積から `runCompletionRate()`)の
    //   合計と件数★(D-155。100/200 → 50.0、50.5/100 → 50.5、10/20 → 50.0)
    expect(stats).toEqual([
      {
        facility_id: 1,
        clean_area: 150.5,
        task_area: 300,
        executed_count: 2,
        clean_ratio_sum: 50 + 50.5,
        clean_ratio_count: 2,
      },
      {
        facility_id: 2,
        clean_area: 10,
        task_area: 20,
        executed_count: 1,
        clean_ratio_sum: 50,
        clean_ratio_count: 1,
      },
    ]);
  });

  it("★★`completion_rate` が `null` の行は数えない★★(実DBには履歴の行が存在しない)", () => {
    const stats = cleanAreaStatsFromFixtureAreas(
      [
        areaRow(1, "2026-09-01", 100, 200),
        // ★出発失敗・未実施(`lib/fixtures/areas.ts` は面積を 0 に強制している)★
        areaRow(1, "2026-09-02", 0, 0, null),
      ],
      query()
    );
    // ★面積は変わらないが `executed_count` が静かに1増える★のがこのテストの防いでいる事故
    //   ★完了率の分母(`clean_ratio_count`)も同じく増えてはいけない★(指示書AQ §5-1 で追加されたフィールド)
    expect(stats).toEqual([
      {
        facility_id: 1,
        clean_area: 100,
        task_area: 200,
        executed_count: 1,
        clean_ratio_sum: 50,
        clean_ratio_count: 1,
      },
    ]);
  });

  it("★`date_from` / `date_to` はその日を**含む**★(`dbDateRange()` と同じ契約)", () => {
    const rows = [
      areaRow(1, "2026-08-31", 1, 1),
      areaRow(1, "2026-09-01", 10, 10),
      areaRow(1, "2026-09-03", 100, 100),
      areaRow(1, "2026-09-04", 1000, 1000),
    ];
    const stats = cleanAreaStatsFromFixtureAreas(
      rows,
      query({ date_from: "2026-09-01", date_to: "2026-09-03" })
    );
    // ★`clean_ratio_*` は1回ごとの完了率の合計と件数(10/10 → 100.0、100/100 → 100.0)★
    expect(stats).toEqual([
      {
        facility_id: 1,
        clean_area: 110,
        task_area: 110,
        executed_count: 2,
        clean_ratio_sum: 200,
        clean_ratio_count: 2,
      },
    ]);
  });

  it("★★完了率の材料は面積から `runCompletionRate()` で作る★★(行の `completion_rate` を足さない。期間の面積比でもない。D-155)", () => {
    // ★期間の面積比(Σclean_area÷Σtask_area = 150/400 = 37.5%)・行の completion_rate の平均((90+30)/2 = 60%)・
    //   1回ごとの完了率の平均((100.0 + 16.6)/2 = 58.3%)が食い違う入力★にして、どれで畳んだかを区別する
    const stats = cleanAreaStatsFromFixtureAreas(
      [areaRow(1, "2026-09-01", 100, 100, 90), areaRow(1, "2026-09-02", 50, 300, 30)],
      query()
    );
    expect(stats[0].clean_ratio_sum).toBeCloseTo(100 + 16.6, 10);
    expect(stats[0].clean_ratio_count).toBe(2);
    // ★`aggregateCleanArea()` に通すと単純平均になる★(★NaN にならないこと★ — 担当L 申し送りの事故)
    const total = aggregateCleanArea(
      stats,
      [
        {
          id: 1,
          facility_name: "施設1",
          customer_id: 1,
          customer_name: "顧客1",
          group_id: null,
          group_name: null,
        },
      ],
      "facility"
    ).total;
    expect(total.completion_rate).toBeCloseTo(58.3, 10);
  });

  it("★`task_area = 0` の行は実施回数に入り、完了率の件数にも入る(0% として)★(P-5=B。Prisma 版と同じ)", () => {
    const stats = cleanAreaStatsFromFixtureAreas(
      [areaRow(1, "2026-09-01", 10, 0, 80), areaRow(1, "2026-09-02", 50, 100, 50)],
      query()
    );
    expect(stats[0].executed_count).toBe(2);
    expect(stats[0].clean_ratio_sum).toBe(50);
    expect(stats[0].clean_ratio_count).toBe(2);
  });

  it("期間を指定しなければ全期間(★上限を設けない★。§2-3)", () => {
    const rows = [areaRow(1, "2024-01-01", 1, 1), areaRow(1, "2026-09-01", 2, 2)];
    expect(cleanAreaStatsFromFixtureAreas(rows, query())[0].executed_count).toBe(2);
  });

  it("★★`facility_ids: []` は0件★★(`null`=絞らない と別物。罠1 / 落とし穴3)", () => {
    const rows = [areaRow(1, "2026-09-01", 10, 10), areaRow(2, "2026-09-01", 20, 20)];
    expect(cleanAreaStatsFromFixtureAreas(rows, query({ facility_ids: [] }))).toEqual([]);
    // ★`null` のときだけ全件★(ここが逆になると「0件ヒット」が「全件」に化ける)
    expect(
      cleanAreaStatsFromFixtureAreas(rows, query({ facility_ids: null }))
    ).toHaveLength(2);
    expect(
      cleanAreaStatsFromFixtureAreas(rows, query({ facility_ids: [2] })).map(
        (s) => s.facility_id
      )
    ).toEqual([2]);
  });
});

describe("fixtures の面積を `aggregateCleanArea()` に通す(`CLAUDE.md` §9.5)", () => {
  /** `fixtureSource.cleanArea()` が渡すのと同じ施設マスタ */
  const masters = facilities.map((f) => ({
    id: f.id,
    facility_name: f.facility_name,
    customer_id: f.customer_id,
    customer_name: f.customer_name,
    group_id: f.group_id,
    group_name: f.group_name,
  }));

  const stats = cleanAreaStatsFromFixtureAreas(areaCompletions, query());

  it("★面積を持つ fixtures が実際に集計される★(0件だと画面が空のままでも気づけない)", () => {
    expect(stats.length).toBeGreaterThan(0);
    const total = aggregateCleanArea(stats, masters, "facility").total;
    expect(total.clean_area).toBeGreaterThan(0);
    expect(total.task_area).toBeGreaterThan(0);
  });

  it("★★粒度を変えても全体合計が変わらない★★(§5-3 B-5 / 落とし穴4)", () => {
    const byGroup = aggregateCleanArea(stats, masters, "group");
    const byCustomer = aggregateCleanArea(stats, masters, "customer");
    const byFacility = aggregateCleanArea(stats, masters, "facility");
    expect(byGroup.total).toEqual(byFacility.total);
    expect(byCustomer.total).toEqual(byFacility.total);
  });

  it("★★fixtures 全体の完了率が数値になる★★(`clean_ratio_*` が欠けると `NaN` になる。担当L 申し送り)", () => {
    const total = aggregateCleanArea(stats, masters, "facility").total;
    expect(total.completion_rate).not.toBeNull();
    expect(Number.isFinite(total.completion_rate)).toBe(true);
  });

  it("★★`runCompletionRate()` 経由でも fixtures の完了率と数字が変わらない★★(`USE_FIXTURES` の true/false で同じ。指示書AS 4-3)", () => {
    // ★fixtures の面積は行の完了率から作ってある(`lib/fixtures/areas.ts`)★ので、
    //   面積から作った Σ・件数は、行の `completion_rate` をそのまま足した Σ・件数と一致するはず
    for (const stat of stats) {
      const rows = areaCompletions.filter(
        (r) => r.facility_id === stat.facility_id && r.completion_rate !== null
      );
      const expectedSum = rows.reduce((sum, r) => sum + (r.completion_rate ?? 0), 0);
      expect(stat.clean_ratio_count).toBe(rows.length);
      expect(stat.clean_ratio_sum).toBeCloseTo(expectedSum, 9);
    }
  });

  it("★施設マスタに無い `facility_id` が出ない★(fixtures の施設はすべてマスタにある)", () => {
    expect(aggregateCleanArea(stats, masters, "facility").unmatched_facility_ids).toEqual(
      []
    );
  });

  it("★期間で絞ると合計が減る★(当日だけに絞ると全期間より小さい)", () => {
    const today = cleanAreaStatsFromFixtureAreas(
      areaCompletions,
      query({ date_from: dateOnly(0), date_to: dateOnly(0) })
    );
    const all = aggregateCleanArea(stats, masters, "facility").total;
    const todayTotal = aggregateCleanArea(today, masters, "facility").total;
    expect(todayTotal.clean_area).toBeLessThan(all.clean_area);
    expect(todayTotal.clean_area).toBeGreaterThan(0);
  });
});