/**
 * 주입 하네스의 **순수 영역** spec. §D2 4 가 요구하는 "분류기 spec 을 실제 코드가 만들어내는
 * 문자열에 대해 고정한다"가 이 파일의 존재 이유다 — 메시지 형식이 바뀌면 여기가 먼저 깨진다.
 *
 * Nest 를 부팅하지 않는다. `alert-inject.ts` 는 Nest·DB 접근을 전부 함수 안 lazy require 로
 * 두었고 `require.main === module` 가드가 있어, import 만으로는 아무것도 실행되지 않는다.
 */
import { readFileSync } from 'fs';
import { join } from 'path';
import {
  classifySeverity,
  parseArgs,
  parseDurationMs,
  parseLedger,
  emptyLedger,
  ledgerIsEmpty,
  ledgerIsActive,
  assertWritableDatabase,
  resolveScheduleExpect,
  DB_DOWN_SKIPS,
  SCENARIOS,
} from './alert-inject';
import type { ScenarioObservation } from './alert-inject';

/** 관측 1건. 판정식이 보는 필드 전부를 0 으로 채우고 필요한 것만 덮는다. */
const obs = (over: Partial<ScenarioObservation> = {}): ScenarioObservation => ({
  delivered: 0,
  critical: 0,
  warn: 0,
  recovery: 0,
  digest: 0,
  test: 0,
  unclassified: 0,
  criticalDestinations: 0,
  invalid: 0,
  ...over,
});

const scenario = (id: string) => {
  const s = SCENARIOS.find((x) => x.id === id);
  if (!s) throw new Error(`시나리오 '${id}' 가 카탈로그에 없다`);
  return s;
};

describe('assertWritableDatabase — 잘못된 DB 에 실패 결제 행을 넣지 않는다', () => {
  it('-dev 로 끝나면 통과한다', () => {
    expect(() => assertWritableDatabase('insuniverse-dev', undefined)).not.toThrow();
  });

  //* stg 는 접미사가 없다. 포트가 dev 와 인접해 터널 설정 하나로 뒤바뀌므로 이름으로 가린다.
  it('접미사 없는 이름은 거부한다', () => {
    expect(() => assertWritableDatabase('insuniverse', undefined)).toThrow(/dev 가 아니다/);
  });

  it('빈 이름은 거부한다 (대상을 확인할 수 없다)', () => {
    expect(() => assertWritableDatabase('', undefined)).toThrow(/비어 있다/);
    expect(() => assertWritableDatabase(undefined, undefined)).toThrow(/비어 있다/);
  });

  it('ALERT_INJECT_ALLOW_DB 가 정확히 일치할 때만 연다', () => {
    expect(() => assertWritableDatabase('insuniverse', 'insuniverse')).not.toThrow();
    //* 접두·부분 일치로는 열리지 않는다 — 오조작 방지의 본체가 이것이다.
    expect(() => assertWritableDatabase('insuniverse', 'insuniverse-dev')).toThrow(/dev 가 아니다/);
    expect(() => assertWritableDatabase('insuniverse', 'insu')).toThrow(/dev 가 아니다/);
    expect(() => assertWritableDatabase('insuniverse', '*')).toThrow(/dev 가 아니다/);
  });
});

