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