import { describe, expect, it } from "vitest";
import { COMPLETION_DROP, RESCUE_THRESHOLDS } from "@/lib/config";
import {
countNotExecutedStreak,
facilityDayRate,
foldRescueDays,
isEscalationL3,
judgeCompletionDrop,
judgeRescue,
type RescueRowInput,
} from "../rescue";
/**
* 要レスキュー店舗の判定(指示書AI 段階3 / ユーザー指示 2026-09-15)
*
* ★★このファイルは実装より先に書かれた★★(`CLAUDE.md` §6-2 / §9.4
* 「期待値表を先に作り、それをテストで固定する」)。表の番号は指示書AI 表1〜表5 に対応する。
*
* 3条件:
* R-1 未実施が3日以上連続(=出発失敗と同義。★`departed === false` の連続★。【未決1】案A。D-156)
* R-2 連続問題レベル3(★既存の `judgeEscalationLevel()` をそのまま使う★。数え直さない)
* R-3 完了率が前回比20%以上の急落(★差ではなく比★。ユーザー確定「比で」)
*/
/**
* 1行(=1清掃エリアの1回の清掃)を作る。★★は未実施=`departed: false` / `completion_rate: null`★。
* ★`departed` の既定は「`completion_rate` が null でなければ出発した」★ — `departed` と `completion_rate` が
* 食い違う行(防御。P-5=B 以降 DB からは来ない)は表5 で `departed` を明示して作る(【未決1】案A。D-156 / 指示書AU D3)。
*/
function row(
date: string,
completionRate: number | null,
time = "02:00:00",
departed = completionRate !== null
): RescueRowInput {
return {
scheduled_at: `${date}T${time}+09:00`,
completion_rate: completionRate,
departed,
};
}
/** 未実施の1行(`clean_task_reports` の行が無い清掃。指示書AQ 1-1) */
function notExecuted(date: string, time = "02:00:00"): RescueRowInput {
return row(date, null, time);
}
/** 正常に終わった1行(★`rate` は1回ごとの完了率=`runCompletionRate()` の戻り。D-155★) */
function done(date: string, rate: number, time = "02:00:00"): RescueRowInput {
return row(date, rate, time);
}
/**
* ★面積つきの1行★(表3 / 表6 用)。
*
* ★実際の呼び出し側は `ConsecutiveInput`(`lib/db/clean-log-mapping.ts`)をそのまま渡すので、
* `clean_area` / `task_area` が**付いたまま**届く★。★判定がそれを読まないこと★を固定するために使う。
* (★オブジェクトリテラルを直接 `RescueRowInput` に代入すると余分なキーで型エラーになる★ので、
* 一度変数に受けてから返す)
*/
function doneWithArea(
date: string,
rate: number,
cleanArea: number,
taskArea: number,
time = "02:00:00"
): RescueRowInput {
const withArea = {
scheduled_at: `${date}T${time}+09:00`,
completion_rate: rate,
departed: true,
clean_area: cleanArea,
task_area: taskArea,
};
return withArea;
}
// ---------------------------------------------------------------- 表1: R-1
describe("表1: R-1(未実施が3日以上連続)", () => {
/** 指示書AI 表1 は「当該日時点の連続数」で判定する。★当該日=最後の要素★ */
const streak = (rows: readonly RescueRowInput[]) =>
countNotExecutedStreak(foldRescueDays(rows));
const hit = (rows: readonly RescueRowInput[]) =>
streak(rows) >= RESCUE_THRESHOLDS.notExecutedDays;
it("1-1: [○] は false(1日・正常)", () => {
expect(hit([done("2026-09-01", 95)])).toBe(false);
expect(streak([done("2026-09-01", 95)])).toBe(0);
});
it("1-2: [★] は false(未実施1日)", () => {
expect(hit([notExecuted("2026-09-01")])).toBe(false);
expect(streak([notExecuted("2026-09-01")])).toBe(1);
});
it("1-3: [★, ★] は false(2日)", () => {
const rows = [notExecuted("2026-09-01"), notExecuted("2026-09-02")];
expect(streak(rows)).toBe(2);
expect(hit(rows)).toBe(false);
});
it("★1-4: [★, ★, ★] は true(3日。★境界★)", () => {
const rows = [
notExecuted("2026-09-01"),
notExecuted("2026-09-02"),
notExecuted("2026-09-03"),
];
expect(streak(rows)).toBe(3);
expect(hit(rows)).toBe(true);
});
it("1-5: [★, ★, ★, ★] は true(4日以上も拾う。「3日以上」)", () => {
const rows = [
notExecuted("2026-09-01"),
notExecuted("2026-09-02"),
notExecuted("2026-09-03"),
notExecuted("2026-09-04"),
];
expect(streak(rows)).toBe(4);
expect(hit(rows)).toBe(true);
});
it("1-6: [★, ○, ★, ★] は false(途中で切れて2日)", () => {
const rows = [
notExecuted("2026-09-01"),
done("2026-09-02", 95),
notExecuted("2026-09-03"),
notExecuted("2026-09-04"),
];
expect(streak(rows)).toBe(2);
expect(hit(rows)).toBe(false);
});
it("★1-7: [★, ★, ★, ○] は false(当該日が正常なら、過去に3日連続があっても拾わない)★", () => {
const rows = [
notExecuted("2026-09-01"),
notExecuted("2026-09-02"),
notExecuted("2026-09-03"),
done("2026-09-04", 95),
];
expect(streak(rows)).toBe(0);
expect(hit(rows)).toBe(false);
});
it("1-8: [] は false(行なし)", () => {
expect(streak([])).toBe(0);
expect(hit([])).toBe(false);
});
it("★1-9: 予定のない日を挟んでも数える(カレンダー日ではなくスケジュール存在日。D-026)★", () => {
// 9/8 は予定なし。カレンダー日で数えると 9/9〜9/10 の2日に切れて取りこぼす
const rows = [
notExecuted("2026-09-07"),
notExecuted("2026-09-09"),
notExecuted("2026-09-10"),
];
expect(streak(rows)).toBe(3);
expect(hit(rows)).toBe(true);
});
it("★1-10: 同一日に2台、片方だけ未実施ならその日は「未実施の日」(OR。D-109 A案)★", () => {
const rows = [notExecuted("2026-09-01"), done("2026-09-01", 90, "03:00:00")];
// ★未実施の行は完了率の件数に入れない★(完了率は実施した台だけで出す)
expect(foldRescueDays(rows)).toEqual([
{ date: "2026-09-01", not_executed: true, clean_ratio_sum: 90, clean_ratio_count: 1 },
]);
// ★3日ぶん重ねれば拾う★
const threeDays = [
...rows,
notExecuted("2026-09-02"),
done("2026-09-02", 90, "03:00:00"),
notExecuted("2026-09-03"),
done("2026-09-03", 90, "03:00:00"),
];
expect(streak(threeDays)).toBe(3);
expect(hit(threeDays)).toBe(true);
});
it("1-11: 同一日に同一ロボットが2回清掃、片方だけ未実施でも1日に畳む(決定2)", () => {
// ★この入力は 1-10 と構造が同じ★ — 「1日に何行あるか」だけが問題で、
// どの台の行かは畳み込みに影響しない(D-026 は日単位であって回数単位ではない)
const rows = [
done("2026-09-01", 90, "02:00:00"),
notExecuted("2026-09-01", "22:00:00"),
];
expect(foldRescueDays(rows)).toHaveLength(1);
expect(foldRescueDays(rows)[0].not_executed).toBe(true);
expect(streak(rows)).toBe(1);
});
});
// ---------------------------------------------------------------- 表2: R-2
describe("表2: R-2(連続問題レベル3)", () => {
// ★★既存の `judgeEscalationLevel()` をそのまま使う。新しく数え直さない★★
// (D-109 の再発防止「連続日数の算出は1本に集約する」。指示書AI §13-6)
it("2-1: 2日は false(L2)", () => {
expect(isEscalationL3(2)).toBe(false);
});
it("★2-2: 3日は true(L3。★境界★)", () => {
expect(isEscalationL3(3)).toBe(true);
});
it("2-3: 10日は true", () => {
expect(isEscalationL3(10)).toBe(true);
});
it("2-4: 0日は false", () => {
expect(isEscalationL3(0)).toBe(false);
});
it("1日は false(L1)— 境界の下側", () => {
expect(isEscalationL3(1)).toBe(false);
});
});
// ---------------------------------------------------------------- 表5(指示書AS): 未実施の数え方
/**
* ★★指示書AS §4 表5 — 未実施の数え方(【未決1】案A。D-156)★★
*
* | # | 清掃 | departed | completion_rate | R-1 の未実施日 |
* |----|-----------------------------|----------|-----------------|----------------|
* | U1 | レポートの行が無い | false | null | 数える |
* | U2 | ★`departed` と `completion_rate` が食い違う行(防御)★ | ★true★ | ★null★ | ★数えない★ |
* | U3 | 行がある・50/100 | true | 50.0 | 数えない |
* | U4 | 行がある・100/100 | true | 100.0 | 数えない |
* | U5 | 同じ施設・同じ日に U1 と U4 | — | — | その日を1日数える(OR) |
*/
describe("表5(指示書AS): R-1 の未実施は departed で数える", () => {
const D = "2026-09-01";
const notExecutedOf = (rows: readonly RescueRowInput[]) =>
foldRescueDays(rows).map((d) => d.not_executed);
it("U1 レポートの行が無い → 未実施の日", () => {
expect(notExecutedOf([row(D, null, "02:00:00", false)])).toEqual([true]);
});
it("★U2 `departed` と `completion_rate` が食い違う行(防御。departed true・completion_rate null)→ 未実施の日に数えない★", () => {
const days = foldRescueDays([row(D, null, "02:00:00", true)]);
expect(days.map((d) => d.not_executed)).toEqual([false]);
// ★完了率の Σ と件数には入らない(null は数えない)★
expect(days[0].clean_ratio_count).toBe(0);
expect(facilityDayRate(days)).toBeNull();
});
it("U3 行がある・50/100 → 数えない", () => {
expect(notExecutedOf([row(D, 50, "02:00:00", true)])).toEqual([false]);
});
it("U4 行がある・100/100 → 数えない", () => {
expect(notExecutedOf([row(D, 100, "02:00:00", true)])).toEqual([false]);
});
it("U5 同じ日に U1 と U4 → その日を1日数える(OR)", () => {
const days = foldRescueDays([
row(D, null, "02:00:00", false),
row(D, 100, "03:00:00", true),
]);
expect(days.map((d) => d.not_executed)).toEqual([true]);
expect(countNotExecutedStreak(days)).toBe(1);
});
it("★U2 が3日続いても R-1 にならない(出発しているので現場に誤った連絡を飛ばさない)★", () => {
const rows = ["2026-09-01", "2026-09-02", "2026-09-03"].map((d) =>
row(d, null, "02:00:00", true)
);
expect(countNotExecutedStreak(foldRescueDays(rows))).toBe(0);
});
});
// ---------------------------------------------------------------- 表3: 施設日完了率
/**
* ★表3: 施設日完了率(facilityDayRate)= その日の1回ごとの完了率の単純平均★
* (指示書AQ 決定4 / §5-1・D-155。★2026-09-30 に面積加重から変更★)
*
* ★★判定は面積を読まず、渡された `completion_rate` だけを使う★★ — 1回ごとの完了率を面積から作るのは
* `runCompletionRate()`(データ層の `completionRateFromReport()` 経由)の仕事で、ここではない。
* 3-8・3-9 は「面積が付いたまま届いても判定がそれを読まない」ことの確認として残す。
*
* | # | その日の行(1回ごとの完了率 / 面積) | 期待 | 何を固定するか |
* |---|---|---|---|
* | 3-1 | [90] | 90 | 1行はその値 |
* | 3-2 | [90, 10] | 50 | 単純平均 |
* | ★3-3 | [90(900/1000㎡), 10(10/100㎡)] | ★50★(面積加重なら 82.7…) | ★面積で重み付けしない★ |
* | 3-4 | [0] | 0 | 0%で実施した(`null` ではない) |
* | ★3-5 | [未実施] | null | ★0除算を捏造しない★ |
* | 3-6 | [] | null | 行なし |
* | 3-7 | [未実施, 90] | 90 | ★未実施行は分母に入れない★(0% として混ぜない) |
* | ★3-8 | [61.0(1628/1000㎡ = 面積比162.8%)] | ★61★ | ★判定は面積を読まない★(渡された `completion_rate` だけ) |
* | ★3-9 | [100(138/1000㎡ = 面積比13.8%)] | ★100★ | 同上(逆向き) |
* | 3-10 | [89.5] | 89.5 | ★丸めない★(§4.3) |
*/
describe("表3: 施設日完了率(facilityDayRate)= 1回ごとの完了率の単純平均", () => {
/** ★同じ日の行を畳んでから率を出す★(`judgeRescue()` と同じ経路) */
const dayRate = (rows: readonly RescueRowInput[]) => facilityDayRate(foldRescueDays(rows));
const D = "2026-09-01";
it("3-1: [90] → 90", () => {
expect(dayRate([done(D, 90)])).toBe(90);
});
it("3-2: [90, 10] → 50", () => {
expect(dayRate([done(D, 90), done(D, 10, "03:00:00")])).toBe(50);
});
it("★3-3: [90(900/1000㎡), 10(10/100㎡)] → 50(★面積加重なら 82.7…★)★", () => {
// ★★この1本が「面積で重み付けしないこと」を固定する★★ — 3-1/3-2 は面積加重でも通る
const actual = dayRate([
doneWithArea(D, 90, 900, 1000),
doneWithArea(D, 10, 10, 100, "03:00:00"),
]);
expect(actual).toBe(50);
expect(actual).not.toBeCloseTo((910 / 1100) * 100, 5);
});
it("3-4: [0] → 0(★0%で実施した。`null` ではない★)", () => {
expect(dayRate([done(D, 0)])).toBe(0);
});
it("★3-5: [未実施] → null(0除算を捏造しない)★", () => {
expect(dayRate([notExecuted(D)])).toBeNull();
});
it("3-6: [] → null", () => {
expect(facilityDayRate([])).toBeNull();
expect(dayRate([])).toBeNull();
});
it("★3-7: [未実施, 90] → 90(未実施行は分母に入れない。0% として混ぜない)★", () => {
expect(dayRate([notExecuted(D), done(D, 90, "03:00:00")])).toBe(90);
});
it("★3-8: 面積比162.8%の面積が付いていても、渡された completion_rate の 61 を使う(判定は面積を読まない)★", () => {
expect(dayRate([doneWithArea(D, 61, 1628, 1000)])).toBe(61);
});
it("★3-9: 面積比13.8%の面積が付いていても、渡された completion_rate の 100 を使う(判定は面積を読まない)★", () => {
expect(dayRate([doneWithArea(D, 100, 138, 1000)])).toBe(100);
});
it("3-10: [89.5] → 89.5(丸めない)", () => {
expect(dayRate([done(D, 89.5)])).toBe(89.5);
});
});
// ---------------------------------------------------------------- 表4: R-3
describe("表4: R-3(急落。dropRatio = 0.2)", () => {
// ★閾値は設定値から読む(ハードコード禁止。`CLAUDE.md` §7)★
it("前提: 既定の閾値は 0.2(比。★20 ではない★)", () => {
expect(COMPLETION_DROP.dropRatio).toBe(0.2);
});
const dropped = (prev: number | null, cur: number | null) =>
judgeCompletionDrop(prev, cur).dropped;
it("★4-1: 100 → 80(ちょうど20%低下)は true(境界は「以上」)★", () => {
expect(dropped(100, 80)).toBe(true);
});
it("4-2: 100 → 80.1(19.9%低下)は false(★境界の反対側★)", () => {
expect(dropped(100, 80.1)).toBe(false);
});
it("4-3: 100 → 79.9(20.1%低下)は true", () => {
expect(dropped(100, 79.9)).toBe(true);
});
it("★4-4: 30 → 18(40%低下)は true(差なら12ポイントで拾えない。比を選んだ意味)★", () => {
expect(dropped(30, 18)).toBe(true);
expect(judgeCompletionDrop(30, 18).drop_ratio).toBeCloseTo(0.4, 10);
});
it("4-5: 80 → 60(25%低下)は true", () => {
expect(dropped(80, 60)).toBe(true);
});
it("★4-6: 60 → 80(上昇)は false(決定E。絶対値で見ない)★", () => {
expect(dropped(60, 80)).toBe(false);
});
it("★4-7: 0 → 0 は false(決定B。0で割らない)★", () => {
expect(dropped(0, 0)).toBe(false);
expect(judgeCompletionDrop(0, 0).drop_ratio).toBeNull();
});
it("★4-8: 0 → 50 は false(決定B)★", () => {
expect(dropped(0, 50)).toBe(false);
expect(judgeCompletionDrop(0, 50).drop_ratio).toBeNull();
});
it("★4-9: null → 50(前回が未実施)は false(決定C。比べる相手が無い)★", () => {
expect(dropped(null, 50)).toBe(false);
expect(judgeCompletionDrop(null, 50).drop_ratio).toBeNull();
});
it("★4-10: 50 → null(当日が未実施)は false(決定D。R-1 で拾う)★", () => {
expect(dropped(50, null)).toBe(false);
expect(judgeCompletionDrop(50, null).drop_ratio).toBeNull();
});
it("4-11: null → null は false", () => {
expect(dropped(null, null)).toBe(false);
});
it("★4-12: 前回が存在しない(初日)は false(比べる相手が無い)★", () => {
// 初日は `judgeRescue()` から見ると「前回の日が無い」= previous が null
const judgement = judgeRescue({
rows: [done("2026-09-01", 10)],
consecutive_problem_days: 0,
});
expect(judgement.previous_completion_rate).toBeNull();
expect(judgement.drop_ratio).toBeNull();
expect(judgement.reasons).not.toContain("completion_drop");
});
it("★★4-13: 予定のない日を挟んでも「前回のスケジュール存在日」と比べる(決定A)★★", () => {
// 施設7 の実データ: 09-07 が 94.7% → 09-09 が 72.5%(09-08 は予定なし)。
// ★カレンダー前日(09-08)と比べる実装だと算出不可になり、拾えない★
const judgement = judgeRescue({
rows: [done("2026-09-07", 94.7), done("2026-09-09", 72.5)],
consecutive_problem_days: 0,
});
expect(judgement.previous_completion_rate).toBeCloseTo(94.7, 10);
expect(judgement.completion_rate).toBeCloseTo(72.5, 10);
expect(judgement.drop_ratio).toBeCloseTo((94.7 - 72.5) / 94.7, 10);
expect(judgement.reasons).toContain("completion_drop");
expect(judgement.required).toBe(true);
});
});
// ---------------------------------------------------------------- 表5: 合成
describe("表5: 3条件の合成(judgeRescue)", () => {
/** R-1 だけを立てる行(3日連続で未実施) */
const notExecuted3Days = [
notExecuted("2026-09-01"),
notExecuted("2026-09-02"),
notExecuted("2026-09-03"),
];
/** どの条件にも当たらない行 */
const healthy = [done("2026-09-01", 95), done("2026-09-02", 95)];
/** R-3 だけを立てる行(100% → 79% = 21%低下) */
const droppedRows = [done("2026-09-01", 100), done("2026-09-02", 79)];
it("5-1: 3つとも false なら要レスキューでない", () => {
const j = judgeRescue({ rows: healthy, consecutive_problem_days: 0 });
expect(j.required).toBe(false);
expect(j.reasons).toEqual([]);
});
it("5-2: R-1 だけ → 要レスキュー(理由: 未実施3日以上)", () => {
// ★実データでは未実施日は問題日でもあるので R-2 も同時に立つ★。
// ここは合成そのものを確かめるため、連続問題日数を 0 として切り離している
const j = judgeRescue({ rows: notExecuted3Days, consecutive_problem_days: 0 });
expect(j.required).toBe(true);
expect(j.reasons).toEqual(["not_executed_streak"]);
expect(j.not_executed_days).toBe(3);
});
it("5-3: R-2 だけ → 要レスキュー(理由: L3)", () => {
const j = judgeRescue({ rows: healthy, consecutive_problem_days: 3 });
expect(j.required).toBe(true);
expect(j.reasons).toEqual(["escalation_l3"]);
expect(j.consecutive_problem_days).toBe(3);
});
it("5-4: R-3 だけ → 要レスキュー(理由: 急落)", () => {
const j = judgeRescue({ rows: droppedRows, consecutive_problem_days: 0 });
expect(j.required).toBe(true);
expect(j.reasons).toEqual(["completion_drop"]);
expect(j.drop_ratio).toBeCloseTo(0.21, 10);
});
it("★5-5: 3つとも true → 理由を3つとも返す(1つに絞らない)★", () => {
// ★R-1 と R-3 が同時に立つのは「同じ日に未実施の台と実施した台が混在する」とき★
// (1-10 の OR と 3-7 の「未実施行は分母に入れない」が同時に効く)。実データにありうる形
const rows = [
notExecuted("2026-09-01"),
done("2026-09-01", 90, "03:00:00"),
notExecuted("2026-09-02"),
done("2026-09-02", 90, "03:00:00"),
notExecuted("2026-09-03"),
done("2026-09-03", 50, "03:00:00"),
];
const j = judgeRescue({ rows, consecutive_problem_days: 4 });
expect(j.required).toBe(true);
expect(j.reasons).toEqual([
"not_executed_streak",
"escalation_l3",
"completion_drop",
]);
expect(j.not_executed_days).toBe(3);
expect(j.consecutive_problem_days).toBe(4);
expect(j.completion_rate).toBe(50);
expect(j.previous_completion_rate).toBe(90);
expect(j.drop_ratio).toBeCloseTo((90 - 50) / 90, 10);
});
it("★理由は識別子で返す(日本語を作らない。D-111)★", () => {
const j = judgeRescue({ rows: notExecuted3Days, consecutive_problem_days: 5 });
for (const reason of j.reasons) {
// ★和文が混ざっていないこと★ — 混ざると EN 切替で日本語が残る
expect(reason).toMatch(/^[a-z0-9_]+$/);
}
});
it("★空配列なら required は false(型の約束。指示書AI §5-3)★", () => {
const j = judgeRescue({ rows: [], consecutive_problem_days: 0 });
expect(j.reasons).toEqual([]);
expect(j.required).toBe(false);
expect(j.completion_rate).toBeNull();
expect(j.previous_completion_rate).toBeNull();
expect(j.drop_ratio).toBeNull();
expect(j.not_executed_days).toBe(0);
});
it("★付帯情報は丸めない生値(`CLAUDE.md` §4.3 / §9.3)★", () => {
// 89.5% は表示すると 90% だが、判定にも付帯情報にも丸めた値を使わない
const j = judgeRescue({
rows: [done("2026-09-01", 100), done("2026-09-02", 89.5)],
consecutive_problem_days: 0,
});
expect(j.completion_rate).toBe(89.5);
expect(j.completion_rate).not.toBe(90);
});
it("★行は日付昇順でなくてよい(内部で並べ替える)★", () => {
const shuffled = [
done("2026-09-09", 72.5),
done("2026-09-07", 94.7),
];
const j = judgeRescue({ rows: shuffled, consecutive_problem_days: 0 });
expect(j.completion_rate).toBeCloseTo(72.5, 10);
expect(j.previous_completion_rate).toBeCloseTo(94.7, 10);
});
it("★DBアクセスを混ぜない純粋関数(同じ入力で必ず同じ結果)★", () => {
const input = { rows: droppedRows, consecutive_problem_days: 3 };
expect(judgeRescue(input)).toEqual(judgeRescue(input));
});
});
// ---------------------------------------------------------------- 表6: 面積加重(旧)→ 1回ごとの完了率の平均(新)で結果が変わるケース
/**
* ★表6: R-3 の判定が面積加重(旧)と1回ごとの完了率の単純平均(新)で変わるケース★
* (指示書AQ 決定4 / §5-1。2026-09-30)。
* ★表の「clean_ratio」は判定に渡す1回ごとの完了率(`completion_rate`)のこと★(D-155 以降はデータ層が
* 面積から `runCompletionRate()` で作る)。★判定は面積を読まず、渡された `completion_rate` だけを使う★ —
* 面積は「付いたまま届いても読まない」ことの確認のために付けてある。
*
* ★★「判定結果が変わったこと」を仕様として固定する★★ — 面積比に戻すと、ここが落ちる。
* ★数値の出所★: 6-1 / 6-2 は 3-8 / 3-9 と同じくユーザー提供CSV `clean_task_reports.csv`(189行)の
* 個別行の乖離例(面積比 13.8% ↔ `clean_ratio` 100.0% / 面積比 162.8% ↔ `clean_ratio` 61.0%)を借りた作り値。
* 6-3 / 6-4 は2台構成の施設を想定した作り値。
*
* | # | 前回(clean_ratio / 面積) | 当日(clean_ratio / 面積) | 旧: 面積比 | 新: clean_ratio 平均 |
* |---|---|---|---|---|
* | 6-1 | 80 / 1628÷1000 | 80 / 1000÷1000 | 162.8 → 100 = 38.6%低下 ★急落★ | 80 → 80 = 0% ★急落しない★ |
* | 6-2 | 100 / 138÷1000 | 61 / 1628÷1000 | 13.8 → 162.8 = 上昇 ★急落しない★ | 100 → 61 = 39%低下 ★急落★ |
* | 6-3 | 90 / 900÷1000 | 90 / 900÷1000 + 50 / 10÷100 | 90 → 82.7 = 8.1%低下 ★急落しない★ | 90 → 70 = 22.2%低下 ★急落★ |
* | 6-4 | 90 / 900÷1000 | 60 / 600÷1000 + 100 / 100÷100 | 90 → 63.6 = 29.3%低下 ★急落★ | 90 → 80 = 11.1%低下 ★急落しない★ |
*/
describe("表6: 面積比と1回ごとの完了率の平均で R-3 の結果が変わるケース", () => {
const PREV = "2026-09-01";
const TODAY = "2026-09-02";
const judge = (rows: readonly RescueRowInput[]) =>
judgeRescue({ rows, consecutive_problem_days: 0 });
it("★6-1: 面積比では急落(162.8→100)だが、clean_ratio は 80→80 なので急落しない★", () => {
const j = judge([
doneWithArea(PREV, 80, 1628, 1000),
doneWithArea(TODAY, 80, 1000, 1000),
]);
expect(j.previous_completion_rate).toBe(80);
expect(j.completion_rate).toBe(80);
expect(j.drop_ratio).toBe(0);
expect(j.reasons).not.toContain("completion_drop");
expect(j.required).toBe(false);
});
it("★6-2: 面積比では上昇(13.8→162.8)だが、clean_ratio は 100→61 で急落★", () => {
const j = judge([
doneWithArea(PREV, 100, 138, 1000),
doneWithArea(TODAY, 61, 1628, 1000),
]);
expect(j.previous_completion_rate).toBe(100);
expect(j.completion_rate).toBe(61);
expect(j.drop_ratio).toBeCloseTo(0.39, 10);
expect(j.reasons).toEqual(["completion_drop"]);
});
it("★6-3: 小面積の台が低率 — 面積加重では薄まって急落しないが、単純平均では急落★", () => {
const j = judge([
doneWithArea(PREV, 90, 900, 1000),
doneWithArea(TODAY, 90, 900, 1000),
doneWithArea(TODAY, 50, 10, 100, "03:00:00"),
]);
expect(j.completion_rate).toBe(70);
expect(j.drop_ratio).toBeCloseTo((90 - 70) / 90, 10);
expect(j.reasons).toEqual(["completion_drop"]);
});
it("★6-4: 大面積の台が低率 — 面積加重では急落だが、単純平均では急落しない★", () => {
const j = judge([
doneWithArea(PREV, 90, 900, 1000),
doneWithArea(TODAY, 60, 600, 1000),
doneWithArea(TODAY, 100, 100, 100, "03:00:00"),
]);
expect(j.completion_rate).toBe(80);
expect(j.drop_ratio).toBeCloseTo((90 - 80) / 90, 10);
// ★閾値は設定値から読む(ハードコードしない)★
expect(j.drop_ratio as number).toBeLessThan(COMPLETION_DROP.dropRatio);
expect(j.reasons).not.toContain("completion_drop");
});
});