// tests/audit-mockup-html-conformance.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-mockup-html-conformance.mjs`（npm key `audit:mockup-html-conformance`）的
// **整脚本**回归面。
//
// 🔴 **为什么是这一条**（第四十七轮按量具现取挑的，⛔ 不按印象）：它在 `package.json` 的
// `files[]` 白名单里（经 `scripts/audit-mockup-*.mjs` 那条 glob）⇒ **随 npm 包发到每个
// consumer**；且它是 blocking，挂 **L4** `.husky/pre-commit` + **L5**
// `.gitea/workflows/pr-checks.yml`（该 workflow 的 `on:` 含 push:master ⇒ owner 直推也拦）。
// ⇒ 它的假绿会随下一个版本分发到所有下游产品，其余闸的假绿只影响本仓。
//
// 🔴 **本文件同时是「P3=TREE ⇒ 走不了 fixture-root」这条转述的反证。** 量具
// `gate-regression-face-inventory` 第 40 行写的是「递归走大目录树 = 造 fixture **成本**
// 反超抽 lib」—— 那是**成本**信号，不是封路，而该估算隐含「必须复刻真仓库那棵树」。
// 实际只需放进够让判据开火的两三个文件：本闸的 `DEFAULT_MOCKUP_GLOBS` 只有一个目录
// （`docs/internal/_demos`），下方 fixture 里 `walkHtml` 走的是 1-3 个文件。
// ⇒ 按「P2 干净 + P3 非 TREE」筛随包面得 **0** 条；去掉 TREE 那一半，得这一条。
//
// 此前它的回归面是 `I` 类 —— `tests/mockup-html-conformance.test.ts` 的 8 条只 import 了
// 纯函数 `lintMockupHtml`。闸本体有 CLI entry guard，**import 时刻意不跑 CLI** ⇒ 那份测试
// 结构上碰不到：argv 解析（`--all` / staged 显式文件 / `--warnings-as-errors`）、`walkHtml`
// 的递归与 `.html` 过滤、severity→退出码映射、以及**三个进程出口**（nothing-to-lint 0 /
// 全绿 0 / 有 error 1）。摘掉任一出口的接线，那 8 条全绿。
//
// ⛔ **闸本体一行没改。**
// ⛔ 散文里提别的闸时**去掉 `.mjs` 后缀** —— 带后缀会让量具把那条闸误报成「已覆盖」。
//
// 🔴 **顺带是 `is-cli-entry` 的第二个端到端见证**：macOS `os.tmpdir()` 返回 `/var/folders/…`
// 而 `/var` 自己就是 symlink ⇒ 这批 fixture **全程跑在 symlink 布局下**。上一轮修掉的那类
// 假绿（entry guard 在 symlink 下恒假 ⇒ 整程序不执行、exit 0、stdout/stderr 双空）在这里会
// 表现为**每条绿档当场失败**（拿不到 fixture 自己的读数）。⇒ 下方每条绿档都钉了「只有真
// 跑过才有的读数」，⛔ 别把任何一条降级成只看退出码。
// ⇒ 也因此 `linkDirs` **必须走 harness 默认的 `['scripts/lib']`**（闸 import
//   `./lib/is-cli-entry`）—— ⛔ 别写 `linkDirs: []`，那会让闸在 fixture 里 ENOENT 崩掉，
//   而崩溃也是非零退出 ⇒ 红得理由不对。
//
// 覆盖：绿档非空过（fixture 自己的文件数 + 文件名回显 + **对真仓库 `_demos` 的反向钉**，
//   反向钉自带非空前提检查）· 三条 error 规则各经 CLI 开火一次 · 两条 warn 规则不改退出码
//   · `--warnings-as-errors` 把 warn 升成 error（**只存在于 CLI 块，纯函数完全不知道它**）
//   · `walkHtml` 递归子目录 · 非 `.html` 不进分母 · 文件级 suppression 经 CLI 生效
//   · 显式传不存在的文件 ⇒ fail-closed · staged 显式文件那一路挂载
//   · 🔴 **一条如实登记的 fail-open**（`_demos` 空 ⇒ nothing-to-lint 且 exit 0），
//     ⛔ 不是背书 —— 是把闸头注释自己登记的那条诚实边界钉住，防它被静默改掉、
//     也防后来人把本闸读成「无条件」。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
  REPO_ROOT,
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-mockup-html-conformance.mjs'
/** 闸的 `DEFAULT_MOCKUP_GLOBS` 唯一那一项。 */
const DEMOS = 'docs/internal/_demos'

