Newer
Older
client_dwatch / lib / db / __tests__ / rescue-mapping.test.ts
import { describe, expect, it } from "vitest";

import type { ConsecutiveInput } from "@/lib/db/clean-log-mapping";
import {
  buildRescueRequired,
  rescueInputsFromCleanLogs,
} from "@/lib/db/rescue-mapping";
import type { AreaCompletionRow, CleanLogItem, FacilityNameRef } from "@/lib/types";

/**
 * 指示書AI 段階4 の期待値表。
 *
 * ★★3条件そのものの期待値表は `lib/judge/__tests__/rescue.test.ts` にある★★(段階3)。
 *   ここで固定するのは ★「どの行を、どの施設の、どの日の入力として渡すか」★ だけ:
 *
 * | # | 何を固定するか |
 * |---|---|
 * | 1 | ★当該日に予定が無い施設も出る★(評価日はそれ以前で最も新しいスケジュール存在日) |
 * | 2 | ★当該日より後の行は数に入れない★ |
 * | 3 | 3条件のどれにも当たらない施設は出ない |
 * | 4 | 並びは ★理由の数の降順 → 連続問題日数の降順 → 施設IDの昇順★(指示書AJ 表1-4〜1-6) |
 * | 5 | ★件数で切らない★(指示書AJ 表1-1〜1-3。★以前は5件で切っていた★) |
 * | 6 | 同じ施設×日の複数行は1回ごとの完了率(`completion_rate`)の単純平均で1日に畳まれる(R-3 が施設単位で成立する。指示書AQ 決定4 / 追補5-5) |
 * | 7 | 施設名が引けないときは `String(facility_id)`(行を消さない) |
 * | 8 | fixtures の清掃ログ → `ConsecutiveInput`(面積は `clean_log_id` で引く) |
 */

let seq = 0;
function input(
  facilityId: number,
  date: string,
  completionRate: number | null,
  areas: { clean_area: number; task_area: number } = { clean_area: 0, task_area: 0 }
): ConsecutiveInput {
  seq += 1;
  return {
    id: seq,
    facility_id: facilityId,
    scheduled_at: `${date}T02:00:00+09:00`,
    completion_rate: completionRate,
    // ★null = レポート行なし(未実施)として詰める★(`departed` と `completion_rate` が食い違う行(防御)は
    //   `lib/judge/__tests__/rescue.test.ts` の表5 で確かめる。【未決1】案A。D-156 / 指示書AU D3)
    departed: completionRate !== null,
    clean_area: areas.clean_area,
    task_area: areas.task_area,
  };
}

const NAMES: FacilityNameRef[] = [
  { id: 1, facility_name: "施設1" },
  { id: 2, facility_name: "施設2" },
  { id: 3, facility_name: "施設3" },
  { id: 7, facility_name: "施設7" },
];

/** 未実施が3日続いた施設(R-1 と R-2 の両方に当たる = ★理由2つ★) */
function notExecuted3Days(facilityId: number): ConsecutiveInput[] {
  return [
    input(facilityId, "2026-09-10", null),
    input(facilityId, "2026-09-11", null),
    input(facilityId, "2026-09-12", null),
  ];
}

/**
 * 完了率が低いまま `days` 日続いた施設(★理由は L3 の1つだけ★)。
 *
 * ★毎日同じ率(50)なので前回比の低下が 0 になり、R-3(急落)は成立しない★
 *   — 「理由1つ・連続が長い」施設を作るための道具。
 *   (旧コメントは「面積0なので `facilityDayRate()` が null」だったが、完了率が
 *   1回ごとの完了率の単純平均に変わったため面積は判定に効かない。指示書AQ 決定4 / 追補5-5 / D-155)
 * ★未実施ではない(`completion_rate` が `null` でない)ので R-1 も成立しない★。
 */
function lowRateDays(facilityId: number, days: number): ConsecutiveInput[] {
  const rows: ConsecutiveInput[] = [];
  for (let i = 0; i < days; i += 1) {
    // 09-12 で終わるように遡って日付を作る(★連続の起点が日付順に並んでいればよい★)
    const day = 12 - (days - 1 - i);
    rows.push(input(facilityId, `2026-09-${String(day).padStart(2, "0")}`, 50));
  }
  return rows;
}

