import { describe, expect, it } from "vitest";

import {
  CLEAN_AREA_GROUP_NONE_LABEL,
  aggregateCleanArea,
} from "@/lib/clean-area/aggregate";
// ★型は `lib/types/` に集約した★（段階4。`CLAUDE.md` §9.2）
import type { CleanAreaFacilityMaster, CleanAreaFacilityStats } from "@/lib/types";

/**
 * 指示書AN §5-3 の期待値表（★テストが先★。`CLAUDE.md` §6-2 / §9.4）。
 *
 * **A群: 合計そのもの**
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | A-1 | 0件 | `clean_area: 0` / `task_area: 0` / ★`completion_rate: null`★（★0% ではない★） |
 * | A-2 | `_sum` が `null`（Prisma の0件時） | ★`?? 0` で 0★。★`null` が画面に漏れない★ |
 * | A-3 | レポート0件の施設だけ（＝未実施） | 合計0 / ★`completion_rate: null`★ |
 * | A-4 | `clean_area > 0` かつ `task_area = 0` | ★完了率は渡された Σ（1回ごとの完了率の合計）と件数から出る★（★面積の分母0は完了率に効かない★。`∞` / `NaN` にしない） |
 * | A-5 | 小数を持つ行の合計 | 厳密値と一致（§1-3。★誤差が出たら実装が壊れた合図★） |
 *
 * **B群: 組織のロールアップ**
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | B-1 | 1グループ → 1屋号 → 3店舗 | ★グループ合計 ＝ 屋号合計 ＝ 3店舗の和★ |
 * | B-2 | 1グループ → 2屋号 → 各2店舗 | ★各階層で和が一致★ |
 * | B-3 | `group_id = null` の顧客 | ★「グループなし」1行にまとめる★。★「グループ0」を作らない★。★全体合計から落とさない★ |
 * | B-4 | 施設マスタに無い `facility_id` | ★落ちないこと★（→ 実装の判断は `aggregate.ts` の docstring） |
 * | B-5 | 全体合計 | ★★粒度を変えても全体合計は変わらない★★（★最重要★） |
 *
 * **C群: 並び**（「もっと表示」は `visibility.test.ts`）
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | C-1 | 面積がばらばら | ★`clean_area` の降順★ |
 * | C-2 | 同じ面積が2件 | ★IDの昇順★（★安定した順序★） |
 *
 * **D群: 完了率 ＝ 1回ごとの完了率の単純平均**（★2026-09-30 追加。指示書AQ 決定4 / §5-1★。
 * ★2026-10-02 D-155 / 指示書AS: 平均する材料が `clean_ratio` 列から `runCompletionRate()` の戻りに変わった。
 * このテストは集計済みの Σ と件数（型の名前 `clean_ratio_sum` / `clean_ratio_count` は据え置き。D7）を直接渡すので、
 * 表の期待値は変わらない★）
 * | # | 入力（Σclean_ratio / 件数・面積） | 期待 |
 * |---|---|---|
 * | D-1 | 施設101: 150 / 2件 | 75（清掃1回＝1票） |
 * | D-2 | 101: 200 / 2件（平均100）＋ 102: 0 / 1件（平均0）を屋号で束ねる | ★66.7…（200/3）★。★平均の平均（50）にしない★ |
 * | D-3 | 101: 61 / 1件、面積 1628/1000（面積比162.8%） | ★61★（★面積比にしない。CSV実測の乖離例★） |
 * | D-4 | 件数0・面積あり | ★`null`★（面積から完了率を作らない） |
 * | D-5 | 179 / 2件 | ★89.5（表示90%だが判定は ○）★。丸めない |
 * | D-6 | 面積が大きい施設が低率 | ★面積で重み付けしない★（大面積 10% ＋ 小面積 90% → 50） |
 * | D-7 | マスタに無い施設の `clean_ratio` | ★`total` にも入らない★（B-4 と同じ扱い） |
 */

/**
 * テスト用の施設マスタ。
 *
 * ```
 * グループ1 ホールディングスA ─ 屋号10 ブランドX ─ 施設 101 / 102 / 103
 *                            └ 屋号11 ブランドY ─ 施設 111 / 112
 * （グループなし）            ─ 屋号20 ブランドZ ─ 施設 201
 * ```
 */
