Newer
Older
client_dwatch / lib / judge / __tests__ / advice.test.ts
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);
  });
});