// tests/stop-figma-conformance-hook.test.ts
// -----------------------------------------------------------------------------
// 把 `.claude/hooks/stop-figma-conformance.sh` 的 20 条自测**挂进 `pnpm test`**
// —— 也就是 `.husky/pre-commit` L8-9 与 `.gitea/workflows/pr-checks.yml` 的
// "Unit tests (vitest)"，两层。
//
// ⛔ **为什么必须挂**：那份自测写完时只能手动跑。本仓自己的
// `tests/lib/a11y-contrast-exemptions.mjs` 头注释逐字写着
// 「**一条恒红的 suite 和一条不挂载的 suite，保护的是同样多的东西：什么都没有**」。
// 一条没人跑的自测 = 一条会烂的自测；而它守的那条 hook（Q6 的收尾兜底）
// 恰恰是**只在真 session 收尾时才触发**的东西，坏了不会有人察觉。
//
// ⚠️ 本文件覆盖的是 hook 的**脚本逻辑**（该挡时挡 / 该放时放）。
// 「Claude Code 真的会在收尾时触发 Stop hook 并尊重退出码 2」⛔ 仍未观测 ——
// 那要一次真 session，如实登记在 ai-ds-lab `docs/decision-queue.md` Q6 的天花板小节。
//
// 真源: tvu-design-system/docs/internal/mockup-conventions.md §M-LIFECYCLE
// -----------------------------------------------------------------------------
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'

const REPO_ROOT = resolve(__dirname, '..')
const HOOK = '.claude/hooks/stop-figma-conformance.sh'
const SELFTEST = '.claude/hooks/stop-figma-conformance.selftest.sh'

describe('§M-LIFECYCLE 收尾兜底 hook（queue Q6）', () => {
  it('hook 与它的自测都在场', () => {
    // fail-closed：文件被删/改名时这条先红，⛔ 别让下面那条静默跳过
    expect(existsSync(resolve(REPO_ROOT, HOOK)), `${HOOK} 不在`).toBe(true)
    expect(existsSync(resolve(REPO_ROOT, SELFTEST)), `${SELFTEST} 不在`).toBe(true)
  })

  it('自测 20 条全过（必拦 / 必放两侧）', () => {
    let out = ''
    let failed = false
    try {
      out = execFileSync('bash', [SELFTEST], {
        cwd: REPO_ROOT,
        encoding: 'utf8',
        timeout: 120_000,
        // 自测里有 `sleep 30 &` 的 fixture；它自己会 kill，这里只兜住卡死
      })
    } catch (e: unknown) {
      const err = e as { stdout?: string; stderr?: string; message?: string }
      out = `${err.stdout ?? ''}${err.stderr ?? ''}${err.message ?? ''}`
      failed = true
    }
    // 把自测的逐条输出带进失败信息 —— ⛔ 别只报「脚本退出码非 0」，
    // 那会让人不知道是哪一条判据坏了（本仓通则：失败必须点名）。
    expect(failed, `自测未通过：\n${out}`).toBe(false)
    expect(out, `自测输出里没有汇总行，形态可能变了：\n${out}`).toMatch(/▶ 自测：\d+ 过 \/ 0 失败/)
    // 条数下限钉住：⛔ 防「把用例删到只剩一条也算全过」
    const m = out.match(/▶ 自测：(\d+) 过/)
    expect(Number(m?.[1] ?? 0), '自测用例数少于 20 —— 用例被删了？').toBeGreaterThanOrEqual(20)
  })

  it('hook 注册在 Stop 与 SubagentStop 两个事件上', () => {
    const s = JSON.parse(readFileSync(resolve(REPO_ROOT, '.claude/settings.json'), 'utf8'))
    for (const evt of ['Stop', 'SubagentStop']) {
      const cmds = JSON.stringify(s.hooks?.[evt] ?? [])
      expect(cmds, `.claude/settings.json hooks.${evt} 里没挂 ${HOOK} —— 挂载面丢了`).toContain(
        'stop-figma-conformance.sh',
      )
    }
  })

  it('⛔ 两条防循环 belt 都还在（拆掉任一条会让收尾陷入反复阻塞）', () => {
    const src = readFileSync(resolve(REPO_ROOT, HOOK), 'utf8')
    // 🔴 **全部判据只扫剥掉 `#` 注释后的代码。**
    // ⚠️ 本文件初版对着全文判，结果**两条都是假绿**（2026-09-07 造故障当场抓到）：
    //    ① `not.toMatch(decision:block)` 被 hook 头注释里那句「⛔ 别写成 …」的说明文字打红（假阳）；
    //    ② `toContain('result.consumed.txt')` 被同一段头注释**喂饱** —— 我把那行 `mv`
    //       整条拆掉，这条断言**照样绿**（假阴，更危险）。
    //    ⇒ 散文提到一个机制 ≠ 那个机制在代码里。同族于本仓已登记的
    //       「fixture 里别写真实闸名」：判据的扫描面把散文也圈进去了。
    const code = src
      .split('\n')
      .filter((l) => !/^\s*#/.test(l))
      .join('\n')
    // 阴性对照先钉：剥注释⛔ 不得把真代码一起剥掉，否则下面全部退化成空判
    expect(code, '剥注释后代码没了 —— 剥法有问题，下面的判据会变成空判').toMatch(/emit_and_block\(\)/)

    // belt 1：消费一次 —— 注入过的结果必须真的被移走（判的是 mv 那一行，不是注释）
    expect(code, 'belt 1「消费一次」不见了：result.txt 未被 mv 成 result.consumed.txt').toMatch(
      /mv\s+"\$STATE_DIR\/result\.txt"\s+"\$STATE_DIR\/result\.consumed\.txt"/,
    )
    // belt 2：有界升级 —— 必须有阻塞次数上限，且真的被用来比较
    expect(code, 'belt 2「有界升级」不见了：没有 MAX_BLOCKS 上限').toMatch(/MAX_BLOCKS/)
    expect(code, 'belt 2 的上限没被用于判断 —— 定义了却不比较等于没有').toMatch(
      /-ge\s+"\$MAX_BLOCKS"/,
    )
    // ⚠️ 契约钉：阻塞靠退出码 2（活源 code.claude.com/docs/en/hooks）。
    expect(code, 'hook 必须用退出码 2 阻塞').toMatch(/exit 2/)
    // ⛔ Stop 事件**不吃** decision-block 那种 JSON，写成那样会静默失效。
    expect(code, '⛔ Stop 事件不吃 decision-block 形态的 JSON，别写回去').not.toMatch(
      /"decision"\s*:\s*"block"/,
    )
  })
})