const MASTERS: CleanAreaFacilityMaster[] = [
  {
    id: 101,
    facility_name: "店舗101",
    customer_id: 10,
    customer_name: "ブランドX",
    group_id: 1,
    group_name: "ホールディングスA",
  },
  {
    id: 102,
    facility_name: "店舗102",
    customer_id: 10,
    customer_name: "ブランドX",
    group_id: 1,
    group_name: "ホールディングスA",
  },
  {
    id: 103,
    facility_name: "店舗103",
    customer_id: 10,
    customer_name: "ブランドX",
    group_id: 1,
    group_name: "ホールディングスA",
  },
  {
    id: 111,
    facility_name: "店舗111",
    customer_id: 11,
    customer_name: "ブランドY",
    group_id: 1,
    group_name: "ホールディングスA",
  },
  {
    id: 112,
    facility_name: "店舗112",
    customer_id: 11,
    customer_name: "ブランドY",
    group_id: 1,
    group_name: "ホールディングスA",
  },
  // ★ホールディングスに属さない屋号★（`customers.group_id = 0` を `groupIdFromDb()` が
  //   `null` に変換したあとの形。`lib/format/group-id.ts`）
  {
    id: 201,
    facility_name: "店舗201",
    customer_id: 20,
    customer_name: "ブランドZ",
    group_id: null,
    group_name: null,
  },
];

/**
 * 施設ごとの集計値を作る。
 *
 * ★`clean_ratio_count` は `executed_count` と同じ（`clean_ratio` は NOT NULL なので、
 *   レポート1行 = 1件）★。★`clean_ratio_sum` の既定は「1件あたり 80%」★ —
 *   完了率を見ないテストで値を考えなくて済むようにするため（★D群では明示する★）。
 */
function stats(
  facility_id: number,
  clean_area: number | null,
  task_area: number | null,
  executed_count = 1,
  clean_ratio_sum: number | null = 80 * executed_count
): CleanAreaFacilityStats {
  return {
    facility_id,
    clean_area,
    task_area,
    executed_count,
    clean_ratio_sum,
    clean_ratio_count: executed_count,
  };
}

describe("aggregateCleanArea — A群: 合計そのもの", () => {
  it("A-1 0件: clean_area 0 / task_area 0 / ★completion_rate は null（0% ではない）★", () => {
    for (const scope of ["group", "customer", "facility"] as const) {
      const result = aggregateCleanArea([], MASTERS, scope);

      expect(result.items).toEqual([]);
      expect(result.total.clean_area).toBe(0);
      expect(result.total.task_area).toBe(0);
      // ★★0% ではなく null★★ — 「対象面積が0」と「1㎡も清掃できなかった」は別
      expect(result.total.completion_rate).toBeNull();
      expect(result.total.executed_count).toBe(0);
    }
  });

  it("A-2 `_sum` が null（Prisma の0件時）でも 0 になり、★null が画面に漏れない★", () => {
    // ★`clean_ratio` の `_sum` も 0件で null になる★
    const result = aggregateCleanArea(
      [stats(101, null, null, 0, null)],
      MASTERS,
      "facility"
    );

    expect(result.items).toHaveLength(1);
    expect(result.items[0].clean_area).toBe(0);
    expect(result.items[0].task_area).toBe(0);
    expect(result.items[0].completion_rate).toBeNull();
    expect(result.total.clean_area).toBe(0);
    expect(result.total.task_area).toBe(0);
  });

  it("A-3 レポート0件の施設だけ（＝未実施）: 合計0 / completion_rate は null", () => {
    const result = aggregateCleanArea(
      [stats(101, 0, 0, 0), stats(102, 0, 0, 0)],
      MASTERS,
      "facility"
    );

    expect(result.total.clean_area).toBe(0);
    expect(result.total.task_area).toBe(0);
    expect(result.total.completion_rate).toBeNull();
    for (const item of result.items) {
      expect(item.completion_rate).toBeNull();
    }
  });

  it("A-4 ★clean_area > 0 かつ task_area = 0★: 完了率は渡された Σ と件数から出る（∞ / NaN にしない）", () => {
    const result = aggregateCleanArea([stats(101, 120, 0, 1, 95)], MASTERS, "facility");

    expect(result.items[0].clean_area).toBe(120);
    expect(result.items[0].task_area).toBe(0);
    // ★面積の分母0は完了率に効かない★（完了率は面積から作らない。指示書AQ 決定4）
    expect(result.items[0].completion_rate).toBe(95);
    expect(result.total.completion_rate).toBe(95);
    expect(Number.isFinite(result.items[0].completion_rate ?? NaN)).toBe(true);
  });

  it("A-5 小数を持つ行の合計が厳密値と一致する（§1-3。★誤差が出たら実装が壊れた合図★）", () => {
    // ★2進で厳密に表せる小数を使う★ — 丸め誤差が出ないのが正しい状態なので、
    //   期待値の方を「だいたい一致」に緩めない（緩めると壊れても気づけない）
    const result = aggregateCleanArea(
      [
        stats(101, 12.5, 25.0, 1, 50.5),
        stats(102, 0.25, 0.5, 1, 49.25),
        stats(103, 1.125, 2.25, 2, 100.25),
      ],
      MASTERS,
      "facility"
    );

    expect(result.total.clean_area).toBe(13.875);
    expect(result.total.task_area).toBe(27.75);
    // ★(50.5 + 49.25 + 100.25) ÷ 4件 = 50★（面積比でも 50 になるが、ここは Σ の厳密さを見る）
    expect(result.total.completion_rate).toBe(50);
  });

  it("★completion_rate は丸めない生値★（`CLAUDE.md` §4.3 / §9.3）", () => {
    const result = aggregateCleanArea([stats(101, 1, 3, 3, 100)], MASTERS, "facility");

    // 33.333... をそのまま返す（表示層で丸める）
    expect(result.items[0].completion_rate).toBeCloseTo(100 / 3, 10);
    expect(result.items[0].completion_rate).not.toBe(33);
  });
});

