// tests/gate-chain-steps.test.ts
// 闸链**步骤面**解析（scripts/lib/gate-chain-steps.mjs）的判据面 —— 第二轮第 9 步。
//
// ⚠️ 这份文件只覆盖**判据逻辑**。runner 的**接线**（40 步全跑完 / 阻断语义 / 失败摘要 /
//    步骤面不是第二真源）由落地当天的故障注入实测覆盖，两者缺一不可 ——
//    本仓已量化过「纯 import 单测全绿、摘掉 main() 接线全仓 1993 测试无一转红」。
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  CHAIN_SOT_KEY,
  CHAIN_ENTRY_KEY,
  GATE_PREFIXES,
  resolveNpmKey,
  parseChainSteps,
  inChainGates,
} from '../scripts/lib/gate-chain-steps.mjs'

const REPO_ROOT = resolve(__dirname, '..')
const pkgOf = (scripts: Record<string, string>) => ({ scripts })

describe('parseChainSteps — fail closed（AGENTS §3.1）', () => {
  it('⛔ 步骤面 key 缺失 → 抛（不是「链上没有闸」，是读不到链）', () => {
    expect(() => parseChainSteps(pkgOf({}))).toThrow(new RegExp(CHAIN_SOT_KEY))
    expect(() => parseChainSteps(pkgOf({ [CHAIN_SOT_KEY]: '   ' }))).toThrow(new RegExp(CHAIN_SOT_KEY))
    expect(() => parseChainSteps(undefined as never)).toThrow()
  })

  it('⛔ 某步引用不存在的 npm key → 当场抛（静默跳过 = 把一条闸从分母里悄悄摘掉）', () => {
    const pkg = pkgOf({ [CHAIN_SOT_KEY]: 'pnpm run audit:a && pnpm run audit:ghost', 'audit:a': 'node scripts/audit-a.mjs' })
    expect(() => parseChainSteps(pkg)).toThrow(/audit:ghost/)
    // 阴性对照：把它补上就不抛了（证明抛的是那一条，不是「凡有两步就抛」）
    expect(() => parseChainSteps(pkgOf({ ...pkg.scripts, 'audit:ghost': 'node scripts/audit-ghost.mjs' }))).not.toThrow()
  })
})

describe('parseChainSteps — 闸 / 非闸分类（E14：lint:ds 不进闸的计数）', () => {
  const pkg = pkgOf({
    [CHAIN_SOT_KEY]: 'pnpm run audit:a && pnpm run lint:ds && pnpm run lint-skills && pnpm run audit:b',
    'audit:a': 'node figma-sync/audit-a.mjs',
    'lint:ds': "eslint 'src/**/*.vue'",
    'lint-skills': 'node scripts/lint-skills.mjs',
    'audit:b': 'node scripts/audit-b.mjs',
  })

  it('步数 = 链的 `&&` 段数；闸只算 `node <audit-|smoke-|check->*.mjs`', () => {
    const p = parseChainSteps(pkg)
    expect(p.steps).toHaveLength(4)
    expect(p.steps.filter((s: any) => s.isGate).map((s: any) => s.npmKey)).toEqual(['audit:a', 'audit:b'])
  })

  it('⛔ must-not-hit：`lint:ds`（不是 node 调用）不算闸', () => {
    const s = parseChainSteps(pkg).steps.find((x: any) => x.npmKey === 'lint:ds')
    expect(s.isGate).toBe(false)
    expect(s.reason).toBe('not a `node <x>.mjs` call')
  })

  it('⛔ must-not-hit：`lint-skills`（是 node 调用但文件名无闸前缀）不算闸', () => {
    const s = parseChainSteps(pkg).steps.find((x: any) => x.npmKey === 'lint-skills')
    expect(s.isGate).toBe(false)
    expect(s.reason).toBe('not gate-prefixed')
  })

  it('一步展开成多条命令时**逐条**判，⛔ 不在第一条命中就停（否则第二条会从分母里消失）', () => {
    const p = parseChainSteps(pkgOf({
      [CHAIN_SOT_KEY]: 'pnpm run combo',
      combo: 'node scripts/helper.mjs && node scripts/audit-x.mjs && node scripts/audit-y.mjs',
    }))
    expect(p.steps[0].scripts).toEqual(['scripts/audit-x.mjs', 'scripts/audit-y.mjs'])
    expect(p.steps[0].nonGateCmds.map((c: any) => c.raw)).toEqual(['node scripts/helper.mjs'])
  })
})

describe('inChainGates — 两个 npm key 共用一个脚本算一条闸', () => {
  it('去重按脚本路径，不按 npm key（真仓库 icon-naming-rules 就是这形态）', () => {
    const g = inChainGates(pkgOf({
      [CHAIN_SOT_KEY]: 'pnpm run audit:k1 && pnpm run audit:k2',
      'audit:k1': 'node scripts/audit-icon-naming-rules.mjs',
      'audit:k2': 'node scripts/audit-icon-naming-rules.mjs',
    }))
    expect(g.steps).toBe(2)
    expect(g.gates).toHaveLength(1)
  })
})

describe('resolveNpmKey — 递归展开', () => {
  it('`pnpm run X` 展开到真实命令；自引用不死循环', () => {
    const pkg = pkgOf({ a: 'pnpm run b', b: 'node scripts/audit-b.mjs' })
    expect(resolveNpmKey(pkg, 'a').map((c: any) => c.raw)).toEqual(['node scripts/audit-b.mjs'])
    expect(() => resolveNpmKey(pkgOf({ a: 'pnpm run a' }), 'a')).not.toThrow()
  })
})

describe('真仓库的步骤面（钉会变的量，⛔ 不写「字段存在」这类恒真断言）', () => {
  const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'))

  it('步骤面真源在 `gate-chain`，入口 `prepublishOnly` 指向 runner', () => {
    expect(typeof pkg.scripts[CHAIN_SOT_KEY]).toBe('string')
    expect(pkg.scripts[CHAIN_ENTRY_KEY]).toBe('node scripts/run-gate-chain.mjs')
  })

  it('⛔ must-not-hit：`gate-chain` 不得被写回成 runner 调用（那会变成自引用死循环）', () => {
    expect(pkg.scripts[CHAIN_SOT_KEY]).not.toContain('run-gate-chain.mjs')
  })

  it('真链解析得出 40 步 / 37 条闸（数变了就是链动了，要人看一眼）', () => {
    // 2026-08-27：39 → 40 步 / 36 → 37 条 —— 新增 `audit:acceptance-gate-coverage`
    // （CANONICAL-F104 的结构性那一半：「这条 Acceptance 有没有闸在管」的三态登记）。
    const g = inChainGates(pkg)
    expect(g.steps).toBe(40)
    expect(g.gates).toHaveLength(37)
  })

  it('闸前缀恰好三个 —— 加第四个要先改元闸那份真源', () => {
    expect(GATE_PREFIXES).toEqual(['audit-', 'smoke-', 'check-'])
  })
})
