Newer
Older
client_dwatch / lib / clean-logs / __tests__ / areas.test.ts
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;
}): 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),
  };
}

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