describe('classifySeverity — 실제 발송 문자열 고정 케이스', () => {
  //* 출처: alert-noise.ts:52-56 formatFindingLine + alert.service.ts:630 withLabel
  it('라벨 접두를 벗기고 critical 을 잡는다', () => {
    expect(classifySeverity('[stg] 🔴 [위험] 일반FC 정기결제 실패 5/5건 (최근 10분)')).toBe('critical');
  });

  it('라벨 접두를 벗기고 warn 을 잡는다', () => {
    expect(classifySeverity('[stg] 🟠 [주의] API 5xx 비율 예외 12/40건 (최근 10분)')).toBe('warn');
  });

  //* 출처: alert.service.ts:526 — 복구 본문은 원래 critical 문구를 **품는다**.
  //*  포함 검사를 썼다면 여기서 critical 로 오분류되고 M3 복구 횟수와 M9 critical 0 이 동시에 깨진다.
  it('복구가 critical 본문을 품어도 복구로 분류한다 (first-match-wins, 복구 우선)', () => {
    expect(classifySeverity('[stg] ✅ [복구] 🔴 [위험] 일반FC 정기결제 실패 5/5건')).toBe('recovery');
  });

  //* 출처: alert.service.ts:919 digestText 머리말
  it('다이제스트 머리말을 잡는다', () => {
    expect(classifySeverity('[stg] 🟠 [요약] 누적 경고 3종\n• 무언가')).toBe('digest');
  });

  //* 출처: alert-admin.controller.ts:208. `withLabel` 을 거치지 않으므로 **라벨이 없다.**
  //*  plan §D2 는 이 접두를 `🧪` 로 적었으나 실제 코드는 `🔔` 다 — 코드가 사실이다.
  it('라벨 없는 POST /test 기본 메시지를 test 로 잡는다', () => {
    expect(classifySeverity('🔔 [테스트] 운영 알림 채널 연결 확인')).toBe('test');
  });

  //* 출처: alert.service.ts 스누즈 해제 요약. **마일스톤 3 이 신설한 종류다.**
  //*  이 어휘가 빠지면 요약이 미분류로 떨어지고 "미분류가 있으면 측정 무효"가 걸려,
  //*  발송이 정상인데도 V12·V18·V20·V35 의 측정이 통째로 버려진다.
  it('스누즈 해제 요약을 잡는다 (M3 신설 어휘)', () => {
    expect(
      classifySeverity('[dev] 🟠 [스누즈 중 경과] 스누즈 구간에 발생했다 해소된 항목 2건'),
    ).toBe('snooze-summary');
  });

  //* 세 `🟠` 어휘가 서로를 삼키지 않는지 — 리터럴이 서로의 접두가 아님을 고정한다.
  it('세 가지 🟠 어휘가 서로 오분류되지 않는다', () => {
    expect(classifySeverity('[dev] 🟠 [요약] 누적 경고 1종')).toBe('digest');
    expect(classifySeverity('[dev] 🟠 [주의] API 요청 카운터 0건')).toBe('warn');
    expect(classifySeverity('[dev] 🟠 [스누즈 중 경과] 해소된 항목 1건')).toBe('snooze-summary');
  });

  it('어휘에 없는 본문은 미분류로 남긴다 (조용히 아무 통에나 넣지 않는다)', () => {
    expect(classifySeverity('[stg] 알 수 없는 형식의 메시지')).toBe('unclassified');
    expect(classifySeverity('')).toBe('unclassified');
  });
});