describe("aggregateCleanArea — B群: 組織のロールアップ", () => {
  it("B-1 1グループ → 1屋号 → 3店舗: グループ合計 ＝ 屋号合計 ＝ 3店舗の和", () => {
    const input = [
      stats(101, 100, 200, 2, 100),
      stats(102, 150, 300, 3, 150),
      stats(103, 50, 100, 1, 50),
    ];

    const byFacility = aggregateCleanArea(input, MASTERS, "facility");
    const byCustomer = aggregateCleanArea(input, MASTERS, "customer");
    const byGroup = aggregateCleanArea(input, MASTERS, "group");

    expect(byFacility.items).toHaveLength(3);
    expect(byCustomer.items).toHaveLength(1);
    expect(byGroup.items).toHaveLength(1);

    expect(byCustomer.items[0].clean_area).toBe(300);
    expect(byCustomer.items[0].task_area).toBe(600);
    expect(byCustomer.items[0].executed_count).toBe(6);
    expect(byGroup.items[0].clean_area).toBe(300);
    expect(byGroup.items[0].task_area).toBe(600);
    expect(byGroup.items[0].executed_count).toBe(6);
    expect(byGroup.items[0].completion_rate).toBe(50);
  });

  it("B-2 1グループ → 2屋号 → 各2店舗: 各階層で和が一致する", () => {
    const input = [
      stats(101, 10, 20),
      stats(102, 20, 40),
      stats(111, 30, 60),
      stats(112, 40, 80),
    ];

    const byFacility = aggregateCleanArea(input, MASTERS, "facility");
    const byCustomer = aggregateCleanArea(input, MASTERS, "customer");
    const byGroup = aggregateCleanArea(input, MASTERS, "group");

    expect(byFacility.items).toHaveLength(4);
    expect(byCustomer.items).toHaveLength(2);
    expect(byGroup.items).toHaveLength(1);

    const sum = (rows: readonly { clean_area: number }[]) =>
      rows.reduce((acc, row) => acc + row.clean_area, 0);

    expect(sum(byFacility.items)).toBe(100);
    expect(sum(byCustomer.items)).toBe(100);
    expect(sum(byGroup.items)).toBe(100);

    // 屋号10 = 10+20 / 屋号11 = 30+40（★降順なので屋号11が先頭★）
    expect(byCustomer.items.map((row) => [row.id, row.clean_area])).toEqual([
      [11, 70],
      [10, 30],
    ]);
  });

  it("B-3 ★group_id = null の顧客は「グループなし」1行にまとまる★（「グループ0」を作らない）", () => {
    const input = [stats(101, 100, 200), stats(201, 60, 100)];

    const byGroup = aggregateCleanArea(input, MASTERS, "group");

    expect(byGroup.items).toHaveLength(2);
    const none = byGroup.items.find((row) => row.id === null);
    expect(none).toBeDefined();
    expect(none?.name).toBe(CLEAN_AREA_GROUP_NONE_LABEL);
    expect(none?.group_id).toBeNull();
    expect(none?.group_name).toBeNull();
    expect(none?.clean_area).toBe(60);
    // ★「グループ0」を作らない★ — センチネル（D-011）を画面に出さない
    expect(byGroup.items.some((row) => row.id === 0)).toBe(false);

    // ★★その屋号の面積が全体合計から落ちないこと★★（落とし穴5）
    expect(byGroup.total.clean_area).toBe(160);
    expect(
      byGroup.items.reduce((acc, row) => acc + row.clean_area, 0)
    ).toBe(160);
  });

  it("B-3' 「グループなし」の屋号が複数あっても1行にまとまる", () => {
    const masters: CleanAreaFacilityMaster[] = [
      ...MASTERS,
      {
        id: 202,
        facility_name: "店舗202",
        customer_id: 21,
        customer_name: "ブランドW",
        group_id: null,
        group_name: null,
      },
    ];

    const byGroup = aggregateCleanArea(
      [stats(201, 10, 20), stats(202, 30, 40)],
      masters,
      "group"
    );

    expect(byGroup.items).toHaveLength(1);
    expect(byGroup.items[0].id).toBeNull();
    expect(byGroup.items[0].clean_area).toBe(40);
    // ★屋号は1つに決まらないので null★（`aggregate.ts` の docstring）
    expect(byGroup.items[0].customer_id).toBeNull();
    expect(byGroup.items[0].customer_name).toBeNull();

    // 屋号の粒度なら2行に分かれる
    const byCustomer = aggregateCleanArea(
      [stats(201, 10, 20), stats(202, 30, 40)],
      masters,
      "customer"
    );
    expect(byCustomer.items.map((row) => row.id)).toEqual([21, 20]);
    expect(byCustomer.items.every((row) => row.group_id === null)).toBe(true);
  });

  it("B-4 ★施設マスタに無い facility_id でも落ちない★（集計から除外し、件数を返す）", () => {
    const input = [stats(101, 100, 200, 2), stats(999, 70, 140, 1)];

    expect(() => aggregateCleanArea(input, MASTERS, "facility")).not.toThrow();

    for (const scope of ["group", "customer", "facility"] as const) {
      const result = aggregateCleanArea(input, MASTERS, scope);

      // ★マスタに無い施設は名前も所属も作れないので集計に入れない★
      //   （入れると「全体合計 ≠ 内訳の和」になり、どの粒度でも突き合わせできなくなる）
      expect(result.total.clean_area).toBe(100);
      expect(result.total.task_area).toBe(200);
      expect(result.total.executed_count).toBe(2);
      expect(
        result.items.reduce((acc, row) => acc + row.clean_area, 0)
      ).toBe(result.total.clean_area);

      // ★静かに消さない★ — 呼び出し側（段階3）が警告ログを出せるように件数を返す
      expect(result.unmatched_facility_ids).toEqual([999]);
    }
  });

  it("B-4' マスタに無い施設が無ければ unmatched_facility_ids は空", () => {
    const result = aggregateCleanArea(
      [stats(101, 1, 2)],
      MASTERS,
      "facility"
    );

    expect(result.unmatched_facility_ids).toEqual([]);
  });

  it("B-5 ★★粒度を変えても全体合計は変わらない★★（最重要）", () => {
    const input = [
      stats(101, 100, 200, 2),
      stats(102, 150.5, 300.25, 3),
      stats(103, 0, 0, 0),
      stats(111, 40, 80, 1),
      stats(112, null, null, 0),
      stats(201, 60, 100, 1),
    ];

    const totals = (["group", "customer", "facility"] as const).map(
      (scope) => aggregateCleanArea(input, MASTERS, scope).total
    );

    for (const total of totals) {
      expect(total).toEqual(totals[0]);
    }
    expect(totals[0].clean_area).toBe(350.5);
    expect(totals[0].task_area).toBe(680.25);
    expect(totals[0].executed_count).toBe(7);

    // ★どの粒度でも「内訳の和 ＝ 全体合計」★（落とし穴4 / 落とし穴5）
    for (const scope of ["group", "customer", "facility"] as const) {
      const result = aggregateCleanArea(input, MASTERS, scope);
      expect(
        result.items.reduce((acc, row) => acc + row.clean_area, 0)
      ).toBe(result.total.clean_area);
      expect(
        result.items.reduce((acc, row) => acc + row.task_area, 0)
      ).toBe(result.total.task_area);
      expect(
        result.items.reduce((acc, row) => acc + row.executed_count, 0)
      ).toBe(result.total.executed_count);
    }
  });

  it("★同じ施設が複数行で来ても合算する★（groupBy の結果が1行/施設でなくなった場合の保険）", () => {
    const result = aggregateCleanArea(
      [stats(101, 10, 20, 1), stats(101, 5, 10, 2)],
      MASTERS,
      "facility"
    );

    expect(result.items).toHaveLength(1);
    expect(result.items[0].clean_area).toBe(15);
    expect(result.items[0].task_area).toBe(30);
    expect(result.items[0].executed_count).toBe(3);
  });

  it("各粒度の行が scope / id / name / 所属を正しく持つ", () => {
    const input = [stats(101, 100, 200, 2), stats(201, 60, 100, 1)];

    const facility = aggregateCleanArea(input, MASTERS, "facility").items[0];
    expect(facility.scope).toBe("facility");
    expect(facility.id).toBe(101);
    expect(facility.name).toBe("店舗101");
    expect(facility.customer_id).toBe(10);
    expect(facility.customer_name).toBe("ブランドX");
    expect(facility.group_id).toBe(1);
    expect(facility.group_name).toBe("ホールディングスA");

    const customer = aggregateCleanArea(input, MASTERS, "customer").items[0];
    expect(customer.scope).toBe("customer");
    expect(customer.id).toBe(10);
    expect(customer.name).toBe("ブランドX");
    expect(customer.customer_id).toBe(10);
    expect(customer.customer_name).toBe("ブランドX");
    expect(customer.group_id).toBe(1);
    expect(customer.group_name).toBe("ホールディングスA");

    const group = aggregateCleanArea(input, MASTERS, "group").items[0];
    expect(group.scope).toBe("group");
    expect(group.id).toBe(1);
    expect(group.name).toBe("ホールディングスA");
    expect(group.group_id).toBe(1);
    expect(group.group_name).toBe("ホールディングスA");
    // ★グループには屋号が複数ぶら下がりうるので、1つに決まらない★
    expect(group.customer_id).toBeNull();
    expect(group.customer_name).toBeNull();
  });

  it("★実績が無い施設はマスタにあっても行を作らない★（集計対象の行だけ返す）", () => {
    const result = aggregateCleanArea([stats(101, 1, 2)], MASTERS, "facility");

    expect(result.items).toHaveLength(1);
    expect(result.items[0].id).toBe(101);
  });
});

