Newer
Older
client_dwatch / app / api / clean-logs / [id] / __tests__ / route.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts`
//   の冒頭コメントと同じ理由 — `it()` の中で `await import()` すると最初の1本が
//   モジュール読み込みを丸ごと抱え込み、フルスイートで不定期にタイムアウトする)。
import { GET as ADVICE } from "@/app/api/clean-logs/[id]/advice/route";
import { GET as AREAS } from "@/app/api/clean-logs/[id]/areas/route";
import { buildAreaCompletions, selectAreaRowsForLog } from "@/lib/clean-logs/areas";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { db } from "@/lib/db";
import { filterFixtureAreaCompletions } from "@/lib/db/fixture-area-completions";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { logEvent } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { __setAdviceStoreForTesting, type AdviceOverrides } from "@/lib/settings/advice";
import type { JsonStore } from "@/lib/settings/json-store";
import type { JudgeAreasLogEvent } from "@/lib/logger/events";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type {
  AreaCompletionRow,
  AreaCompletionsResponse,
  CleanLogAdviceResponse,
} from "@/lib/types";

/**
 * ★★指示書AC 段階3 の期待値表★★(`/clean-logs/{id}/areas` / `/clean-logs/{id}/advice`)。
 *
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | 1 | 存在する `{id}` | ★段階0(=全件読み + 純粋関数)と同じレスポンス★ |
 * | 2 | 存在しない `{id}` | ★段階0 と同じ 404★ |
 * | 3 | 同じ施設・同じ日に複数エリア | ★全エリアが返る★(`{id}` は「施設と日付の入口」。指示書Z 決定1) |
 * | 4 | `filter=problem` | ★`total_count` / `overall_*` は絞る**前**の値★(指示書Z 決定8 / 決定12) |
 * | 5 | 読み込み行数 | ★全件読みから数十行に減る★ |
 *
 * ------------------------------------------------------------------------
 * ★★「段階0 と同じ」をどう固定するか★★
 * ------------------------------------------------------------------------
 * ★期待値を数値で書き写さないこと★ — 書き写すと、実装とテストが同時にずれたときに
 *   気づけません。代わりに ★★同じデータに対する「全件版 + 純粋関数」を毎回その場で計算し、
 *   SQL で絞った結果と突き合わせます★★(指示書AC G-3「等価テスト」)。
 *   ★これが「レスポンスを1バイトも変えていない」ことの検出手段★(禁止事項14)。
 *
 * ------------------------------------------------------------------------
 * ★★DBには接続しません★★
 * ------------------------------------------------------------------------
 * `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に
 * 固定して ★`prismaSource` の経路★ を走らせます
 * (`app/api/clean-logs/__tests__/route.test.ts` と同じ作法)。
 */

// ---------------------------------------------------------------- スタブ(DBの代わり)

interface StubTimetable {
  id: bigint;
  facility_id: bigint;
  section_id: bigint;
  parent_id: bigint;
  start_at: Date;
  /** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */
  end_at: Date;
  precheck: number;
  /** ★`deleted_at` は省略可(省略は生きている)。削除済みの行を作るテストだけが入れる(指示書BB U1)★ */
  facility: { facility_name: string; deleted_at?: Date | null };
  section: {
    deleted_at?: Date | null;
    section_name: string;
    robots: {
      serial_number: string;
      device: { maker_id: number; model_name: string };
    }[];
  };
}

/**
 * `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。
 * ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
 * ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3)
 */
interface StubReport {
  clean_timetable_id: bigint | null;
  clean_ratio: number;
  /** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */
  status: number;
  clean_area: number;
  task_area: number;
  started_at: Date;
  ended_at: Date | null;
}

interface StubErrorReport {
  clean_timetable_id: bigint | null;
  error_type: string;
  errored_at: Date;
}

interface StubData {
  timetables: StubTimetable[];
  /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */
  historyTimetables?: StubTimetable[];
  reports: StubReport[];
  errorReports: StubErrorReport[];
  facilities: { id: bigint; facility_name: string; deleted_at: Date | null }[];
}

/** 発行されたクエリの記録(★どんな `where` が降りたか★ / ★何行読んだか★) */
interface CallRecord {
  model: string;
  args: Record<string, unknown>;
  rows: number;
}

let calls: CallRecord[] = [];
let data: StubData;

function sameId(a: unknown, b: unknown): boolean {
  if (a === null || a === undefined || b === null || b === undefined) return false;
  return String(a) === String(b);
}