describe('parseArgs — 조용한 강등을 만들지 않는다', () => {
  it('poll 회차와 --advance 를 읽는다', () => {
    const a = parseArgs(['poll', '288', '--advance', '5m']);
    expect(a.command).toBe('poll');
    expect(a.rounds).toBe(288);
    expect(a.advanceMs).toBe(300_000);
    expect(a.expectDbDown).toBe(false);
  });

  //* R1 F4 — 오타가 일반 poll 로 조용히 강등되면 §D3-6 예외가 무력화된다.
  it('알 수 없는 플래그는 던진다', () => {
    expect(() => parseArgs(['poll', '--expect-db-dwon'])).toThrow(/알 수 없는 플래그/);
  });

  it('--expect-db-down 은 poll 전용이다', () => {
    expect(parseArgs(['poll', '--expect-db-down']).expectDbDown).toBe(true);
    expect(() => parseArgs(['cleanup', '--expect-db-down'])).toThrow(/poll 전용/);
  });

  //* §D7 키별 조작 표 — digestSentAt 역행과 hb 갱신은 **짝**이다. 갱신 없이 역행만 하면
  //*  (또는 hb 키가 아예 없으면) 유예 만료와 함께 A3 가 떠서 하네스가 장애를 만든다.
  it('--advance 는 --sim-heartbeat 를 함의한다', () => {
    const a = parseArgs(['poll', '288', '--advance', '5m']);
    expect(a.simHeartbeat).toBe(true);
  });

  it('--sim-heartbeat 는 단독으로도 쓸 수 있다 (9-a 실시간 경과 방어)', () => {
    const a = parseArgs(['poll', '288', '--sim-heartbeat']);
    expect(a.simHeartbeat).toBe(true);
    expect(a.advanceMs).toBe(0);
  });

  it('inject/clear 는 신호 축을 강제한다', () => {
    expect(parseArgs(['inject', 'a1']).signal).toBe('a1');
    expect(() => parseArgs(['inject'])).toThrow(/a1\|a3\|a4/);
    expect(() => parseArgs(['inject', 'a9'])).toThrow(/a1\|a3\|a4/);
  });

  //* 부팅 뒤에 던지면 그때는 a4 가 이미 대장에 올라가 다음 주입이 막힌다.
  it('inject a4 는 --base 나 --redis 없이는 파싱 단계에서 막힌다', () => {
    expect(() => parseArgs(['inject', 'a4'])).toThrow(/--base .* 또는 --redis/);
    expect(parseArgs(['inject', 'a4', '--redis']).redis).toBe(true);
    expect(parseArgs(['inject', 'a4', '--base', 'http://x:3000/']).base).toBe('http://x:3000');
  });

  it('--sample 은 over|under 만 받는다', () => {
    expect(parseArgs(['inject', 'a1', '--sample', 'under']).sample).toBe('under');
    expect(() => parseArgs(['inject', 'a1', '--sample', 'maybe'])).toThrow(/over\|under/);
  });

  it('값이 빠진 플래그를 던진다', () => {
    expect(() => parseArgs(['poll', '--advance'])).toThrow(/값이 필요하다/);
  });

  //* 플래그 **이름**만 검증하면 조용한 강등이 값 쪽으로 옮겨갈 뿐이다. `Number('abc')` 는 NaN 이고
  //*  `for (n = 0; n < NaN; n++)` 는 0회 돌아 5xx 가 한 건도 안 생긴다 — 그 침묵이 "자연 500 경로
  //*  없음"으로 오독되고 --redis 강등이 근거 없이 선택된다.
  it('--ok/--bad 는 0 이상의 정수만 받는다', () => {
    expect(parseArgs(['inject', 'a4', '--redis', '--ok', '0', '--bad', '7']).bad).toBe(7);
    expect(() => parseArgs(['inject', 'a4', '--bad', 'abc'])).toThrow(/0 이상의 정수/);
    expect(() => parseArgs(['inject', 'a4', '--bad', '-1'])).toThrow(/0 이상의 정수/);
    expect(() => parseArgs(['inject', 'a4', '--ok', '2.5'])).toThrow(/0 이상의 정수/);
  });
});