describe("aggregateCleanArea — C群: 並び", () => {
  it("C-1 clean_area の降順に並ぶ", () => {
    const result = aggregateCleanArea(
      [stats(101, 10, 20), stats(102, 300, 400), stats(103, 50, 60)],
      MASTERS,
      "facility"
    );

    expect(result.items.map((row) => row.clean_area)).toEqual([300, 50, 10]);
    expect(result.items.map((row) => row.id)).toEqual([102, 103, 101]);
  });

  it("C-2 同じ面積が2件なら ID の昇順（★安定した順序★）", () => {
    const result = aggregateCleanArea(
      [stats(103, 100, 200), stats(101, 100, 200), stats(102, 100, 200)],
      MASTERS,
      "facility"
    );

    expect(result.items.map((row) => row.id)).toEqual([101, 102, 103]);
  });

  it("★面積が同じなら「グループなし」（id = null）は最後★（並びを決め切る）", () => {
    const result = aggregateCleanArea(
      [stats(101, 100, 200), stats(201, 100, 200)],
      MASTERS,
      "group"
    );

    expect(result.items.map((row) => row.id)).toEqual([1, null]);
  });

  it("★入力の順序を変えても結果の並びが変わらない★", () => {
    const input = [
      stats(101, 10, 20),
      stats(102, 10, 20),
      stats(111, 40, 50),
      stats(201, 40, 50),
    ];
    const reversed = [...input].reverse();

    expect(aggregateCleanArea(reversed, MASTERS, "facility").items).toEqual(
      aggregateCleanArea(input, MASTERS, "facility").items
    );
  });

  it("★純粋関数であること★（入力を書き換えない）", () => {
    const input = [stats(102, 10, 20), stats(101, 30, 40)];
    const snapshot = JSON.parse(JSON.stringify(input)) as unknown;
    const masters = [...MASTERS];

    aggregateCleanArea(input, masters, "facility");

    expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot);
    expect(masters).toEqual(MASTERS);
  });
});