afterEach(() => {
  cleanupGateFixtures()
})

// ---------------------------------------------------------------------------
// fixture 构造
// ---------------------------------------------------------------------------

/** 在 fixture 的 `docs/internal/_demos/` 下写这些文件（key = 该目录内的相对路径）。 */
function build(files: Record<string, string>): string {
  return createGateFixture({
    gate: GATE,
    prefix: 'fx-mockup-html-conf',
    // 目录先建出来：即使 files 为空也要有它，才能复现「被清空」而不是「不存在」
    dirs: [DEMOS],
    files: Object.fromEntries(Object.entries(files).map(([rel, c]) => [`${DEMOS}/${rel}`, c])),
    // linkDirs 刻意不传 ⇒ 用默认 ['scripts/lib']，理由见文件头
  })
}

/** 零违例。`#000` 是闸的 NEUTRAL_HEX_ALLOW 成员 ⇒ 顺带走一遍 allow-list 那条分支。 */
const CLEAN = [
  '<!doctype html>',
  '<title>fx clean</title>',
  '<style>#fx-stage{background:#000}</style>',
  '<p>fx clean mockup</p>',
  '',
].join('\n')

/** 只触发 popupbox-for-confirm：确认词在窗口内，且无 FORM_FIELDS（`<button>` 不算）。 */
const POPUP_CONFIRM = [
  '<tvu-popup-box>',
  '  <p>确认删除这条 fx 记录？</p>',
  '  <button>取消</button>',
  '</tvu-popup-box>',
  '',
].join('\n')

/** 只触发 unconstrained-svg：无 width/height 属性、无 inline size、无 class。 */
const UNSIZED_SVG = [
  '<p>fx icon</p>',
  '<svg viewBox="0 0 24 24"><path d="M0 0h24v24"></path></svg>',
  '',
].join('\n')

/** 触发 handrolled-nav **两次**（`<nav>` 与 nav-classed `<ul>` 是两个不同的 push 点）。 */
const HANDROLLED_NAV = [
  '<nav><a href="#fx">fx one</a></nav>',
  '<ul class="fx-nav"><li>fx two</li></ul>',
  '',
].join('\n')

/** 只触发两条 warn 规则，零 error ⇒ 用来测 severity→退出码那条映射。 */
const WARNS_ONLY = [
  '<style>.fx-card{background:#3a3a3a}</style>',
  "<script>const fxN = { form: 'alert', confirmText: 'fx OK' }</script>",
  '',
].join('\n')

/** 文件级 suppression 压掉上面那条 nav 违例。 */
const NAV_SUPPRESSED_FILE = [
  '<!-- mockup-lint-disable-file all -->',
  '<nav><a href="#fx">fx</a></nav>',
  '',
].join('\n')

/**
 * 真仓库 `docs/internal/_demos/` 里的 `.html` 名字 —— 只作**反向钉**：
 * fixture 跑对了树，这些名字一个都不该出现在 stdout 里。
 */
function realDemoNames(): string[] {
  try {
    return readdirSync(join(REPO_ROOT, DEMOS)).filter((n) => n.endsWith('.html'))
  } catch {
    return []
  }
}

// ===========================================================================
describe('audit-mockup-html-conformance — 反向钉的前提', () => {
  it('真仓库 _demos 非空 —— 否则下面那条 not.toContain 是空过', () => {
    // 这条是「每条 must-not-hit 都要配正向锚点」纪律的落地：反向钉本身也会空过。
    expect(realDemoNames().length).toBeGreaterThan(0)
  })
})

// ===========================================================================
describe('audit-mockup-html-conformance — 绿档（非空过）', () => {
  it('--all 扫到的是 fixture 自己那三个文件 ⇒ exit 0，且回显 fixture 自己的读数', () => {
    const root = build({
      'fx-clean-a.html': CLEAN,
      'fx-clean-b.html': CLEAN,
      'fx-clean-c.html': CLEAN,
    })
    const run = runGate(root, GATE, ['--all'])
    expectGateGreen(run, {
      contains: [
        // 文件数 3 与文件名 fx-clean-* 都是**只有真跑过 CLI 才有的读数**：
        // import 那条路上没有 walkHtml、没有总结行。真仓库那份是 2 个别的名字。
        '═══ mockup-html-conformance: 3 file(s), 0 error(s), 0 warning(s) ═══',
        `✅ ${DEMOS}/fx-clean-a.html`,
        `✅ ${DEMOS}/fx-clean-b.html`,
        `✅ ${DEMOS}/fx-clean-c.html`,
      ],
    })
    // ⛔ 反向钉：跑错了树（退回真仓库的 _demos）就会印真文件名
    for (const n of realDemoNames()) expect(run.stdout).not.toContain(n)
  })

  it('文件级 suppression 经 CLI 全路径生效 ⇒ 违例被压掉后仍是绿', () => {
    const root = build({ 'fx-suppressed.html': NAV_SUPPRESSED_FILE })
    expectGateGreen(runGate(root, GATE, ['--all']), {
      contains: [
        '1 file(s), 0 error(s), 0 warning(s)',
        `✅ ${DEMOS}/fx-suppressed.html`,
      ],
    })
  })
})

