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]);
});
});