/**
 * Prisma の `where` のうち★本作業が使う分だけ★を再現する。
 * ★`in: undefined` は例外にする★ — Prisma では「条件が消えて全件」になるため(罠1)。
 */
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
  if (!where) return true;
  for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
    const value = row[field];
    if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
      const c = cond as Record<string, unknown>;
      if ("in" in c) {
        if (c.in === undefined) {
          throw new Error(
            `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
          );
        }
        if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
      }
      if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
      if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
      // ★リレーションの `{ deleted_at: null }`(指示書BB U1)は、その行の `deleted_at`(省略は生きている)で見る★
      if ("deleted_at" in c && c.deleted_at === null) {
        const target =
          value !== null && typeof value === "object" ? ((value as { deleted_at?: Date | null }).deleted_at ?? null) : value;
        if (target !== null && target !== undefined) return false;
      }
    } else if (!sameId(value, cond) && value !== cond) {
      // ★`bigint` と `number` をまたいで比較できるようにする★(`id` / `facility_id` は BigInt 列)
      return false;
    }
  }
  return true;
}

function project(row: Record<string, unknown>, select: unknown): Record<string, unknown> {
  if (!select) return row;
  const out: Record<string, unknown> = {};
  for (const key of Object.keys(select as Record<string, unknown>)) out[key] = row[key];
  return out;
}

function findMany(
  model: string,
  rows: Record<string, unknown>[],
  args: Record<string, unknown> | undefined
) {
  const filtered = rows.filter((row) => matchesWhere(row, args?.where));
  calls.push({ model, args: args ?? {}, rows: filtered.length });
  return filtered.map((row) => project(row, args?.select));
}

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
// ★`api.error`(指示書BB 表8 T3)を数えるため `logApiError` を差し替える★
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));

// ★`judge.areas` ログの中身を見るため `logEvent` だけ差し替える★(指示書AR 段階4)。
//   ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない。`app/api/clean-area` のテストと同じ作法)
vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  return { ...actual, logEvent: vi.fn() };
});

vi.mock("@/lib/db/prisma", () => ({
  getPrisma: async () => ({
    cleanTimetable: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
    },
    // ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★
    cleanTimetableHistory: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "cleanTimetableHistory",
          (data.historyTimetables ?? []) as unknown as Record<string, unknown>[],
          args
        ),
    },
    cleanTaskReport: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
    },
    robotErrorReport: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], args),
    },
    facility: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("facility", data.facilities as unknown as Record<string, unknown>[], args),
    },
  }),
}));

// ---------------------------------------------------------------- 合成データ

/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
function dbWallClock(jstIso: string): Date {
  return new Date(jstIso.replace("+09:00", "Z"));
}

/** ★実在の店名・実在のエリア名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
const FACILITIES = [
  { id: 1, name: "ドソ・キホーテ戸塚" },
  { id: 2, name: "ドソ・キホーテ四街道" },
  { id: 3, name: "フレッシュタウソ大和店" },
];

/** ★1施設あたり3エリア★(= 同じ施設・同じ日に複数エリアが並ぶ。#3) */
const SECTIONS_PER_FACILITY = 3;

/** ★全件版が「明らかに多い」と言える量にする★(#5 の読み込み行数の比較に要る) */
const DAYS = 30;

/** 対象日(★最終日★)。全件版と単一ID版の突き合わせはこの日で行う */
const TARGET_DATE = "2026-09-30";

function dayIso(index: number): string {
  // 2026-09-01 から index 日ぶん進める(DAYS=30 なので 09-01〜09-30)
  const d = new Date(Date.UTC(2026, 8, 1 + index));
  return d.toISOString().slice(0, 10);
}

/**
 * `clean_timetables.id` の採番。★日とエリアから一意に決まる形にする★
 * (テスト側で「この日のこのエリアの id」を計算できるようにするため)。
 */
function timetableId(facilityId: number, dayIndex: number, sectionIndex: number): number {
  return facilityId * 100_000 + dayIndex * 10 + sectionIndex;
}

/**
 * 完了率。★エリアごとに変える★ —
 *   section 0 → 95(◎)/ section 1 → 5(×・問題)/ section 2 → `null`(未実施・問題)
 * ★#4(`filter=problem` で絞られる行と絞られない行)を両方作るため★。
 */
function ratioFor(sectionIndex: number): number | null {
  if (sectionIndex === 0) return 95;
  if (sectionIndex === 1) return 5;
  return null;
}

function buildData(): StubData {
  const timetables: StubTimetable[] = [];
  const reports: StubReport[] = [];
  const errorReports: StubErrorReport[] = [];

  for (const facility of FACILITIES) {
    for (let dayIndex = 0; dayIndex < DAYS; dayIndex++) {
      for (let s = 0; s < SECTIONS_PER_FACILITY; s++) {
        const id = timetableId(facility.id, dayIndex, s);
        timetables.push({
          id: BigInt(id),
          facility_id: BigInt(facility.id),
          section_id: BigInt(facility.id * 10 + s),
          parent_id: BigInt(0),
          // ★★壁時計 20:00 の清掃★★(`toDbJst()` の付け忘れを検出する。指示書AC C-6。
          //   ★ローカルDB(深夜0〜3時しかない)では検出できない時間帯★)
          start_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`),
          end_at: new Date(dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`).getTime() + 60 * 60_000),
          precheck: CLEAN_TIMETABLE_PRECHECK.main,
          facility: { facility_name: facility.name },
          section: {
            section_name: `エリア${facility.id}-${s + 1}`,
            robots: [
              {
                serial_number: `8111R5628060${facility.id}${s}`,
                device: { maker_id: 1, model_name: "CC1" },
              },
            ],
          },
        });

        const ratio = ratioFor(s);
        // ★★未実施(`ratio === null`)はレポートの行を作らない★★(指示書AQ §1-1。
        //   旧取得元の「`clean_error = 1` の行」に当たるものは `clean_task_reports` に無い)
        if (ratio === null) continue;
        reports.push({
          clean_timetable_id: BigInt(id),
          clean_ratio: ratio,
          // ★生値の一例★(意味は第10回 N-2 で受領済み。指示書AQ §1-2)
          status: 4,
          // ★面積は完了率と独立に置く★(面積は表示用に残る。完了率の計算には使わない。
          //   指示書AQ 決定4 / §1-3)
          clean_area: ratio * 10,
          task_area: 1_000,
          started_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`),
          ended_at: dbWallClock(`${dayIso(dayIndex)}T21:00:00+09:00`),
        });
      }
    }
  }

  // ★事前チェック行(本清掃の子)★ — `status_1h` / `status_30m` の材料。
  //   ★対象日の1件にだけ付ける(`parent_id` の別クエリが走ることの確認も兼ねる)★
  const targetId = timetableId(1, DAYS - 1, 0);
  for (const [precheck, minutes] of [
    [60, "19:00"],
    [30, "19:30"],
  ] as const) {
    timetables.push({
      id: BigInt(targetId * 10 + precheck),
      facility_id: BigInt(1),
      section_id: BigInt(10),
      parent_id: BigInt(targetId),
      start_at: dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`),
      end_at: new Date(dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`).getTime() + 60 * 60_000),
      precheck,
      facility: { facility_name: FACILITIES[0].name },
      section: {
        section_name: "エリア1-1",
        robots: [
          { serial_number: "8111R56280601", device: { maker_id: 1, model_name: "CC1" } },
        ],
      },
    });
  }
  errorReports.push({
    clean_timetable_id: BigInt(targetId * 10 + 60),
    error_type: "EmergencyStop",
    errored_at: dbWallClock(`${TARGET_DATE}T19:05:00+09:00`),
  });

  return {
    timetables,
    reports,
    errorReports,
    facilities: FACILITIES.map((f) => ({
      id: BigInt(f.id),
      facility_name: f.name,
      deleted_at: null,
    })),
  };
}