describe('resolveScheduleExpect — 규칙과 같은 감시 목록을 본다', () => {
  const FALLBACK = { 'fax-status': 60_000, 'alimtalk-status': 300_000 };

  it('오버라이드가 없으면 기본 목록이다', () => {
    expect(resolveScheduleExpect(null, FALLBACK)).toEqual(FALLBACK);
    expect(resolveScheduleExpect('{not json', FALLBACK)).toEqual(FALLBACK);
    expect(resolveScheduleExpect('[1,2]', FALLBACK)).toEqual(FALLBACK);
  });

  //* 이것이 H2 의 본체다 — 기본값(1분)으로 되돌린 하트비트는 오버라이드(60분) × grace 안에 있다.
  it('오버라이드된 기대 주기를 쓴다', () => {
    const e = resolveScheduleExpect(JSON.stringify({ 'fax-status': 3_600_000 }), FALLBACK);
    expect(e['fax-status']).toBe(3_600_000);
  });

  it('오버라이드에만 있는 이름도 감시 목록이다', () => {
    const e = resolveScheduleExpect(JSON.stringify({ 'new-batch': 120_000 }), FALLBACK);
    expect(e['new-batch']).toBe(120_000);
  });

  //* 규칙(`schedule-heartbeat.rule.ts`)과 같은 항목 검증. `Number(true)` 는 1 이라 강제 변환하면
  //*  `true` 가 1ms 주기로 통과하고 그 스케줄러가 영구 오탐 critical 이 된다.
  it.each([
    ['숫자 문자열', { 'fax-status': '60000' }],
    ['불리언', { 'fax-status': true }],
    ['0 이하', { 'fax-status': 0 }],
    ['대문자 이름', { 'Fax-Status': 60_000 }],
  ])('%s 항목은 버린다', (_label, bad) => {
    expect(resolveScheduleExpect(JSON.stringify(bad), FALLBACK)).toEqual(FALLBACK);
  });

  it('유효 항목이 하나라도 있으면 그것만 쓴다 (기본 목록과 병합하지 않는다)', () => {
    const e = resolveScheduleExpect(
      JSON.stringify({ 'fax-status': 120_000, 'BAD NAME': 1 }),
      FALLBACK,
    );
    expect(e).toEqual({ 'fax-status': 120_000 });
  });
});

describe('parseDurationMs', () => {
  it('단위를 읽는다', () => {
    expect(parseDurationMs('5m')).toBe(300_000);
    expect(parseDurationMs('30s')).toBe(30_000);
    expect(parseDurationMs('250ms')).toBe(250);
    expect(parseDurationMs('250')).toBe(250);
  });
  it('형식 오류를 던진다', () => {
    expect(() => parseDurationMs('5분')).toThrow(/기간 형식/);
  });
});

describe('parseLedger — 손상된 대장이 추측 삭제를 유발하지 않는다', () => {
  it('빈 값·깨진 JSON 은 빈 대장이다 (§D3 4: 대장 없으면 아무것도 지우지 않는다)', () => {
    expect(ledgerIsEmpty(parseLedger(null))).toBe(true);
    expect(ledgerIsEmpty(parseLedger(''))).toBe(true);
    expect(ledgerIsEmpty(parseLedger('{not json'))).toBe(true);
    expect(ledgerIsEmpty(parseLedger('[1,2,3]'))).toBe(true);
  });

  it('id 배열과 kv 원복 대장을 보존한다', () => {
    const l = parseLedger(
      JSON.stringify({
        createdAt: 1,
        injected: ['a1'],
        paymentIds: [10, 11],
        orderIds: [20],
        kv: { 'alert:digestSentAt': { original: '5', written: '4' } },
        pinned: ['alert:hb:fax-status'],
      }),
    );
    expect(l.paymentIds).toEqual([10, 11]);
    expect(l.orderIds).toEqual([20]);
    expect(l.kv['alert:digestSentAt']).toEqual({ original: '5', written: '4' });
    expect(l.injected).toEqual(['a1']);
    expect(l.pinned).toEqual(['alert:hb:fax-status']);
    expect(ledgerIsEmpty(l)).toBe(false);
  });

  //* 실측에서 실제로 걸린 회귀 — `clear a1` 뒤에도 --sim-heartbeat 가 남긴 hb 키 때문에
  //*  "주입 중"으로 읽혀 정당한 다음 inject 가 거부됐다. 두 축은 분리돼야 한다.
  it('추적 중인 KV 가 있어도 주입 축이 비면 "주입 중"이 아니다', () => {
    const l = parseLedger(
      JSON.stringify({
        injected: [],
        paymentIds: [],
        orderIds: [],
        kv: { 'alert:hb:fax-status': { original: null, written: '1' } },
        pinned: [],
      }),
    );
    expect(ledgerIsActive(l)).toBe(false); //* 새 inject 를 막지 않는다
    expect(ledgerIsEmpty(l)).toBe(false); //* 그러나 cleanup 회수 대상으로는 남는다
  });

  it('숫자가 아닌 id 는 버린다', () => {
    expect(parseLedger(JSON.stringify({ ...emptyLedger(), paymentIds: ['x', 3, null] })).paymentIds).toEqual([3]);
  });
});