// ===========================================================================
describe('audit-mockup-html-conformance — 三条 error 规则各经 CLI 开火一次', () => {
  it('popupbox-for-confirm ⇒ 红且点名判据 + 行号 + 出口计数', () => {
    // ⚠️ **2 error(s) 不是笔误，是闸的实测行为**：判据扫的是
    // `/(tvu-popup-box\b|\bPopupBox\b|PopupBox\.[a-z])/g`，**开标签与闭标签各命中一次**
    // （第 1 行 `<tvu-popup-box>` + 第 4 行 `</tvu-popup-box>`），两处都落在 confirm-words
    // 的 ±窗口内 ⇒ 同一个逻辑违例报两条。性质 = 输出噪声，**不是假绿**（该红的照样红）。
    // 这里按实测钉住而不是写成 `>= 1`：真去做去重时本条会红，改的人就会读到这段注释。
    // （旧的纯函数测试用 `toBeGreaterThanOrEqual(1)`，正是那种把行为差异糊过去的写法。）
    const root = build({ 'fx-popup.html': POPUP_CONFIRM })
    expectGateRed(runGate(root, GATE, ['--all']), {
      checks: [
        '❌ error',
        '[popupbox-for-confirm]',
        `${DEMOS}/fx-popup.html:1`,
        `${DEMOS}/fx-popup.html:4`,
        '1 file(s), 2 error(s), 0 warning(s)',
        // 出错时才印的补救提示 —— 属 CLI 块，纯函数没有
        'mockup-lint-disable-next-line',
      ],
    })
  })

  it('unconstrained-svg ⇒ 红且点名判据（行号取第 2 行，证明 lineOf 经 CLI 也对）', () => {
    const root = build({ 'fx-svg.html': UNSIZED_SVG })
    expectGateRed(runGate(root, GATE, ['--all']), {
      checks: [
        '[unconstrained-svg]',
        `${DEMOS}/fx-svg.html:2`,
        '1 file(s), 1 error(s), 0 warning(s)',
      ],
    })
  })

  it('handrolled-nav 的两个 push 点都经 CLI 计入 ⇒ 2 error(s)', () => {
    const root = build({ 'fx-nav.html': HANDROLLED_NAV })
    expectGateRed(runGate(root, GATE, ['--all']), {
      checks: [
        '[handrolled-nav]',
        `${DEMOS}/fx-nav.html:1`,
        `${DEMOS}/fx-nav.html:2`,
        '1 file(s), 2 error(s), 0 warning(s)',
      ],
    })
  })
})

// ===========================================================================
describe('audit-mockup-html-conformance — severity → 退出码（CLI 独有，纯函数看不见）', () => {
  it('只有 warn ⇒ 印 🟡 warn 但 exit 0（两条 warn 规则都计入）', () => {
    const root = build({ 'fx-warn.html': WARNS_ONLY })
    expectGateGreen(runGate(root, GATE, ['--all']), {
      contains: [
        '🟡 warn ',
        '[hardcoded-color]',
        '[alert-confirmText]',
        '1 file(s), 0 error(s), 2 warning(s)',
      ],
    })
  })

  it('同一份文件 + --warnings-as-errors ⇒ 同样两条改判 error 且 exit 1', () => {
    // `warnAsError` 这个开关**只存在于 CLI 块** —— `lintMockupHtml` 结构上不知道它，
    // 所以这条断言只有整脚本回归面拿得到。
    const root = build({ 'fx-warn.html': WARNS_ONLY })
    expectGateRed(runGate(root, GATE, ['--all', '--warnings-as-errors']), {
      checks: [
        '❌ error',
        '[hardcoded-color]',
        '[alert-confirmText]',
        '1 file(s), 2 error(s), 0 warning(s)',
      ],
    })
  })
})