/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */
const NOW = new Date("2026-10-15T00:00:00+09:00");

/** ★対象の清掃ログ★(施設1・最終日・エリア1)。同じ施設・同じ日に3エリアある */
const TARGET_ID = timetableId(1, DAYS - 1, 0);

const ALL_RANGE = { facility_id: null, date: null } as const;

async function areas(id: number, search = ""): Promise<Response> {
  return AREAS(new Request(`http://localhost/api/clean-logs/${id}/areas${search}`), {
    params: Promise.resolve({ id: String(id) }),
  });
}

async function advice(id: number): Promise<Response> {
  return ADVICE(new Request(`http://localhost/api/clean-logs/${id}/advice`), {
    params: Promise.resolve({ id: String(id) }),
  });
}

/**
 * ★連続日数用のクエリ★(`loadConsecutiveInputs()` が発行する2本)。
 *
 * ★★`select` の中身で見分けること★★ — 段階6 でこの2本にも `where` が付いたので、
 *   ★「`where` を持たない」では区別できなくなった★。
 *   ★他の `select` 付きクエリ(事前チェック行の `parent_id` / `precheck` / `id`)と
 *   取り違えないように、この関数が読む列そのもので判定する★。
 */
function consecutiveCalls(): CallRecord[] {
  return calls.filter((c) => {
    const select = (c.args as { select?: Record<string, unknown> }).select;
    if (!select) return false;
    // ★予定は2表から読む(指示書AU 段階3b)ので、履歴側の連続日数用クエリも同じ扱い★
    if (c.model === "cleanTimetable" || c.model === "cleanTimetableHistory") {
      return select.facility_id === true && select.start_at === true;
    }
    if (c.model === "cleanTaskReport") return select.clean_ratio === true;
    return false;
  });
}

/** 連続日数用を除いた読み込み行数(★段階3 で減らしたいのはこちら★) */
function rowsReadExcludingConsecutive(): number {
  return calls
    .filter((c) => !consecutiveCalls().includes(c))
    .reduce((sum, c) => sum + c.rows, 0);
}

beforeEach(() => {
  data = buildData();
  calls = [];
  vi.mocked(logEvent).mockClear();
  vi.useFakeTimers({ toFake: ["Date"] });
  vi.setSystemTime(NOW);
});