describe('DB_DOWN_SKIPS — run() 의 실제 db-down 반환값 집합', () => {
  /**
   * **손으로 옮겨 적은 목록을 손으로 옮겨 적은 목록과 대조하지 않는다.** 그렇게 하면 두 사본이
   * 같이 낡을 뿐이고, 실제로 그렇게 낡았다 — M3 가 `db-down-snoozed` 를 신설했을 때 상수도
   * spec 도 그것을 몰라 V26-a 회차가 전량 "무효"로 찍혔다. 그래서 **원본에서 다시 센다**:
   * `alert.service.ts` 를 읽어 `skipped: 'db-down-*'` 을 전부 뽑고 집합 자체를 대조한다.
   * 이 방식은 다음 마일스톤이 새 skip 을 추가해도 자동으로 잡는다.
   */
  const SERVICE_PATH = join(__dirname, '../apps/api/src/modules/alert/alert.service.ts');
  const fromSource = new Set(
    Array.from(
      readFileSync(SERVICE_PATH, 'utf8').matchAll(/skipped:\s*'(db-down-[a-z0-9-]+)'/g),
      (m) => m[1],
    ),
  );

  it('원본이 실제로 db-down skip 을 낸다 (정규식이 조용히 0건을 세지 않는다)', () => {
    expect(fromSource.size).toBeGreaterThanOrEqual(7);
  });

  it('상수와 원본 집합이 정확히 일치한다', () => {
    expect([...DB_DOWN_SKIPS].sort()).toEqual([...fromSource].sort());
  });

  it('정상 회차 결과는 인정하지 않는다 (DB 가 살아났다는 뜻이다)', () => {
    expect(DB_DOWN_SKIPS.has('lease-not-acquired')).toBe(false);
    expect(DB_DOWN_SKIPS.has('null')).toBe(false);
  });
});

describe('parseArgs — a5·scenario 축', () => {
  it('a5 를 신호 축으로 받는다', () => {
    expect(parseArgs(['inject', 'a5']).signal).toBe('a5');
    expect(parseArgs(['clear', 'a5']).signal).toBe('a5');
  });

  it('--vendor 를 읽고, 없으면 null 이다 (활성 벤더 중 첫 번째를 고르라는 뜻)', () => {
    expect(parseArgs(['inject', 'a5', '--vendor', 'octover']).vendor).toBe('octover');
    expect(parseArgs(['inject', 'a5']).vendor).toBeNull();
  });

  it('--vendor 에 값이 없으면 던진다', () => {
    expect(() => parseArgs(['inject', 'a5', '--vendor'])).toThrow(/값이 필요하다/);
  });

  //* 오타가 조용히 다른 축으로 강등되지 않는다 — a4 의 `--base` 누락 가드와 같은 취지다.
  it('알 수 없는 신호 축은 거부한다', () => {
    expect(() => parseArgs(['inject', 'a6'])).toThrow(/a1\|a3\|a4\|a5/);
  });

  it('scenario 는 id 를 요구하고, 카탈로그에 없는 id 를 부팅 전에 거부한다', () => {
    expect(() => parseArgs(['scenario'])).toThrow(/id 가 필요하다/);
    expect(() => parseArgs(['scenario', 'nope'])).toThrow(/알 수 없는 시나리오/);
    expect(parseArgs(['scenario', 'list']).scenario).toBe('list');
    expect(parseArgs(['scenario', 'payment-fire']).scenario).toBe('payment-fire');
  });
});

