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,
unscheduled: false,
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]);
});
it("#10 ★予定外タスクの行(`id: null`・`unscheduled: true`)も入れる。面積は引かず 0★(指示書BG §2-3)", () => {
const logs = [
{ id: null, unscheduled: true, facility_id: 1, scheduled_at: "2026-10-28T21:00:00+09:00", completion_rate: 40, departed: true, result_pending: false },
] as unknown as CleanLogItem[];
// ★`clean_log_id` が null の面積行があっても引き当てない★(予定外は予定の id を持たない)
const areas = [{ clean_log_id: null, clean_area: 900, task_area: 1000 }] as unknown as AreaCompletionRow[];
expect(rescueInputsFromCleanLogs(logs, areas)).toEqual([
{
id: null,
unscheduled: true,
facility_id: 1,
scheduled_at: "2026-10-28T21:00:00+09:00",
completion_rate: 40,
departed: true,
clean_area: 0,
task_area: 0,
},
]);
});
});