describe("buildRescueRequired", () => {
  it("#1 当該日に予定が無い施設も出る(評価日は直前のスケジュール存在日)", () => {
    // 施設7は 09-12 まで予定があり、09-13 には予定が無い
    const items = buildRescueRequired(notExecuted3Days(7), NAMES, "2026-09-13");

    expect(items).toHaveLength(1);
    expect(items[0].facility_id).toBe(7);
    expect(items[0].facility_name).toBe("施設7");
    // ★リクエストの日付(09-13)ではない★
    expect(items[0].evaluated_on).toBe("2026-09-12");
    expect(items[0].reasons).toEqual(["not_executed_streak", "escalation_l3"]);
    expect(items[0].not_executed_days).toBe(3);
    expect(items[0].consecutive_problem_days).toBe(3);
  });

  it("#2 当該日より後の行は数に入れない", () => {
    const rows = [
      ...notExecuted3Days(7),
      // ★当該日の翌日に正常な清掃がある(=翌日には連続が切れる)★
      input(7, "2026-09-14", 95),
    ];

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items).toHaveLength(1);
    expect(items[0].evaluated_on).toBe("2026-09-12");
    expect(items[0].consecutive_problem_days).toBe(3);
  });

  it("#3 3条件のどれにも当たらない施設は出ない", () => {
    const rows = [input(1, "2026-09-12", 95), input(1, "2026-09-13", 92)];

    expect(buildRescueRequired(rows, NAMES, "2026-09-13")).toEqual([]);
  });

  it("#4 理由の数が同じなら 連続問題日数の降順 → 施設IDの昇順に並ぶ", () => {
    const rows = [
      // 施設3: 3日連続
      ...notExecuted3Days(3),
      // 施設1: 4日連続(最上位)
      input(1, "2026-09-09", null),
      ...notExecuted3Days(1),
      // 施設2: 3日連続(施設3と同値 → 施設IDの昇順で先に出る)
      ...notExecuted3Days(2),
    ];

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items.map((i) => i.facility_id)).toEqual([1, 2, 3]);
    expect(items.map((i) => i.consecutive_problem_days)).toEqual([4, 3, 3]);
  });

  /*
   * ★指示書AJ §4-5 の期待値表★
   *
   * データ層(★件数の上限が無いこと★)
   * | # | 該当施設 | APIが返す件数 |
   * |---|---|---|
   * | 1-1 | 0 | 0 |
   * | 1-2 | 7 | ★7(5ではない)★ |
   * | 1-3 | 50 | 50 |
   *
   * ★1-2 が今回の眼目★ — 以前は `RESCUE_LIST_LIMIT`(5件)で切っており、
   * ★実データで7件中2件が黙って消えていた★(指示書AJ §1-1)。
   */
  it("#5-1 該当が0件なら空配列(表1-1)", () => {
    expect(buildRescueRequired([], NAMES, "2026-09-13")).toEqual([]);
  });

  it("#5-2 ★該当が7件なら7件返る(5件で切らない。表1-2)★", () => {
    const rows = [1, 2, 3, 4, 5, 6, 7].flatMap((id) => notExecuted3Days(id));

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items).toHaveLength(7);
    expect(items.map((i) => i.facility_id)).toEqual([1, 2, 3, 4, 5, 6, 7]);
  });

  it("#5-3 該当が50件なら50件返る(表1-3)", () => {
    const ids = Array.from({ length: 50 }, (_, i) => i + 1);
    const rows = ids.flatMap((id) => notExecuted3Days(id));

    expect(buildRescueRequired(rows, NAMES, "2026-09-13")).toHaveLength(50);
  });

  it("#5-4 理由の数が多い施設が先に来る(連続日数より優先。表1-4)", () => {
    const rows = [
      // 施設1: 理由2つ(未実施3日連続 + L3)・連続3日
      ...notExecuted3Days(1),
      // 施設2: 理由1つ(L3 だけ)・連続6日 ★連続日数はこちらが長い★
      ...lowRateDays(2, 6),
    ];

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items.map((i) => i.reasons.length)).toEqual([2, 1]);
    // ★連続日数の降順だけで並べていたら [2, 1] になるはず★
    expect(items.map((i) => i.facility_id)).toEqual([1, 2]);
  });

  it("#5-5 理由の数が同じなら連続日数の降順(表1-5)", () => {
    const rows = [
      // 施設1: 理由1つ・連続3日
      ...lowRateDays(1, 3),
      // 施設2: 理由1つ・連続6日
      ...lowRateDays(2, 6),
    ];

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items.map((i) => i.reasons.length)).toEqual([1, 1]);
    expect(items.map((i) => i.facility_id)).toEqual([2, 1]);
  });

  it("#5-6 理由の数も連続日数も同じなら施設IDの昇順(表1-6)", () => {
    const rows = [...lowRateDays(9, 3), ...lowRateDays(3, 3)];

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items.map((i) => i.consecutive_problem_days)).toEqual([3, 3]);
    expect(items.map((i) => i.facility_id)).toEqual([3, 9]);
  });

  it("#6 同じ施設×日の複数行は完了率の単純平均で畳まれる(R-3 は単純平均で判定される)", () => {
    const rows = [
      // 前回(09-12): 100%
      input(1, "2026-09-12", 100, { clean_area: 1000, task_area: 1000 }),
      // 当日(09-13): 2台の完了率の単純平均 (95+25)/2 = 60%(前回比 40% 低下 → 急落)
      //   ★面積はあえて残す★ — 旧仕様の面積加重なら 700/1000 = 70% になり、式の取り違えが値で分かる
      input(1, "2026-09-13", 95, { clean_area: 600, task_area: 600 }),
      input(1, "2026-09-13", 25, { clean_area: 100, task_area: 400 }),
    ];

    const items = buildRescueRequired(rows, NAMES, "2026-09-13");

    expect(items).toHaveLength(1);
    expect(items[0].reasons).toEqual(["completion_drop"]);
    // 70 → 60: 面積加重 → 完了率の単純平均(指示書AQ 決定4 / 追補5-5)
    expect(items[0].completion_rate).toBe(60);
    expect(items[0].previous_completion_rate).toBe(100);
    // 0.3 → 0.4: 上の完了率の変更に連動((100-60)/100。指示書AQ 決定4 / 追補5-5)
    expect(items[0].drop_ratio).toBeCloseTo(0.4, 10);
    // ★未実施は無いので R-1 は 0 日★
    expect(items[0].not_executed_days).toBe(0);
  });

  it("#7 施設名が引けないときも行を消さず、施設IDをそのまま出す", () => {
    const items = buildRescueRequired(notExecuted3Days(99), NAMES, "2026-09-13");

    expect(items).toHaveLength(1);
    expect(items[0].facility_name).toBe("99");
  });
});

