import { describe, expect, it } from "vitest";
import { matchAdviceRules, maxAdviceLevel, type AdviceInput } from "@/lib/judge";
import { DEFAULT_ADVICE_RULES } from "@/lib/config/advice-rules";
/**
* 対応アドバイスの発火条件テスト
*
* ★ここでテストしているのは「どのルールが該当するか」だけです★
* 文言(title / text / actions)は運用設定で編集できるため、テストしません。
* 文言を変えてもこのテストは壊れません。
*/
function input(over: Partial<AdviceInput> = {}): AdviceInput {
return {
departed: true,
completionRate: 95,
consecutiveProblemDays: 0,
status1h: "待機中",
status30m: "待機中",
...over,
};
}
describe("matchAdviceRules — 正常な行", () => {
it("◎の行にはアドバイスを出さない", () => {
expect(matchAdviceRules(input({ completionRate: 95 }))).toEqual([]);
});
it("○の行にもアドバイスを出さない", () => {
expect(
matchAdviceRules(input({ completionRate: 75, consecutiveProblemDays: 0 }))
).toEqual([]);
});
});
describe("R1 — 出発失敗(初回)", () => {
it("未実施(完了率null)でもR1が出る", () => {
// モックは完了率0を条件にしていたが、実データでは未出発なら null になる
const r = matchAdviceRules(
input({ departed: false, completionRate: null, consecutiveProblemDays: 1 })
);
expect(r).toContain("R1");
});
it("完了率0の出発失敗でもR1が出る", () => {
const r = matchAdviceRules(
input({ departed: false, completionRate: 0, consecutiveProblemDays: 1 })
);
expect(r).toContain("R1");
});
it("2日目以降はR1を出さない(R3/R4が担当する)", () => {
const r = matchAdviceRules(
input({ departed: false, completionRate: null, consecutiveProblemDays: 2 })
);
expect(r).not.toContain("R1");
expect(r).toContain("R3");
});
});
describe("R2 — 完了率×(10%未満)", () => {
it("9.9%はR2", () => {
expect(matchAdviceRules(input({ completionRate: 9.9 }))).toContain("R2");
});
it("10.0%はR2ではない(×は10%未満。10.0は△。D-139)", () => {
const r = matchAdviceRules(input({ completionRate: 10.0 }));
expect(r).not.toContain("R2");
expect(r).toContain("B1"); // △なのでマップ更新の確認が出る
});
it("0%はR2", () => {
expect(matchAdviceRules(input({ completionRate: 0 }))).toContain("R2");
});
it("未実施(null)はR2ではない(出発していないのでR1の領域)", () => {
const r = matchAdviceRules(
input({ departed: false, completionRate: null, consecutiveProblemDays: 1 })
);
expect(r).not.toContain("R2");
});
});
describe("R3 / R4 — 連続日数", () => {
it("1日ではR3もR4も出ない", () => {
const r = matchAdviceRules(
input({ completionRate: 45, consecutiveProblemDays: 1 })
);
expect(r).not.toContain("R3");
expect(r).not.toContain("R4");
});
it("2日連続でR3", () => {
const r = matchAdviceRules(
input({ completionRate: 45, consecutiveProblemDays: 2 })
);
expect(r).toContain("R3");
expect(r).not.toContain("R4");
});
it("3日連続でR3とR4の両方(段階的な対応を促すため)", () => {
const r = matchAdviceRules(
input({ completionRate: 45, consecutiveProblemDays: 3 })
);
expect(r).toContain("R3");
expect(r).toContain("R4");
});
it("5日連続でもR3とR4", () => {
const r = matchAdviceRules(
input({ completionRate: 22, consecutiveProblemDays: 5 })
);
expect(r).toContain("R3");
expect(r).toContain("R4");
});
});
// ★指示書V 段階3(不具合B)決定4 — 連続日数を施設単位にした帰結★
// (2026-09-11 ユーザー承認済み。R3/R4は「現場に行くか」の判断なので施設単位が正しい)。
// ★matchAdviceRules() 自体は変更していない★ — このテストは既存関数の挙動を
// 「意図した結果」として記録するために書く(気づいた人が元に戻さないように)。
describe("決定4 — 連続日数を施設単位にした帰結(意図した結果)", () => {
it("c1: 正常終了した行にも R3/R4 が出る(別の台のせいで施設の連続が3日以上)", () => {
const r = matchAdviceRules(
input({ departed: true, completionRate: 99, consecutiveProblemDays: 3 })
);
expect(r).toContain("R3");
expect(r).toContain("R4");
});
it("c2: 別の台のせいで、その台にとって初回の出発失敗でもR1が出ない", () => {
const r = matchAdviceRules(
input({ departed: false, completionRate: null, consecutiveProblemDays: 2 })
);
expect(r).not.toContain("R1");
expect(r).toContain("R3");
});
});
describe("Phase B — B1/B2/B3", () => {
it("B1: △判定でマップ更新の確認", () => {
expect(matchAdviceRules(input({ completionRate: 45 }))).toContain("B1");
expect(matchAdviceRules(input({ completionRate: 69.9 }))).toContain("B1");
});
it("B1: ○以上では出ない", () => {
expect(matchAdviceRules(input({ completionRate: 70.1 }))).not.toContain("B1");
});
it("★B1: 70.0%では出ない(70%以上が○。D-139。旧 D-020b では出ていた)★", () => {
// ユーザー指示 2026-09-16「◯、×は90%以上のように境はふくめてください」で
// 70.0 が △ → ○ に変わったため、△判定に紐づく B1 も出なくなる。
// ★`lib/judge/advice.ts` は変更していない★(judgeGrade() の結果を見ているだけ)。
expect(matchAdviceRules(input({ completionRate: 70.0 }))).not.toContain("B1");
});
// ★B2/B3 の発火条件は lib/config/robot-status-match.ts の設定値とのマッチ(D-078 項2)★
// B2 のマッチ値 "Handrail Lifted" は暫定値。受領済み24値リストには含まれておらず、
// ユーザーも「〜だと思いますが」と述べており確定ではない(段階7-B。D-078 項2)。
// B3 のマッチ配列は現状空(値が未判明)。以下の期待値表を先に作ってからテストする(CLAUDE.md §9.4):
// # | 入力 | 期待 | 何を固定するか
// 1 | status1h: "Handrail Lifted" | B2を含む | B2の暫定値で発火する
// 2 | status30m: "Handrail Lifted" | B2を含む | 2回目(status30m)でも発火する
// 3 | status1h: " Handrail Lifted "(前後空白) | B2を含む | trimして比較している
// 4 | status1h: "handrail lifted" / "HANDRAIL..." | B2を含む | 大小文字を無視して比較している
// 5 | status1h: "ハンドルアップ"(旧値) | B2を含まない | 日本語完全一致が本当に消えたこと
// 6 | status1h: "EmergencyStop" | B2もB3も含まない | 無関係な値では発火しない
// 7 | status1h/30m: "オフライン"(B3マッチ配列が空) | B3を含まない | 空配列なら発火しない(今回の核心)
// 8 | status1h/30m: undefined | B2もB3も含まない・例外を投げない | optional引数で落ちない
// 9 | status1h: ""(空文字) | B2もB3も含まない | 空文字でマッチしない
it("B2 #1: 事前チェック1回目が暫定値 Handrail Lifted で発火する", () => {
expect(
matchAdviceRules(input({ status1h: "Handrail Lifted" }))
).toContain("B2");
});
it("B2 #2: 事前チェック2回目が暫定値 Handrail Lifted でも発火する", () => {
expect(
matchAdviceRules(input({ status30m: "Handrail Lifted" }))
).toContain("B2");
});
it("B2 #3: 前後に空白があっても trim して発火する", () => {
expect(
matchAdviceRules(input({ status1h: " Handrail Lifted " }))
).toContain("B2");
});
it("B2 #4: 大小文字が違っても発火する", () => {
expect(
matchAdviceRules(input({ status1h: "handrail lifted" }))
).toContain("B2");
expect(
matchAdviceRules(input({ status1h: "HANDRAIL LIFTED" }))
).toContain("B2");
});
it("B2 #5: 旧値の日本語完全一致「ハンドルアップ」ではもう発火しない", () => {
expect(
matchAdviceRules(input({ status1h: "ハンドルアップ" }))
).not.toContain("B2");
});
it("B2/B3 #6: 無関係な値では発火しない", () => {
const r = matchAdviceRules(input({ status1h: "EmergencyStop" }));
expect(r).not.toContain("B2");
expect(r).not.toContain("B3");
});
it("B3 #7: マッチ配列が空なので「オフライン」でも発火しない(これが今回の核心)", () => {
// ★このテストは B3_OFFLINE_STATUSES が空である限り何を渡しても通る★
// それでも実データで値が判明し設定値に足された瞬間にこのテストは落ちる。
// 「落ちたら値を足した合図であり、バグではない」(B3の期待挙動が変わったことを知らせる)
expect(matchAdviceRules(input({ status1h: "オフライン" }))).not.toContain(
"B3"
);
expect(
matchAdviceRules(input({ status30m: "オフライン" }))
).not.toContain("B3");
});
it("B2/B3 #8: status1h/status30m が undefined でも例外を投げない", () => {
const r = matchAdviceRules(
input({ status1h: undefined, status30m: undefined })
);
expect(r).not.toContain("B2");
expect(r).not.toContain("B3");
});
it("B2/B3 #9: 空文字ではマッチしない", () => {
const r = matchAdviceRules(input({ status1h: "", status30m: "" }));
expect(r).not.toContain("B2");
expect(r).not.toContain("B3");
});
// ★null = その回の事前チェックの予定が無い(D-171 / 列は順番で割り当てる。D-178)★(API の `status_1h` / `status_30m`。
// ユーザー指示 2026-10-07「事前チェックがないものは-にしてください」。D-171)
// 10 | status1h/30m: null | B2もB3も含まない・例外を投げない | null はどのルールにも当たらない
// 11 | status1h: null / status30m: "Handrail Lifted" | B2を含む | 片方が null でももう片方は見る
it("B2/B3 #10: status1h/status30m が null(事前チェックの予定が無い)でも例外を投げず、B2/B3 は当たらない", () => {
expect(() =>
matchAdviceRules(input({ status1h: null, status30m: null }))
).not.toThrow();
const r = matchAdviceRules(input({ status1h: null, status30m: null }));
expect(r).not.toContain("B2");
expect(r).not.toContain("B3");
// ★null は判定(◎○△×・R1〜R4)にも効かない★(D-170。事前チェックは判定に使わない)
expect(r).toEqual([]);
});
it("B2 #11: 1回目が null でも、2回目が Handrail Lifted なら発火する", () => {
expect(
matchAdviceRules(input({ status1h: null, status30m: "Handrail Lifted" }))
).toContain("B2");
});
});
describe("複数該当", () => {
it("#10: 3日連続のオフライン出発失敗は R3 / R4 のみ(B3は空配列なので発火しない)", () => {
const r = matchAdviceRules(
input({
departed: false,
completionRate: null,
consecutiveProblemDays: 3,
status1h: "オフライン",
status30m: "オフライン",
})
);
expect(r).toEqual(["R3", "R4"]);
});
it("#11: 初回の Handrail Lifted 出発失敗は R1 / B2", () => {
const r = matchAdviceRules(
input({
departed: false,
completionRate: null,
consecutiveProblemDays: 1,
status1h: "Handrail Lifted",
status30m: "Handrail Lifted",
})
);
expect(r).toEqual(["R1", "B2"]);
});
});
describe("maxAdviceLevel — 行バッジの色分け", () => {
it("該当ルールの最大レベルを返す", () => {
const rules = ["R3", "R4", "B3"].map(
(id) => DEFAULT_ADVICE_RULES[id as "R3"]
);
expect(maxAdviceLevel(rules)).toBe(3); // R4 が level 3
});
it("該当なしは0", () => {
expect(maxAdviceLevel([])).toBe(0);
});
it("R1とB2だけならレベル1", () => {
const rules = [DEFAULT_ADVICE_RULES.R1, DEFAULT_ADVICE_RULES.B2];
expect(maxAdviceLevel(rules)).toBe(1);
});
});
// ★表11(指示書BB A13・D21・D-182)★ — R1 の閾値は `level2Days` から(旧: `<= 1` の直書き)。
// `level2Days = 3` の設定でも R1 と R3 の境目が閾値に追随すること
describe("R1 の閾値は設定から(指示書BB 表11)", () => {
const failed = (days: number, departed = false) =>
input({ departed, completionRate: departed ? 95 : null, consecutiveProblemDays: days });
const custom = { level2Days: 3, level3Days: 4 };
it.each([
["T1(既定): 出発失敗・0日 → R1 出る / R3 出ない", undefined, false, 0, true, false],
["T2(既定): 出発失敗・1日 → R1 出る / R3 出ない", undefined, false, 1, true, false],
["T3(既定): 出発失敗・2日 → R1 出ない / R3 出る", undefined, false, 2, false, true],
["★T4(level2Days 3): 出発失敗・2日 → R1 出る / R3 出ない★", custom, false, 2, true, false],
["T5(level2Days 3): 出発失敗・3日 → R1 出ない / R3 出る", custom, false, 3, false, true],
["T6(level2Days 3): 出発している・1日 → R1 / R3 とも出ない", custom, true, 1, false, false],
] as const)("%s", (_label, thresholds, departed, days, r1, r3) => {
const r = thresholds === undefined ? matchAdviceRules(failed(days, departed)) : matchAdviceRules(failed(days, departed), thresholds);
expect(r.includes("R1")).toBe(r1);
expect(r.includes("R3")).toBe(r3);
});
});