// ===========================================================================
describe('audit-mockup-html-conformance — 输入面（walkHtml 的接线）', () => {
  it('递归进子目录 ⇒ 深层违例照样被抓，且 rel 路径含完整层级', () => {
    const root = build({ 'fx-sub/fx-deep/fx-nested.html': UNSIZED_SVG })
    expectGateRed(runGate(root, GATE, ['--all']), {
      checks: [
        '[unconstrained-svg]',
        `${DEMOS}/fx-sub/fx-deep/fx-nested.html:2`,
        '1 file(s), 1 error(s), 0 warning(s)',
      ],
    })
  })

  it('非 .html 不进分母 —— 同名违例放 .txt 里不被扫，分母只算那个干净 .html', () => {
    const root = build({
      'fx-not-html.txt': HANDROLLED_NAV,
      'fx-clean-a.html': CLEAN,
    })
    const run = runGate(root, GATE, ['--all'])
    expectGateGreen(run, { contains: ['1 file(s), 0 error(s), 0 warning(s)'] })
    // ⛔ must-not-hit + 正向锚点：上面那条 `1 file(s)` 就是「闸真跑过且只收了 .html」的读数
    expect(run.stdout).not.toContain('fx-not-html.txt')
    expect(run.stdout).not.toContain('[handrolled-nav]')
  })

  it('staged 显式文件那一路挂载（L4 的 else 分支）⇒ 不传 --all 也能红', () => {
    // `.husky/pre-commit` 两路：改了闸本体走 `--all`，否则把 staged 的
    // `docs/internal/_demos/*.html` 用 xargs 逐个传进来。这是第二路。
    const root = build({ 'fx-popup.html': POPUP_CONFIRM, 'fx-clean-a.html': CLEAN })
    const run = runGate(root, GATE, [`${DEMOS}/fx-popup.html`])
    expectGateRed(run, {
      checks: [
        '[popupbox-for-confirm]',
        // 显式那一路只收传进来的那一个 ⇒ 分母 **1**，而目录里有 2 个 .html。
        // （error 数 2 的理由同上一条：开/闭标签各一次。）
        '1 file(s), 2 error(s), 0 warning(s)',
      ],
    })
    // ⛔ must-not-hit：真的走了显式那一路，没有偷偷回落到 --all 全扫
    expect(`${run.stderr}\n${run.stdout}`).not.toContain('fx-clean-a.html')
  })
})

// ===========================================================================
describe('audit-mockup-html-conformance — 三个进程出口', () => {
  it('显式传不存在的文件 ⇒ fail-closed（⚠️ not found 且 exit 1）', () => {
    const root = build({ 'fx-clean-a.html': CLEAN })
    expectGateRed(runGate(root, GATE, [`${DEMOS}/fx-missing.html`]), {
      checks: [
        '⚠️  not found:',
        'fx-missing.html',
        '1 file(s), 1 error(s), 0 warning(s)',
      ],
    })
  })

  it('🔴 如实登记的 fail-open：_demos 存在但空 ⇒ nothing-to-lint 且 exit 0', () => {
    // ⛔ **这不是背书。** 闸头注释逐字登记了这条诚实边界（「分母来自目录扫描，
    // `docs/internal/_demos` 被清空时打印 nothing-to-lint 并 exit 0 ⇒ 本闸强度绑在
    // 该目录非空上，别读成无条件」）。这里钉住它，是为了：
    //   ① 它被静默改掉时有东西响（改成 fail-closed 也会红 ⇒ 届时同时改这条与那段注释）
    //   ② 「exit 0 且几乎零输出」这个假绿签名在这一档是**预期行为**，
    //      而 nothing-to-lint 那句话就是区分「预期空过」与「entry guard 恒假」的读数。
    const root = build({})
    const run = runGate(root, GATE, ['--all'])
    expectGateGreen(run, {
      contains: ['▶ mockup-html-conformance: no mockup HTML files found — nothing to lint.'],
    })
    // 正向锚点：真跑过才有这句；⛔ 别把本用例降级成只断言 exit 0
    expect(run.stdout.trim()).not.toBe('')
  })

  it('全绿出口不印任何 error/warn 行 ⇒ 与上面两档是三个不同的终态', () => {
    const root = build({ 'fx-clean-a.html': CLEAN })
    const run = runGate(root, GATE, ['--all'])
    expectGateGreen(run, { contains: ['1 file(s), 0 error(s), 0 warning(s)'] })
    expect(run.stdout).not.toContain('❌ error')
    expect(run.stdout).not.toContain('🟡 warn')
    expect(run.stdout).not.toContain('nothing to lint')
  })
})