describe('parseLedger — orderDetailIds (a5 회수 대상)', () => {
  it('보존한다', () => {
    const l = parseLedger(JSON.stringify({ v: 1, orderDetailIds: [7, 9] }));
    expect(l.orderDetailIds).toEqual([7, 9]);
  });

  //* 이 필드가 없던 대장을 읽어도 깨지지 않아야 한다. 그런 대장에는 detail 행이 없다.
  it('옛 대장에는 필드가 없다 — 빈 배열로 읽는다', () => {
    expect(parseLedger(JSON.stringify({ v: 1, paymentIds: [1] })).orderDetailIds).toEqual([]);
    expect(emptyLedger().orderDetailIds).toEqual([]);
  });

  //* `toPk` 는 형식이 아니라 **값의 정의역**으로 거른다 — 숫자 문자열 `'4'` 는 통과하고
  //*  0·음수·소수·null 은 떨어진다. 0 이 통과하면 손상된 대장이 `DELETE ... WHERE odd_id = 0`
  //*  를 만들고 그 무해한 no-op 이 "회수했다"는 거짓 기록으로 남는다.
  it('id 정의역(양의 정수)을 벗어난 값은 버린다 — 0 이 DELETE 대상으로 흘러들지 않는다', () => {
    const l = parseLedger(JSON.stringify({ v: 1, orderDetailIds: [0, -3, '4', null, 5.5, 6] }));
    expect(l.orderDetailIds).toEqual([4, 6]);
  });
});

describe('ledgerIsEmpty — detail 행만 남은 대장을 "비었다"로 읽지 않는다', () => {
  /**
   * 이 검사가 없으면 `cleanup` 이 `ledgerIsEmpty` 분기에서 **즉시 no-op 으로 반환**하고
   * (`cmdCleanup` 첫 분기) 심은 `tn_order_detail` 행이 dev DB 에 영구히 남는다. 그 행은
   * 다음 회차의 벤더 모집단에 그대로 들어가므로, 회수 실패가 곧 **다음 측정의 오염**이다.
   */
  it('orderDetailIds 만 남아 있어도 비어 있지 않다', () => {
    expect(ledgerIsEmpty({ ...emptyLedger(), orderDetailIds: [1] })).toBe(false);
  });

  it('셋 다 비고 kv 도 비면 비어 있다', () => {
    expect(ledgerIsEmpty(emptyLedger())).toBe(true);
  });
});

describe('시나리오 카탈로그 — 구조 불변식', () => {
  it('id 가 중복되지 않는다 (중복이면 find 가 앞의 것만 돌려 뒤가 영영 안 돈다)', () => {
    const ids = SCENARIOS.map((s) => s.id);
    expect(new Set(ids).size).toBe(ids.length);
  });

  it('비어 있지 않다 (정규식·필터 실수로 목록이 0건이 되면 전 검사가 공회전한다)', () => {
    expect(SCENARIOS.length).toBeGreaterThan(0);
  });

  /**
   * `clear` 는 그 축을 **주입한 뒤에만** 의미가 있다. 카탈로그 오타로 주입하지 않은 축을
   * 해제하면 `cmdClear` 가 빈 대장을 상대로 아무것도 하지 않고, 뒤따르는 poll 의 "복구 0건"이
   * 규칙 결함처럼 보인다 — 실제로는 시나리오가 틀린 것이다.
   */
  it('clear 하는 축은 앞선 단계에서 주입된 축이다', () => {
    for (const s of SCENARIOS) {
      const injected = new Set<string>();
      for (const step of s.steps) {
        if (step.do === 'inject') injected.add(step.signal);
        if (step.do === 'clear')
          expect([s.id, step.signal, injected.has(step.signal)]).toEqual([s.id, step.signal, true]);
      }
    }
  });

  it('관측을 만드는 단계(poll·test)가 최소 1개다 — 판정식이 볼 것이 있어야 한다', () => {
    for (const s of SCENARIOS)
      expect([s.id, s.steps.some((x) => x.do === 'poll' || x.do === 'test')]).toEqual([s.id, true]);
  });
});

