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