import { describe, expect, it } from "vitest";
import {
buildAreaCompletions,
overallCompletionTally,
parseAreaFilter,
selectAreaRowsForLog,
} from "@/lib/clean-logs/areas";
import type { AreaCompletionRow } from "@/lib/types";
/**
* エリア別完了率(GET /clean-logs/{id}/areas)の期待値表テスト
*
* ★仕様の正★: `d-watch-api.yaml` の `/clean-logs/{id}/areas` / `CLAUDE.md` §4.1 §4.2 §4.3 /
* 指示書Z(`docs/handoff/指示書Z_エリア別完了率を実装する.md`)の決定3〜決定13。
*
* ★この表を変えたくなったら、先に YAML と CLAUDE.md を確認すること★
* (実装に合わせて期待値を書き換えない。D-041)。
*
* ★★とくに B のケース(面積が違う2台)を消さないこと★★ —
* 面積が同じデータだけだと、★面積加重に戻してもテストが緑のまま通ってしまう★。
*
* ★★2026-09-30: 全体率を面積比から `clean_ratio` 列の単純平均に変えた★★(指示書AQ 決定4 / §5-1)—
* ★2026-10-02(D-155 / 指示書AS): 平均する材料は `clean_ratio` 列から1回ごとの完了率(`runCompletionRate()`)に変わった。
* このテストの行の `completion_rate` がその値で、表の期待値は変わらない★
* ★レポート画面・F-08・R-3 と同じ `meanCleanRatio()`(`lib/judge/kpi.ts`)で畳む★。
* ★B / C の期待値はこのとき書き換えた★(ユーザー指示 2026-09-30。実装に合わせた書き換えではない)。
*/
/** 期待値表を読みやすくするための行ビルダ(`clean_area` は完了率と整合させる) */
function row(input: {
section_id: number;
completion_rate: number | null;
task_area?: number;
clean_area?: number;
facility_id?: number;
date?: string;
clean_log_id?: number;
/** ★結果待ち(D-173。指示書BB A3)。省略は false★ */
result_pending?: boolean;
}): AreaCompletionRow {
const taskArea = input.task_area ?? 0;
return {
clean_log_id: input.clean_log_id ?? 9000 + input.section_id,
facility_id: input.facility_id ?? 7,
date: input.date ?? "2026-09-10",
section_id: input.section_id,
section_name: `清掃エリア(ロボット${input.section_id})`,
completion_rate: input.completion_rate,
task_area: taskArea,
clean_area:
input.clean_area ??
(input.completion_rate === null
? 0
: (taskArea * input.completion_rate) / 100),
result_pending: input.result_pending ?? false,
};
}
// ---------------------------------------------------------------------------
// A. 並び順と絞り込み
//
// section_id completion_rate 期待 grade
// 101 95.0 ◎
// 102 42.5 △
// 103 null(未実施) ×
// 104 5.0 ×
// 105 70.0 ★○★ ← 「70%以上」が○なので 70.0 は ○(D-139。旧規則では △)
//
// filter=all の並び: 103(null) → 104(5.0) → 102(42.5) → 105(70.0) → 101(95.0)
// filter=problem の並び: 103 → 104 → 102(★105 は ○ になったので落ちる★ / 101 も落ちる)
// total_count : どちらの filter でも 5
// problem_count : どちらの filter でも ★3★(D-139 以前は 4)
// ---------------------------------------------------------------------------
const caseA: AreaCompletionRow[] = [
row({ section_id: 101, completion_rate: 95.0, task_area: 1000 }),
row({ section_id: 102, completion_rate: 42.5, task_area: 1000 }),
row({ section_id: 103, completion_rate: null, task_area: 0 }),
row({ section_id: 104, completion_rate: 5.0, task_area: 1000 }),
row({ section_id: 105, completion_rate: 70.0, task_area: 1000 }),
];
describe("buildAreaCompletions — A. 並び順と絞り込み", () => {
it("filter=all は完了率の昇順・未実施(null)が先頭", () => {
const res = buildAreaCompletions(caseA, "all");
expect(res.items.map((i) => i.section_id)).toEqual([103, 104, 102, 105, 101]);
expect(res.items.map((i) => i.grade)).toEqual(["×", "×", "△", "○", "◎"]);
});
it("filter=problem は △× だけを同じ並びで返す", () => {
const res = buildAreaCompletions(caseA, "problem");
// ★105(70.0)は D-139 で ○ になったので問題行から外れる★
expect(res.items.map((i) => i.section_id)).toEqual([103, 104, 102]);
});
it("total_count / problem_count は filter の前に数える", () => {
for (const filter of ["all", "problem"] as const) {
const res = buildAreaCompletions(caseA, filter);
expect(res.total_count).toBe(5);
expect(res.problem_count).toBe(3);
}
});
it("完了率が同値のときは section_id の昇順で安定する", () => {
const rows = [
row({ section_id: 202, completion_rate: 50 }),
row({ section_id: 201, completion_rate: 50 }),
row({ section_id: 204, completion_rate: null }),
row({ section_id: 203, completion_rate: null }),
];
const res = buildAreaCompletions(rows, "all");
expect(res.items.map((i) => i.section_id)).toEqual([203, 204, 201, 202]);
});
it("completion_rate と section_name は生値をそのまま返す(丸めない・整形しない)", () => {
const res = buildAreaCompletions(
[row({ section_id: 107, completion_rate: 42.5, task_area: 1000 })],
"all"
);
expect(res.items[0].completion_rate).toBe(42.5);
expect(res.items[0].section_name).toBe("清掃エリア(ロボット107)");
});
it("★入力の配列を書き換えない(並べ替えは複製に対して行う)★", () => {
const rows = [...caseA];
buildAreaCompletions(rows, "all");
expect(rows.map((r) => r.section_id)).toEqual([101, 102, 103, 104, 105]);
});
});
// ---------------------------------------------------------------------------
// B. 全体率は ★各行の完了率(1回ごとの完了率)の単純平均(清掃1回=1票)★(指示書AQ 決定4 / §5-1・D-155)
//
// section_id task_area clean_area completion_rate(行の完了率)
// 201 1000 500 50.0
// 202 100 100 100.0
//
// 単純平均(正) : (50 + 100) ÷ 2 = 75.0 → ○
// 面積ベース(廃止): 600 ÷ 1100 × 100 = 54.5454... → △
// ---------------------------------------------------------------------------
describe("buildAreaCompletions — B. 全体率は行の完了率の単純平均", () => {
const caseB: AreaCompletionRow[] = [
row({ section_id: 201, completion_rate: 50.0, task_area: 1000, clean_area: 500 }),
row({ section_id: 202, completion_rate: 100.0, task_area: 100, clean_area: 100 }),
];
it("★行の完了率の単純平均で出す(面積ベース 54.5… にならない)★", () => {
const res = buildAreaCompletions(caseB, "all");
expect(res.overall_completion_rate).toBe(75);
expect(res.overall_completion_rate).not.toBeCloseTo((600 / 1100) * 100, 5);
expect(res.overall_grade).toBe("○");
});
it("filter=problem でも全体率は全エリアから計算する", () => {
const problem = buildAreaCompletions(caseB, "problem");
const all = buildAreaCompletions(caseB, "all");
expect(problem.overall_completion_rate).toBe(all.overall_completion_rate);
expect(problem.total_count).toBe(2);
// 50.0 = △ だけが残る
expect(problem.items.map((i) => i.section_id)).toEqual([201]);
});
it("★行も全体も面積から再計算しない(行の完了率が面積と食い違うデータ)★", () => {
// ★以前は「行は clean_ratio・全体は面積」という意図した非対称だったが、解消した★
const res = buildAreaCompletions(
[row({ section_id: 301, completion_rate: 12.3, task_area: 1000, clean_area: 900 })],
"all"
);
expect(res.items[0].completion_rate).toBe(12.3);
expect(res.overall_completion_rate).toBe(12.3);
// ★面積比(90)にならない★
expect(res.overall_completion_rate).not.toBe(90);
});
it("★clean_area > task_area の行(CSV実測で 129/189 行)でも 100% を超えない★", () => {
// ★数値の出所: ユーザー提供CSV `clean_task_reports.csv` の乖離例(面積比 162.8% ↔ clean_ratio 61.0%)★
const res = buildAreaCompletions(
[row({ section_id: 302, completion_rate: 61.0, task_area: 1000, clean_area: 1628 })],
"all"
);
expect(res.overall_completion_rate).toBe(61);
expect(res.overall_grade).toBe("△");
});
});
// ---------------------------------------------------------------------------
// C. 全体率の境界と未実施(判定は丸める前の生値で行う。CLAUDE.md §4.3 / §9.4)
//
// ケース 行の clean_ratio overall_completion_rate overall_grade
// ちょうど90.0 [90.0] 90.0 ★◎(90%以上が◎。D-139)★
// 90.0のすぐ上 [90.1] 90.1 ◎
// ちょうど70.0 [70.0] 70.0 ★○(70%以上が○。D-139)★
// 70.0のすぐ上 [70.1] 70.1 ○
// ちょうど10.0 [10.0] 10.0 △(10%未満が×)
// 10.0のすぐ下 [9.9] 9.9 ×
// 0% [0] 0 ×
// 全エリア未実施 [null] null ×
// ★89.5(2台平均)★ [89, 90] 89.5 ★○(表示は90%だが判定は○)★
// ★90.4(1台)★ [90.4] 90.4 ★◎(表示は90%で判定も◎)★
// ---------------------------------------------------------------------------
describe("buildAreaCompletions — C. 全体率の境界と未実施", () => {
const table: Array<{
name: string;
rates: Array<number | null>;
overall: number | null;
grade: string;
}> = [
{ name: "ちょうど90.0", rates: [90.0], overall: 90.0, grade: "◎" },
{ name: "90.0のすぐ上", rates: [90.1], overall: 90.1, grade: "◎" },
{ name: "ちょうど70.0", rates: [70.0], overall: 70.0, grade: "○" },
{ name: "70.0のすぐ上", rates: [70.1], overall: 70.1, grade: "○" },
{ name: "ちょうど10.0", rates: [10.0], overall: 10.0, grade: "△" },
{ name: "10.0のすぐ下", rates: [9.9], overall: 9.9, grade: "×" },
{ name: "0%", rates: [0], overall: 0, grade: "×" },
{ name: "全エリア未実施", rates: [null], overall: null, grade: "×" },
{ name: "89.5(2台平均。表示90%だが○)", rates: [89, 90], overall: 89.5, grade: "○" },
{ name: "90.4(表示90%で◎)", rates: [90.4], overall: 90.4, grade: "◎" },
];
it.each(table)(
"$name → overall=$overall / grade=$grade",
({ rates, overall, grade }) => {
const res = buildAreaCompletions(
rates.map((rate, i) =>
row({
section_id: 401 + i,
completion_rate: rate,
task_area: rate === null ? 0 : 1000,
})
),
"all"
);
if (overall === null) {
expect(res.overall_completion_rate).toBeNull();
} else {
expect(res.overall_completion_rate).toBeCloseTo(overall, 10);
}
expect(res.overall_grade).toBe(grade);
}
);
it("★未実施(null)を 0% と混ぜない★ — 全エリア未実施のときだけ overall は null", () => {
const notExecuted = buildAreaCompletions(
[row({ section_id: 501, completion_rate: null, task_area: 0 })],
"all"
);
expect(notExecuted.items[0].completion_rate).toBeNull();
expect(notExecuted.overall_completion_rate).toBeNull();
const zeroPercent = buildAreaCompletions(
[row({ section_id: 502, completion_rate: 0, task_area: 1000, clean_area: 0 })],
"all"
);
expect(zeroPercent.items[0].completion_rate).toBe(0);
expect(zeroPercent.overall_completion_rate).toBe(0);
});
it("★未実施のエリアは平均の分母に入れない★(同じ日の実施エリアだけで全体率が決まる)", () => {
// 施設内に「レポート行なし(=未実施)」と「正常終了」が混在する日。
// ★0% として混ぜると 47.5 になる★(稼働率で既に数えている失敗を二重に数える)
const res = buildAreaCompletions(
[
row({ section_id: 601, completion_rate: null, task_area: 0, clean_area: 0 }),
row({ section_id: 602, completion_rate: 95.0, task_area: 1000, clean_area: 950 }),
],
"all"
);
expect(res.overall_completion_rate).toBe(95);
expect(res.overall_grade).toBe("◎");
expect(res.total_count).toBe(2);
expect(res.problem_count).toBe(1);
expect(res.items.map((i) => i.section_id)).toEqual([601, 602]);
});
it("★未実施行に面積が残っていても分母に入れない★(面積は読まない)", () => {
// ★DBの値をそのまま足す方針(決定13)なので、未実施でも面積が 0 とは限らない★
const res = buildAreaCompletions(
[
row({ section_id: 701, completion_rate: null, task_area: 1000, clean_area: 0 }),
row({ section_id: 702, completion_rate: 80.0, task_area: 1000, clean_area: 800 }),
],
"all"
);
expect(res.overall_completion_rate).toBe(80);
});
});
// ---------------------------------------------------------------------------
// D. エリア数
// ---------------------------------------------------------------------------
describe("buildAreaCompletions — D. エリア数", () => {
it("1エリアだけの施設でも1件返す(エラーにしない)", () => {
const res = buildAreaCompletions(
[row({ section_id: 11, completion_rate: 88, task_area: 1000 })],
"all"
);
expect(res.items).toHaveLength(1);
expect(res.total_count).toBe(1);
});
it("3エリアの施設は3件返す", () => {
const res = buildAreaCompletions(
[
row({ section_id: 21, completion_rate: 62, task_area: 1000 }),
row({ section_id: 22, completion_rate: 94, task_area: 100 }),
row({ section_id: 23, completion_rate: 88, task_area: 100 }),
],
"all"
);
expect(res.items).toHaveLength(3);
expect(res.total_count).toBe(3);
});
it("該当なし(全部◎○)のとき filter=problem は空配列。404 にしない", () => {
const rows = [
row({ section_id: 31, completion_rate: 95, task_area: 1000 }),
row({ section_id: 32, completion_rate: 80, task_area: 1000 }),
];
const res = buildAreaCompletions(rows, "problem");
expect(res.items).toEqual([]);
expect(res.total_count).toBe(2);
expect(res.problem_count).toBe(0);
expect(res.overall_completion_rate).toBeCloseTo(87.5, 10);
});
it("行が1件も無いとき(想定外だが落ちないこと)", () => {
const res = buildAreaCompletions([], "all");
expect(res.items).toEqual([]);
expect(res.total_count).toBe(0);
expect(res.problem_count).toBe(0);
expect(res.overall_completion_rate).toBeNull();
expect(res.overall_grade).toBe("×");
});
});
// ---------------------------------------------------------------------------
// E. selectAreaRowsForLog(施設 × 日付で取り出す。★その清掃ログ自身を含む★)
// ---------------------------------------------------------------------------
describe("overallCompletionTally — 全体率の分子・分母(指示書AR 段階4)", () => {
it("★分子は null でない行の完了率の合計、分母はその行数(未実施は数えない)★", () => {
// caseA: 95.0 / 42.5 / null / 5.0 / 70.0 → 分子 212.5・分母 4
expect(overallCompletionTally(caseA)).toEqual({ cleanRatioSum: 212.5, cleanRatioCount: 4 });
});
it("★`buildAreaCompletions()` の全体率 === 分子 ÷ 分母★(ログと画面が食い違わない)", () => {
const tally = overallCompletionTally(caseA);
expect(buildAreaCompletions(caseA, "problem").overall_completion_rate).toBe(
tally.cleanRatioSum / tally.cleanRatioCount
);
});
it("全行が未実施なら分母0(全体率は null)", () => {
const rows = [row({ section_id: 1, completion_rate: null })];
expect(overallCompletionTally(rows)).toEqual({ cleanRatioSum: 0, cleanRatioCount: 0 });
expect(buildAreaCompletions(rows, "all").overall_completion_rate).toBeNull();
});
});
describe("selectAreaRowsForLog", () => {
const rows: AreaCompletionRow[] = [
row({ section_id: 71, completion_rate: 62, facility_id: 7, date: "2026-09-10" }),
row({ section_id: 72, completion_rate: 94, facility_id: 7, date: "2026-09-10" }),
row({ section_id: 73, completion_rate: 88, facility_id: 7, date: "2026-09-09" }),
row({ section_id: 51, completion_rate: 97, facility_id: 5, date: "2026-09-10" }),
];
it("同じ施設・同じ日だけを返す(自分自身を含む)", () => {
const picked = selectAreaRowsForLog(rows, {
facility_id: 7,
date: "2026-09-10",
});
expect(picked.map((r) => r.section_id)).toEqual([71, 72]);
});
it("同じ施設でも違う日は含まない", () => {
const picked = selectAreaRowsForLog(rows, {
facility_id: 7,
date: "2026-09-09",
});
expect(picked.map((r) => r.section_id)).toEqual([73]);
});
it("同じ日でも違う施設は含まない", () => {
const picked = selectAreaRowsForLog(rows, {
facility_id: 5,
date: "2026-09-10",
});
expect(picked.map((r) => r.section_id)).toEqual([51]);
});
it("該当が無ければ空配列", () => {
expect(
selectAreaRowsForLog(rows, { facility_id: 99, date: "2026-09-10" })
).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// F. parseAreaFilter(既定は problem。不正値は 422)
// ---------------------------------------------------------------------------
describe("parseAreaFilter", () => {
function parse(qs: string) {
return parseAreaFilter(new URLSearchParams(qs));
}
it("指定なしは problem(既定)", () => {
const r = parse("");
expect(r.ok).toBe(true);
if (r.ok) expect(r.query).toBe("problem");
});
it.each(["problem", "all"] as const)("filter=%s はそのまま", (value) => {
const r = parse(`filter=${value}`);
expect(r.ok).toBe(true);
if (r.ok) expect(r.query).toBe(value);
});
it("不正値は 422 validation_failed", () => {
const r = parse("filter=critical");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.status).toBe(422);
expect(r.error.error.code).toBe("validation_failed");
expect(r.error.error.details).toEqual({
filter: ["problem / all のいずれかを指定してください"],
});
}
});
it("★空文字(filter=)も 422★ — parseCleanLogQuery の result と同じ扱いに揃える", () => {
const r = parse("filter=");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.status).toBe(422);
});
});
// ---------------------------------------------------------------------------
// E. 結果待ち(指示書BB A3・表6。D-173)
//
// 同じ施設・同じ日の4エリア:
// section_id レポート completion_rate result_pending 期待
// 1 (A) あり 95 false ◎
// 2 (B) あり 50 false △(問題)
// 3 (C) なし null ★true★ ×のまま・★問題に数えない★
// 4 (D) なし null false(待ち時間を過ぎた=未実施) ×(問題)
// ---------------------------------------------------------------------------
describe("buildAreaCompletions — E. 結果待ちは問題に数えない(指示書BB 表6)", () => {
const rows = [
row({ section_id: 1, completion_rate: 95, task_area: 100 }),
row({ section_id: 2, completion_rate: 50, task_area: 100 }),
row({ section_id: 3, completion_rate: null, result_pending: true }),
row({ section_id: 4, completion_rate: null, result_pending: false }),
];
it("A1: total_count は 4(結果待ちも数える)", () => {
expect(buildAreaCompletions(rows, "all").total_count).toBe(4);
});
it("★A2: problem_count は 2(B と D)。C(結果待ち)は数えない★", () => {
expect(buildAreaCompletions(rows, "problem").problem_count).toBe(2);
expect(buildAreaCompletions(rows, "all").problem_count).toBe(2);
});
it("★A3: filter=problem の items は B・D(C を出さない)★", () => {
expect(buildAreaCompletions(rows, "problem").items.map((i) => i.section_id).sort()).toEqual([2, 4]);
});
it("A4: filter=all の items は4件。C は result_pending: true・grade ×・completion_rate null(grade は変えない)", () => {
const c = buildAreaCompletions(rows, "all").items.find((i) => i.section_id === 3);
expect(c).toEqual({
section_id: 3,
section_name: "清掃エリア(ロボット3)",
completion_rate: null,
grade: "×",
result_pending: true,
});
});
it("A5: overall は 72.5 / ○(A と B の平均。C・D は null で分母に入らない)", () => {
const body = buildAreaCompletions(rows, "all");
expect(body.overall_completion_rate).toBe(72.5);
expect(body.overall_grade).toBe("○");
});
it("A6: 並びは今どおり(完了率の昇順・null が先頭・同値は section_id 昇順)→ C, D, B, A", () => {
expect(buildAreaCompletions(rows, "all").items.map((i) => i.section_id)).toEqual([3, 4, 2, 1]);
});
});