describe('시나리오 판정식 — 침묵을 통과로 읽지 않는다', () => {
  it('payment-fire: critical 이 있어야 PASS', () => {
    const s = scenario('payment-fire');
    expect(s.verdict([obs({ critical: 1, delivered: 1 })], { channels: 1 })).toBeNull();
    expect(s.verdict([obs()], { channels: 1 })).toMatch(/critical=0/);
  });

  it('payment-under: 한 건이라도 나가면 FAIL', () => {
    const s = scenario('payment-under');
    expect(s.verdict([obs()], { channels: 1 })).toBeNull();
    expect(s.verdict([obs({ delivered: 1, critical: 1 })], { channels: 1 })).toMatch(/발송=1/);
  });

  //* 억제는 "적게 나갔는가"가 아니라 "정확히 한 번 나갔는가"다. 0건도 실패다 — 0건은
  //*  억제가 아니라 애초에 발화하지 않았다는 뜻이고, 그것으로 M4 를 주장할 수 없다.
  it('payment-suppress: 3회차에 critical 이 정확히 1건이어야 PASS', () => {
    const s = scenario('payment-suppress');
    expect(s.verdict([obs({ critical: 1 })], { channels: 1 })).toBeNull();
    expect(s.verdict([obs({ critical: 2 })], { channels: 1 })).toMatch(/critical=2/);
    expect(s.verdict([obs({ critical: 0 })], { channels: 1 })).toMatch(/critical=0/);
  });

  it('payment-recover: 복구 2건은 실패이고, 발화하지 않은 회차는 복구를 잴 수 없다고 말한다', () => {
    const s = scenario('payment-recover');
    expect(s.verdict([obs({ critical: 1 }), obs({ recovery: 1 })], { channels: 1 })).toBeNull();
    expect(s.verdict([obs({ critical: 1 }), obs({ recovery: 2 })], { channels: 1 })).toMatch(
      /recovery=2/,
    );
    expect(s.verdict([obs({ critical: 0 }), obs({ recovery: 1 })], { channels: 1 })).toMatch(
      /잴 수 없다/,
    );
  });

  it('multi-channel: 목적지 수가 활성 채널 수와 같아야 PASS, 채널 1개면 SKIP', () => {
    const s = scenario('multi-channel');
    expect(s.verdict([obs({ critical: 2, criticalDestinations: 2 })], { channels: 2 })).toBeNull();
    expect(s.verdict([obs({ critical: 1, criticalDestinations: 1 })], { channels: 2 })).toMatch(
      /목적지=1/,
    );
    expect(s.skipIf!({ channels: 1 })).toMatch(/2개 이상/);
    expect(s.skipIf!({ channels: 2 })).toBeNull();
  });

  it('quiet: 무장애 구간에 한 건이라도 나가면 FAIL', () => {
    const s = scenario('quiet');
    expect(s.verdict([obs()], { channels: 1 })).toBeNull();
    expect(s.verdict([obs({ delivered: 1, warn: 1 })], { channels: 1 })).toMatch(/발송=1/);
  });

  it('channel-test: 채널 수만큼 받아야 PASS (일부만 도달하면 FAIL)', () => {
    const s = scenario('channel-test');
    expect(s.verdict([obs({ test: 2, delivered: 2 })], { channels: 2 })).toBeNull();
    expect(s.verdict([obs({ test: 1, delivered: 1 })], { channels: 2 })).toMatch(/수신=1/);
    expect(s.skipIf!({ channels: 0 })).toMatch(/활성 채널이 없다/);
  });

  it('vendor-fire 는 풀 축(critical)만 성공으로 친다 — warn 은 다이제스트라 그 회차에 안 나간다', () => {
    const s = scenario('vendor-fire');
    expect(s.verdict([obs({ critical: 1 })], { channels: 1 })).toBeNull();
    expect(s.verdict([obs({ warn: 1 })], { channels: 1 })).toMatch(/critical=0/);
  });
});