describe("aggregateCleanArea — D群: 完了率 ＝ 1回ごとの完了率の単純平均（指示書AQ 決定4 / §5-1・D-155）", () => {
  it("D-1 施設単位: Σclean_ratio 150 / 2件 → 75（清掃1回＝1票）", () => {
    const result = aggregateCleanArea([stats(101, 100, 200, 2, 150)], MASTERS, "facility");
    expect(result.items[0].completion_rate).toBe(75);
    expect(result.total.completion_rate).toBe(75);
  });

  it("★D-2 ロールアップは Σ と件数を合算してから割る（平均の平均にしない）★", () => {
    // 施設101: 100% × 2件 / 施設102: 0% × 1件（どちらも屋号10）
    const input = [stats(101, 10, 10, 2, 200), stats(102, 0, 10, 1, 0)];

    for (const scope of ["customer", "group"] as const) {
      const result = aggregateCleanArea(input, MASTERS, scope);
      expect(result.items).toHaveLength(1);
      expect(result.items[0].completion_rate).toBeCloseTo(200 / 3, 10);
      // ★施設ごとの平均（100 と 0）を平均すると 50 になる。★そうしない★
      expect(result.items[0].completion_rate).not.toBe(50);
    }
    expect(aggregateCleanArea(input, MASTERS, "facility").total.completion_rate).toBeCloseTo(
      200 / 3,
      10
    );
  });

  it("★D-3 面積比 162.8% の施設でも完了率は渡された Σ から出る 61（面積から作らない。CSV実測の乖離例）★", () => {
    // ★数値の出所: ユーザー提供CSV `clean_task_reports.csv` の乖離例（面積比 162.8% ↔ clean_ratio 61.0%）★
    //   ★D-155 以降、実データの1回ごとの完了率は `runCompletionRate()`（上限100）なので、この組み合わせ（61 と 1628/1000）は
    //   実データでは作られない★。ここは「集計は面積を読まず、渡された Σ と件数だけで割る」ことの確認として残してある。
    const result = aggregateCleanArea([stats(101, 1628, 1000, 1, 61)], MASTERS, "facility");
    expect(result.items[0].completion_rate).toBe(61);
    expect(result.items[0].completion_rate).not.toBeCloseTo(162.8, 5);
    // ★面積の合計は表示用にそのまま残る（指示書AQ §1-3）★
    expect(result.items[0].clean_area).toBe(1628);
    expect(result.items[0].task_area).toBe(1000);
  });

  it("★D-4 件数0なら面積があっても completion_rate は null（面積から完了率を作らない）★", () => {
    const result = aggregateCleanArea([stats(101, 900, 1000, 0, 0)], MASTERS, "facility");
    expect(result.items[0].completion_rate).toBeNull();
    expect(result.total.completion_rate).toBeNull();
  });

  it("★D-5 89.5 は丸めない（表示90%だが判定は ○。`CLAUDE.md` §4.3 / §9.4）★", () => {
    const result = aggregateCleanArea([stats(101, 1, 1, 2, 179)], MASTERS, "facility");
    expect(result.items[0].completion_rate).toBe(89.5);
    expect(result.items[0].completion_rate).not.toBe(90);
  });

  it("★D-6 面積で重み付けしない（大面積 10% ＋ 小面積 90% → 50）★", () => {
    // ★面積加重なら (1000×10 + 10×90) ÷ 1010 ≒ 10.8★
    const input = [stats(101, 100, 1000, 1, 10), stats(102, 9, 10, 1, 90)];
    const result = aggregateCleanArea(input, MASTERS, "customer");
    expect(result.items[0].completion_rate).toBe(50);
  });

  it("★D-7 マスタに無い施設の clean_ratio は total にも入らない（B-4 と同じ扱い）★", () => {
    const result = aggregateCleanArea(
      [stats(101, 1, 1, 1, 100), stats(999, 1, 1, 1, 0)],
      MASTERS,
      "facility"
    );
    expect(result.total.completion_rate).toBe(100);
    expect(result.unmatched_facility_ids).toEqual([999]);
  });
});