afterEach(() => {
  vi.useRealTimers();
});

// ---------------------------------------------------------------- データ層

describe("db.cleanLogs({ id }) — 単一IDの取得経路(指示書AC 段階3)", () => {
  it("★★#1 `id` で引いた1件が、全件版の同じ行と**完全一致**する★★(★連続日数まで★)", async () => {
    const all = await db.cleanLogs({
      id: null,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    const expected = all.find((l) => l.id === TARGET_ID);
    expect(expected).toBeDefined();

    const one = await db.cleanLogs({
      id: TARGET_ID,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    expect(one).toHaveLength(1);
    // ★★1バイトも変わっていないこと★★(禁止事項14)— `consecutive_problem_days` /
    //   `escalation_level` / `status_1h` / `status_30m` まで含めて比べる
    expect(one[0]).toEqual(expected);
    // ★連続日数は実際に 1 より大きい★(= 全期間を読めている。★C-2 の地雷の検出★)
    expect(one[0].consecutive_problem_days).toBeGreaterThan(1);
  });

  it("★主クエリの `where` に `id` が降りている★(★メモリ上の `find()` に戻っていない★)", async () => {
    calls = [];
    await db.cleanLogs({
      id: TARGET_ID,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args);
    expect(main).toBeDefined();
    expect((main!.args.where as Record<string, unknown>).id).toBe(BigInt(TARGET_ID));
    // ★★1行しか読んでいないこと★★
    expect(main!.rows).toBe(1);
  });

  it("★存在しない `id` → 0件★(★`id: null` に化けて全件になっていないことの検出★)", async () => {
    const rows = await db.cleanLogs({
      id: 999_999_999,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    expect(rows).toEqual([]);
  });

  it("★★単一ID取得では連続日数用のクエリに日付の下限が付かない★★(=全期間のまま。C-2 / 段階6 の申し送り)", async () => {
    calls = [];
    await db.cleanLogs({
      id: TARGET_ID,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    // ★`clean_timetables` / `clean_timetable_histories`(指示書AU 段階3b)/ `clean_task_reports` の2本
    //   (予定の id で引く本と、予定外タスクを読む本。指示書BG §2-3)の4本★
    expect(consecutiveCalls()).toHaveLength(4);
    // ★予定外タスクの本も期間の下限が付かない★(`started_at` のキーごと無い)
    const unscheduledCall = consecutiveCalls().find(
      (c) => c.model === "cleanTaskReport" && (c.args as { select: Record<string, unknown> }).select.facility_id === true
    );
    expect((unscheduledCall?.args.where as Record<string, unknown>).started_at).toBeUndefined();
    // ★履歴にも同じ引数★
    expect(consecutiveCalls().find((c) => c.model === "cleanTimetableHistory")?.args).toEqual(
      consecutiveCalls().find((c) => c.model === "cleanTimetable")?.args
    );
    // ★★対象ログの日付は引くまで分からないので、この経路だけは遡り範囲を決められない★★
    //   (段階6 で有界にできたのは `date_from` が来る経路。★申し送りに残してある★)
    const timetableCall = consecutiveCalls().find((c) => c.model === "cleanTimetable");
    expect((timetableCall?.args.where as Record<string, unknown>).start_at).toBeUndefined();
  });
});

describe("db.areaCompletions({ facility_id, date }) — 施設・日付をSQLに降ろす(段階3)", () => {
  it("★★SQL版 = 全件版 + `selectAreaRowsForLog()`(完全一致)★★(指示書AC G-3)", async () => {
    const all = await db.areaCompletions(ALL_RANGE);
    const expected = selectAreaRowsForLog(all, { facility_id: 1, date: TARGET_DATE });

    const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
    expect(scoped).toEqual(expected);
    // ★3エリアぶん(#3)★
    expect(scoped).toHaveLength(SECTIONS_PER_FACILITY);
  });

  it("★★`where` に施設ID・日付・本清掃が降りている★★(★壁時計値に直してあること★。C-6)", async () => {
    calls = [];
    await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
    const main = calls.find((c) => c.model === "cleanTimetable")!;
    const where = main.args.where as Record<string, unknown>;
    expect(where.precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
    expect(where.facility_id).toBe(BigInt(1));
    const startAt = where.start_at as { gte: Date; lt: Date };
    // ★★`toDbJst()` を通した壁時計値であること★★(★生の `parseDate()` なら9時間ずれる★)
    expect(startAt.gte.toISOString()).toBe(`${TARGET_DATE}T00:00:00.000Z`);
    expect(startAt.lt.toISOString()).toBe("2026-10-01T00:00:00.000Z");
  });

  it("★★壁時計 20:00 の清掃がその日の範囲に**含まれる**★★(C-6。★ローカルDBでは検出できない★)", async () => {
    const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
    expect(scoped.every((r) => r.date === TARGET_DATE)).toBe(true);
    // ★前日・翌日の指定では出てこない★
    expect(await db.areaCompletions({ facility_id: 1, date: "2026-09-29" })).not.toEqual(scoped);
    expect(await db.areaCompletions({ facility_id: 1, date: "2026-10-01" })).toEqual([]);
  });

  it("★レポートも本清掃の id で引き直す(全件読みに戻さない)★", async () => {
    calls = [];
    await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
    const report = calls.find((c) => c.model === "cleanTaskReport")!;
    expect(report.args.where).toBeDefined();
    // ★未実施のエリア(section 2)はレポートの行が無いので1行少ない★(指示書AQ §1-1 の仕様変更。
    //   旧取得元は未実施でも `clean_error = 1` の行があったので SECTIONS_PER_FACILITY だった)
    expect(report.rows).toBe(SECTIONS_PER_FACILITY - 1);
  });

  it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => {
    calls = [];
    const rows = await db.areaCompletions({ facility_id: 1, date: "2020-01-01" });
    expect(rows).toEqual([]);
    expect(calls.filter((c) => c.model === "cleanTaskReport")).toEqual([]);
  });

  it("★fixtures 版と同じ意味(`null` は「絞らない」)★(禁止事項6)", async () => {
    const all = await db.areaCompletions(ALL_RANGE);
    const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
    expect(filterFixtureAreaCompletions(all, { facility_id: 1, date: TARGET_DATE })).toEqual(
      scoped
    );
    expect(filterFixtureAreaCompletions(all, ALL_RANGE)).toHaveLength(all.length);
  });
});

// ---------------------------------------------------------------- /clean-logs/{id}/areas

describe("GET /clean-logs/{id}/areas(指示書AC 段階3 期待値表)", () => {
  /** ★段階0(全件読み + 純粋関数)と同じ計算★ をその場でやり直す */
  async function expectedBody(
    id: number,
    filter: "problem" | "all"
  ): Promise<AreaCompletionsResponse> {
    const logs = await db.cleanLogs({
      id: null,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    const log = logs.find((l) => l.id === id)!;
    const all: AreaCompletionRow[] = await db.areaCompletions(ALL_RANGE);
    const selected = selectAreaRowsForLog(all, {
      facility_id: log.facility_id,
      date: log.scheduled_at.slice(0, 10),
    });
    return buildAreaCompletions(selected, filter);
  }

  it("★★#1 存在する `{id}` → 段階0 と同じレスポンス★★", async () => {
    const expected = await expectedBody(TARGET_ID, "problem");
    const res = await areas(TARGET_ID);
    expect(res.status).toBe(200);
    const body = (await res.json()) as AreaCompletionsResponse;
    // ★★`items` / `overall_completion_rate` / `total_count` / `problem_count` が全一致★★
    expect(body).toEqual(expected);
  });

  it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => {
    const res = await areas(999_999_999);
    expect(res.status).toBe(404);
    expect(await res.json()).toEqual({
      error: {
        code: "not_found",
        message: "指定された清掃ログが見つかりません",
        details: null,
      },
    });
  });

  it("★#2(構造)404 のときは `areaCompletions()` を引かない★", async () => {
    calls = [];
    await areas(999_999_999);
    // ★連続日数用の `clean_task_reports` は走るが、エリア用の引き直しは無い★
    //   (★段階6 で連続日数用のクエリにも `where` が付いたので、そちらは除いて見る★)
    expect(
      calls.filter(
        (c) =>
          c.model === "cleanTaskReport" &&
          "where" in c.args &&
          !consecutiveCalls().includes(c)
      )
    ).toEqual([]);
  });

  it("★★#3 同じ施設・同じ日の**全エリア**が返る★★(`{id}` はエリアではなく入口。指示書Z 決定1)", async () => {
    const res = await areas(TARGET_ID, "?filter=all");
    const body = (await res.json()) as AreaCompletionsResponse;
    expect(body.total_count).toBe(SECTIONS_PER_FACILITY);
    expect(body.items.map((i) => i.section_name).sort()).toEqual([
      "エリア1-1",
      "エリア1-2",
      "エリア1-3",
    ]);
    // ★自分自身(`{id}` の行)も含まれている★
    expect(body.items.some((i) => i.section_id === 10)).toBe(true);
  });

  it("★#3 別のエリアの `{id}` で開いても同じ3エリアが返る★(入口が変わるだけ)", async () => {
    const other = timetableId(1, DAYS - 1, 1);
    const a = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
    const b = (await (await areas(other, "?filter=all")).json()) as AreaCompletionsResponse;
    expect(b).toEqual(a);
  });

  it("★★#4 `filter=problem` でも `total_count` / `overall_*` は絞る**前**の値★★(決定8 / 決定12)", async () => {
    const all = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
    const problem = (await (
      await areas(TARGET_ID, "?filter=problem")
    ).json()) as AreaCompletionsResponse;

    expect(problem.total_count).toBe(all.total_count);
    expect(problem.overall_completion_rate).toBe(all.overall_completion_rate);
    expect(problem.overall_grade).toBe(all.overall_grade);
    expect(problem.problem_count).toBe(all.problem_count);
    // ★★`items` だけが絞られる★★(◎のエリアが落ちる)
    expect(problem.items.length).toBeLessThan(all.items.length);
    expect(problem.items).toEqual(all.items.filter((i) => i.grade === "×"));
  });

  it("★★#5 読み込み行数が「全件」から数十行に減る★★", async () => {
    // ★全件版が読む行数(段階0 相当)★
    calls = [];
    await db.cleanLogs({ id: null, date_from: null, date_to: null, facility_ids: null });
    await db.areaCompletions(ALL_RANGE);
    const before = calls.reduce((sum, c) => sum + c.rows, 0);

    calls = [];
    await areas(TARGET_ID);
    const after = calls.reduce((sum, c) => sum + c.rows, 0);

    // ★★連続日数用の全期間クエリ(段階6 で有界にする)を除いた行数★★ —
    //   ★段階3 で減らせるのはこちら★。★ここが数十行に収まること★
    const scoped = rowsReadExcludingConsecutive();
    expect(scoped).toBeLessThan(50);
    // ★全件版より確実に少ない★(合成データは本清掃だけで
    //   3施設 × 30日 × 3エリア = 270行ある)
    expect(after).toBeLessThan(before);
    expect(before).toBeGreaterThan(500);
  });
});

// ---------------------------------------------------------------- judge.areas ログ

describe("GET /clean-logs/{id}/areas — judge.areas ログの分子・分母(指示書AR 段階4)", () => {
  /** 直近の呼び出しで出た `judge.areas` を1件だけ取り出す */
  function judgeAreasEvent(): JudgeAreasLogEvent {
    const events = vi
      .mocked(logEvent)
      .mock.calls.map((c) => c[1])
      .filter((p): p is JudgeAreasLogEvent => p.event === "judge.areas");
    expect(events).toHaveLength(1);
    return events[0];
  }

  it("★`raw_overall_completion_rate` が `overall_completion_rate_sum / overall_completion_rate_count` と一致する★", async () => {
    const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
    const ev = judgeAreasEvent();
    expect(ev.raw_overall_completion_rate).toBe(
      ev.overall_completion_rate_sum / ev.overall_completion_rate_count
    );
    // ★画面(レスポンス)の全体率とも一致する★
    expect(ev.raw_overall_completion_rate).toBe(body.overall_completion_rate);
  });

  it("★未実施(完了率 null)の行は分子にも分母にも入らない★", async () => {
    await areas(TARGET_ID, "?filter=all");
    const ev = judgeAreasEvent();
    // 対象日・施設1の3エリア: 95 / 5 / null(未実施)→ 分子 100・分母 2(★3 ではない★)
    expect(ev.total_count).toBe(SECTIONS_PER_FACILITY);
    expect(ev.overall_completion_rate_sum).toBe(95 + 5);
    expect(ev.overall_completion_rate_count).toBe(2);
    expect(ev.raw_overall_completion_rate).toBe(50);
  });

  it("★`filter=problem` でも分子・分母は絞る前の行で数える★(決定8 / 決定12)", async () => {
    await areas(TARGET_ID, "?filter=problem");
    const ev = judgeAreasEvent();
    expect(ev.overall_completion_rate_sum).toBe(100);
    expect(ev.overall_completion_rate_count).toBe(2);
  });

  it("★面積の合計(`overall_clean_area` / `overall_task_area`)はログに出さない★", async () => {
    await areas(TARGET_ID);
    const ev = judgeAreasEvent() as unknown as Record<string, unknown>;
    expect("overall_clean_area" in ev).toBe(false);
    expect("overall_task_area" in ev).toBe(false);
    // ★式の名前を入れた旧名(指示書AR 段階4)も出さない★(指示書AS D8 で改名)
    expect("overall_clean_ratio_sum" in ev).toBe(false);
    expect("overall_clean_ratio_count" in ev).toBe(false);
  });
});

// ---------------------------------------------------------------- /clean-logs/{id}/advice

describe("GET /clean-logs/{id}/advice(指示書AC 段階3)", () => {
  it("★★#1 存在する `{id}` → 1件だけ引いても中身が変わらない★★", async () => {
    calls = [];
    const res = await advice(TARGET_ID);
    expect(res.status).toBe(200);
    const body = (await res.json()) as CleanLogAdviceResponse;

    // ★★全件版の同じ行から作った入力と一致すること★★(★連続日数が壊れていない証拠★。C-2)
    const all = await db.cleanLogs({
      id: null,
      date_from: null,
      date_to: null,
      facility_ids: null,
    });
    const log = all.find((l) => l.id === TARGET_ID)!;
    // ★レスポンスに載るのは表示上限をかけた値★(指示書AF 段階6-2)なので、
    //   ★ルートと同じ手順で期待値を作る★(数字を書き写さない)
    const capped = capConsecutiveDays(log.consecutive_problem_days, await loadConsecutiveDaysMax());
    expect(body.consecutive_problem_days).toBe(capped.days);
    expect(body.consecutive_problem_days_capped).toBe(capped.capped);
    // ★★判定(L1〜L3)は上限をかける**前**の生値で行う★★(指示書AF 禁止事項9)
    expect(body.escalation_level).toBe(log.escalation_level);
    // ★連続日数が 1 より大きい=全期間を読めている(★C-2 の地雷の検出★)★
    expect(log.consecutive_problem_days).toBeGreaterThan(1);
    expect(body.advices.length).toBeGreaterThan(0);
  });

  it("★主クエリが1行しか読んでいない★(★全件読み + `find()` に戻っていない★)", async () => {
    calls = [];
    await advice(TARGET_ID);
    const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!;
    expect((main.args.where as Record<string, unknown>).id).toBe(BigInt(TARGET_ID));
    expect(main.rows).toBe(1);
    // ★連続日数用の全期間クエリを除けば数十行★
    expect(rowsReadExcludingConsecutive()).toBeLessThan(50);
  });

  it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => {
    const res = await advice(999_999_999);
    expect(res.status).toBe(404);
    expect(await res.json()).toEqual({
      error: {
        code: "not_found",
        message: "指定された清掃ログが見つかりません",
        details: null,
      },
    });
  });

  it("★`{id}` の形式が不正なら 422(`db` まで来ない)★", async () => {
    calls = [];
    const res = await ADVICE(new Request("http://localhost/api/clean-logs/1.9/advice"), {
      params: Promise.resolve({ id: "1.9" }),
    });
    expect(res.status).toBe(422);
    expect(calls).toEqual([]);
  });
});

describe("db.areaCompletions / db.cleanLogs({ id }) — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => {
  /** ★履歴だけにある、対象と同じ施設・同じ日のエリア★ */
  const HISTORY_ID = 9_000_001;
  function historyRow(): StubTimetable {
    return {
      id: BigInt(HISTORY_ID),
      facility_id: BigInt(1),
      section_id: BigInt(19),
      parent_id: BigInt(0),
      start_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`),
      end_at: new Date(dbWallClock(`${TARGET_DATE}T20:00:00+09:00`).getTime() + 60 * 60_000),
      precheck: CLEAN_TIMETABLE_PRECHECK.main,
      facility: { facility_name: FACILITIES[0].name },
      section: {
        section_name: "エリア1-9",
        robots: [{ serial_number: "8111R56280619", device: { maker_id: 1, model_name: "CC1" } }],
      },
    };
  }

  it("★履歴だけの予定がエリア別完了率に出る★。2表に同じ引数が渡る", async () => {
    data.historyTimetables = [historyRow()];
    data.reports.push({
      clean_timetable_id: BigInt(HISTORY_ID),
      clean_ratio: 95,
      status: 4,
      clean_area: 950,
      task_area: 1_000,
      started_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`),
      ended_at: dbWallClock(`${TARGET_DATE}T21:00:00+09:00`),
    });
    calls = [];
    const rows = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
    const row = rows.find((r) => r.clean_log_id === HISTORY_ID);
    expect(row).toMatchObject({ section_name: "エリア1-9", completion_rate: 95 });
    const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!;
    const history = calls.find((c) => c.model === "cleanTimetableHistory" && "include" in c.args)!;
    expect(history.args).toEqual(main.args);
  });

  it("★履歴だけの予定を id で1件引ける★(`/clean-logs/{id}/advice` / `/{id}/areas` の入口)", async () => {
    data.historyTimetables = [historyRow()];
    const one = await db.cleanLogs({ id: HISTORY_ID, date_from: null, date_to: null, facility_ids: null });
    expect(one.map((l) => l.id)).toEqual([HISTORY_ID]);
  });
});

describe("削除済みの施設・清掃エリア(指示書BB U1・表1 V2/V3)", () => {
  const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");

  function markDeleted(id: number, what: "facility" | "section") {
    data.timetables = data.timetables.map((t) =>
      Number(t.id) !== id
        ? t
        : what === "facility"
          ? { ...t, facility: { ...t.facility, deleted_at: DELETED } }
          : { ...t, section: { ...t.section, deleted_at: DELETED } }
    );
  }

  it.each(["facility", "section"] as const)(
    "★V2(%s が削除済み): 見えない行の `{id}` は advice / areas とも404★",
    async (what) => {
      markDeleted(TARGET_ID, what);
      expect((await advice(TARGET_ID)).status).toBe(404);
      expect((await areas(TARGET_ID)).status).toBe(404);
    }
  );

  it("★V3: 生きている行から開いたとき、同じ施設・同じ日の削除済みの清掃エリアは items・件数・平均に入らない★", async () => {
    const before = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
    expect(before.total_count).toBe(3);
    // エリア1-2(完了率5%)を削除済みにする
    markDeleted(timetableId(1, DAYS - 1, 1), "section");
    const res = await areas(TARGET_ID, "?filter=all");
    expect(res.status).toBe(200);
    const body = (await res.json()) as AreaCompletionsResponse;
    expect(body.items.map((i) => i.section_name).sort()).toEqual(["エリア1-1", "エリア1-3"]);
    expect(body.total_count).toBe(2);
    // ★平均は 95 と 5 の平均(50)から、95 だけ(1-3 は未実施で分母に入らない)に変わる★
    expect(body.overall_completion_rate).toBe(95);
  });

  it("V2・V3: `cleanLogs({ id })` と `areaCompletions()` の予定の `where` に可視性が入る", async () => {
    await areas(TARGET_ID);
    const mainCalls = calls.filter(
      (c) => c.model === "cleanTimetable" && "include" in c.args
    );
    expect(mainCalls.length).toBeGreaterThanOrEqual(2);
    for (const c of mainCalls) {
      expect((c.args as { where: Record<string, unknown> }).where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    }
  });
});

/**
 * ★表6 A7・A8(指示書BB A3)★ — 結果待ちはデータ層が `isResultPending()` で決める(Prisma 経路)。
 *   対象日(09-30)の施設1は 20:00〜21:00。エリア1-3(section 12)はレポートなし。
 *   ★終了+120分=23:00★ より前なら結果待ち、ちょうどなら確定した未実施(表1 P6 と同じ境界)。
 */
describe("GET /clean-logs/{id}/areas — 結果待ち(指示書BB 表6 A7・A8)", () => {
  async function areaOf(sectionId: number) {
    const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
    return { body, item: body.items.find((i) => i.section_id === sectionId) };
  }

  it("A8: now=22:59(終了+120分より前)→ section 12 は result_pending: true。problem_count に数えない", async () => {
    vi.setSystemTime(new Date(`${TARGET_DATE}T22:59:00+09:00`));
    const { body, item } = await areaOf(12);
    expect(item).toMatchObject({ result_pending: true, grade: "×", completion_rate: null });
    // 問題は section 11(5%)だけ
    expect(body.problem_count).toBe(1);
    expect(body.total_count).toBe(3);
  });

  it("★A7: now=23:00(終了+120分ちょうど)→ result_pending: false(確定した未実施)★", async () => {
    vi.setSystemTime(new Date(`${TARGET_DATE}T23:00:00+09:00`));
    const { body, item } = await areaOf(12);
    expect(item).toMatchObject({ result_pending: false, grade: "×" });
    expect(body.problem_count).toBe(2);
  });

  it("レポートのある行は result_pending: false", async () => {
    vi.setSystemTime(new Date(`${TARGET_DATE}T22:00:00+09:00`));
    const { item } = await areaOf(10);
    expect(item?.result_pending).toBe(false);
  });
});

// ★表10 N6(指示書BB A6・U4)★ — 単一ID(`/advice`・`/areas`)の連続日数の入力は施設で絞らない(TeamWorks の索引の後)
describe("単一ID の連続日数の入力は施設で絞らない(指示書BB 表10 N6・U4)", () => {
  it.each(["advice", "areas"] as const)("%s: 連続日数用の予定のクエリに `facility_id` の条件が無い", async (which) => {
    calls = [];
    const res = which === "advice" ? await advice(TARGET_ID) : await areas(TARGET_ID);
    expect(res.status).toBe(200);
    const timetableConsecutive = consecutiveCalls().filter((c) => c.model === "cleanTimetable");
    expect(timetableConsecutive.length).toBeGreaterThan(0);
    for (const c of timetableConsecutive) {
      expect((c.args as { where: Record<string, unknown> }).where).not.toHaveProperty("facility_id");
    }
  });
});

// ★表8 T3(指示書BB A5・D18・D-182)★ — アドバイスの文言の読み込みが例外なら、標準の形の500と `api.error` が1件
describe("GET /clean-logs/{id}/advice — 文言の読み込みの例外(指示書BB 表8 T3)", () => {
  it("★T3: `loadAdviceRules()` が例外 → 500・internal_error・「データの取得に失敗しました」・api.error が1件★", async () => {
    vi.mocked(logApiError).mockClear();
    const broken: JsonStore<AdviceOverrides> = {
      read: async () => {
        throw new Error("[stub] 読み込みに失敗");
      },
      write: async () => {},
    };
    __setAdviceStoreForTesting(broken);
    try {
      const res = await advice(TARGET_ID);
      expect(res.status).toBe(500);
      expect(await res.json()).toEqual({
        error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
      });
      expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
      expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ method: "GET", status: 500 });
    } finally {
      __setAdviceStoreForTesting(null);
    }
  });
});