describe("rescueInputsFromCleanLogs", () => {
  it("#8 面積は clean_log_id で引き、無い行は 0 になる", () => {
    const logs = [
      { id: 11, facility_id: 1, scheduled_at: "2026-09-13T02:00:00+09:00", completion_rate: 90 },
      { id: 12, facility_id: 1, scheduled_at: "2026-09-13T03:00:00+09:00", completion_rate: null },
    ] as unknown as CleanLogItem[];
    const areas = [
      { clean_log_id: 11, clean_area: 900, task_area: 1000 },
    ] as unknown as AreaCompletionRow[];

    expect(rescueInputsFromCleanLogs(logs, areas)).toEqual([
      {
        id: 11,
        facility_id: 1,
        scheduled_at: "2026-09-13T02:00:00+09:00",
        completion_rate: 90,
        clean_area: 900,
        task_area: 1000,
      },
      {
        id: 12,
        facility_id: 1,
        scheduled_at: "2026-09-13T03:00:00+09:00",
        completion_rate: null,
        clean_area: 0,
        task_area: 0,
      },
    ]);
  });

  it("#9 ★結果待ち(`result_pending: true`)の行は入力から外す★(D-173。表2 C10。問題日にも非問題日にも数えない)", () => {
    const logs = [
      { id: 21, facility_id: 1, scheduled_at: "2026-09-13T02:00:00+09:00", completion_rate: null, departed: false, result_pending: true },
      { id: 22, facility_id: 1, scheduled_at: "2026-09-12T02:00:00+09:00", completion_rate: null, departed: false, result_pending: false },
    ] as unknown as CleanLogItem[];
    expect(rescueInputsFromCleanLogs(logs, []).map((r) => r.id)).toEqual([22]);
  });
});