// tests/audit-render-verification-coverage.test.ts
// -----------------------------------------------------------------------------
// `audit:render-verification-coverage`（L4 pre-commit + L5 gitea-pr-checks）的
// **整脚本**回归面。
//
// 为什么是整脚本而不是 import 判据函数：该闸 133 行**零导出**、判据写在顶层，
// 结构上没有可 import 的符号。量具 `pnpm report:gate-regression-face` 把它记为
// 「零判据覆盖 · P1/P2 双 PASS」。本文件是那条判定的兑现（[[INFRA-F138]]）。
// ⛔ 闸本体一行没改。
//
// 重点覆盖 2026-07-29 那次 root-fix 的**回归钉**：计数曾经过 figma-to-code-mapping
// 按 `figmaName` 归并，于是容器组件白拿了它子项的 entry（Tab / Steps / Breadcrumb 三个
// 盲区，Tab.vue 的 chrome 漂了几个月只有人眼发现）。现在的判据是
// `entry.codeComponent === canonical`，**兄弟组件的 entry 不能满足它** —— 见
// 「§root-fix 回归钉」那组用例，它们就是防止那条计数退回旧形态的。
//
// 覆盖：绿档非空过 + uncovered 致败 + exempt 通路 + NAME_ALIAS + 两条 hint 分叉
// + mapping status 过滤 + report/manifest 滞后提示 + MANIFEST 两种容器形态 + 4 条接线钉。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-render-verification-coverage.mjs'
const REPORT = 'figma-data/normalized/render-verification.report.json'
const MAPPING = 'figma-data/figma-to-code-mapping.json'
const MANIFEST = 'figma-data/render-verification-manifest.json'
const EXEMPT = 'figma-data/audit-allowlist/canonical-exempt.json'
const SILENT = 'figma-data/audit-allowlist/render-coverage-silent-names.json'

afterEach(cleanupGateFixtures)

type Entry = { codeComponent: string; figmaName?: string }
type Mapping = { figmaName: string; codeNames: string[]; status?: string }
type SilentRow = { codeComponent: string; figmaName: string; reason: string; addedAt: string }

type Overrides = {
  /** `src/canonical/` 下的组件名（不含 .vue），默认一个 fixture 专属名 */
  canonicals?: string[]
  manifest?: Entry[] | Record<string, Entry>
  report?: Entry[]
  mappings?: Mapping[]
  exempt?: Record<string, { renderCoverageReason: string }>
  /** S2/S3 具名豁免表；默认空表（⛔ 不是省略文件——省略会触发 fail-closed） */
  silent?: SilentRow[]
  /** 传 false 时**不写**豁免表文件，用来测 fail-closed（⛔ 只给那一条用例用） */
  writeSilentFile?: boolean
  files?: Record<string, string>
}

/**
 * S2 判据按 (codeComponent, figmaName) 成对判。既有用例写 manifest 时只给
 * `codeComponent`，补一个与 approved 映射对得上的 `figmaName`，**让它们保持原来的语义**
 * （那些用例测的是 S1 / root-fix，不是 S2）。⛔ 这不是给 S2 放水：S2 自己的用例显式传
 * `figmaName`，走的不是这条补全。
 */
function fillFigmaNames(manifest: Entry[] | Record<string, Entry>, mappings: Mapping[]) {
  const nameFor = (code: string) =>
    mappings.find((m) => (m.status ?? 'approved') === 'approved' && m.codeNames.includes(code))?.figmaName ?? 'Fx/Probe'
  const fill = (e: Entry): Entry => ({ ...e, figmaName: e.figmaName ?? nameFor(e.codeComponent) })
  return Array.isArray(manifest)
    ? manifest.map(fill)
    : Object.fromEntries(Object.entries(manifest).map(([k, v]) => [k, fill(v)]))
}

function build(o: Overrides = {}) {
  const canonicals = o.canonicals ?? ['FxCoverProbe']
  const manifest = o.manifest ?? [{ codeComponent: 'FxCoverProbe' }, { codeComponent: 'FxCoverProbe' }]
  const report = o.report ?? (Array.isArray(manifest) ? manifest : Object.values(manifest))
  const mappings = o.mappings ?? [
    { figmaName: 'Fx/Probe', codeNames: ['FxCoverProbe'], status: 'approved' },
  ]

  const vueFiles: Record<string, string> = {}
  for (const c of canonicals) vueFiles[`src/canonical/${c}.vue`] = `<template><div /></template>\n`

  return createGateFixture({
    gate: GATE,
    prefix: 'rv-coverage-fx',
    dirs: ['src/canonical'],
    files: {
      ...vueFiles,
      [MANIFEST]: JSON.stringify(fillFigmaNames(manifest, mappings)),
      [REPORT]: JSON.stringify({ entries: report }),
      [MAPPING]: JSON.stringify({ mappings }),
      [EXEMPT]: JSON.stringify({ exempt: o.exempt ?? {} }),
      ...(o.writeSilentFile === false ? {} : { [SILENT]: JSON.stringify({ silent: o.silent ?? [] }) }),
      ...(o.files ?? {}),
    },
  })
}

describe('audit:render-verification-coverage — 绿档 + 自印非空过', () => {
  it('唯一 canonical 有 2 条 entry → exit 0，且自印的四个数全是 fixture 自己的（≠ 真仓库 ⇒ 非空过）', () => {
    const run = runGate(build(), GATE)
    // 真仓库是 30+ canonical / 936 条 manifest —— 「1 canonicals, 1 covered」不可能对上。
    expectGateGreen(run, {
      contains: [
        'audit-render-verification-coverage: 1 canonicals, 1 covered, 0 exempt, 0 uncovered',
        '✓ FxCoverProbe',
        '2 entries via 1 figma name(s)',
        'OK — all 1 non-exempt canonicals have ≥1 verifier entry (0 exempt by design).',
      ],
    })
  })
})

describe('audit:render-verification-coverage — 判据：uncovered', () => {
  it('canonical 在 manifest 里 0 条 entry 且不在豁免表 → 红并点名它', () => {
    const run = runGate(build({ manifest: [] }), GATE)
    expectGateRed(run, {
      marker: 'Uncovered (0 verifier entries — claims Figma binding but invisible to audit):',
      checks: ['✗ FxCoverProbe', 'Next action: register missing entries'],
    })
  })

  it('hint 分叉 (a)：**有** approved mapping 但 0 entries → 指向 manifest 生成器', () => {
    const run = runGate(build({ manifest: [] }), GATE)
    expectGateRed(run, {
      checks: ['mapping points to: Fx/Probe', "verifier manifest generator didn't emit entries"],
    })
  })

  it('hint 分叉 (b)：**无** mapping → 指向先去登记 mapping', () => {
    const run = runGate(build({ manifest: [], mappings: [] }), GATE)
    expectGateRed(run, { checks: ['no figma-to-code-mapping.json entry — register first'] })
  })

  it('mapping 存在但 status ≠ approved → 按「无 mapping」走（草稿态不算数）', () => {
    const run = runGate(
      build({ manifest: [], mappings: [{ figmaName: 'Fx/Probe', codeNames: ['FxCoverProbe'], status: 'draft' }] }),
      GATE,
    )
    expectGateRed(run, { checks: ['no figma-to-code-mapping.json entry — register first'] })
  })
})

describe('audit:render-verification-coverage — §root-fix 回归钉（2026-07-29 那个盲区）', () => {
  it('⛔ 兄弟组件的 entry **不能**让容器 covered（判据是 codeComponent 全等，不是 figmaName 归并）', () => {
    // 旧形态下：容器 FxContainer 与子项 FxContainerItem 映射到同一个 figmaName，
    // 子项的 12 条 entry 会被记到容器名下 ⇒ 容器显示 covered 而实际零测量。
    // 这正是 Tab / Steps / Breadcrumb 当年的病。本用例钉住它不许复发。
    const run = runGate(
      build({
        canonicals: ['FxContainer', 'FxContainerItem'],
        manifest: Array.from({ length: 12 }, () => ({ codeComponent: 'FxContainerItem' })),
        mappings: [
          { figmaName: 'Fx/Container Item', codeNames: ['FxContainer', 'FxContainerItem'], status: 'approved' },
        ],
      }),
      GATE,
    )
    expectGateRed(run, { checks: ['✗ FxContainer', 'mapping points to: Fx/Container Item'] })
    // 子项自己是真 covered —— 证明红的只有容器那一条，不是整体判据太宽
    expect(run.stdout).toContain('✓ FxContainerItem')
    expect(run.stdout).toContain('1 covered, 0 exempt, 1 uncovered')
  })

  it('容器自己有 entry 时才 covered（同一份 fixture 只给容器补 1 条 entry ⇒ 转绿）', () => {
    const run = runGate(
      build({
        canonicals: ['FxContainer', 'FxContainerItem'],
        manifest: [
          ...Array.from({ length: 12 }, () => ({ codeComponent: 'FxContainerItem' })),
          { codeComponent: 'FxContainer' },
        ],
        mappings: [
          { figmaName: 'Fx/Container Item', codeNames: ['FxContainer', 'FxContainerItem'], status: 'approved' },
        ],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['2 canonicals, 2 covered, 0 exempt, 0 uncovered'] })
  })
})

describe('audit:render-verification-coverage — 豁免表与别名', () => {
  it('0 entry 但在 canonical-exempt.json 里 → 绿，且印出豁免理由（不是静默放行）', () => {
    const run = runGate(
      build({ manifest: [], exempt: { FxCoverProbe: { renderCoverageReason: 'fixture: 无 Figma 容器节点' } } }),
      GATE,
    )
    expectGateGreen(run, {
      contains: [
        '1 canonicals, 0 covered, 1 exempt, 0 uncovered',
        '◦ FxCoverProbe',
        'fixture: 无 Figma 容器节点',
        'OK — all 0 non-exempt canonicals have ≥1 verifier entry (1 exempt by design).',
      ],
    })
  })

  it('豁免表按 canonical **文件名**匹配，不是按 alias 后的 effective 名', () => {
    // ButtonBridge 的 effective 是 Button，但豁免键必须写 ButtonBridge
    const run = runGate(
      build({
        canonicals: ['ButtonBridge'],
        manifest: [],
        mappings: [],
        exempt: { Button: { renderCoverageReason: '写错了键' } },
      }),
      GATE,
    )
    expectGateRed(run, { checks: ['✗ ButtonBridge'] })
  })

  it('NAME_ALIAS：ButtonBridge 记在 Button 名下的 entry 算数（re-export 别名）', () => {
    const run = runGate(
      build({
        canonicals: ['ButtonBridge'],
        manifest: [{ codeComponent: 'Button' }],
        mappings: [{ figmaName: 'Fx/Button', codeNames: ['Button'], status: 'approved' }],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['1 canonicals, 1 covered', '✓ ButtonBridge'] })
  })
})

describe('audit:render-verification-coverage — §S2/S3 第二个盲区（2026-09-11）', () => {
  // 病：S1 只要求「每个 canonical ≥1 条」。一个 canonical 有 N 个 approved Figma 来源、
  // 只有 1 个出条目时，它照样 PASS —— 真实实例是 ButtonBridge 3/9。自印的
  // 「via N figma name(s)」里那个 N 从来不是判据。下面这组钉住 S2/S3。
  const twoNames: Mapping[] = [
    { figmaName: 'Fx/Probe A', codeNames: ['FxCoverProbe'], status: 'approved' },
    { figmaName: 'Fx/Probe B', codeNames: ['FxCoverProbe'], status: 'approved' },
  ]
  /** A 出条目、B 静默 —— S1 恒绿（有 1 条 entry），S2 才看得见 B */
  const onlyAEmits = [{ codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe A' }]

  it('🔴 故障臂：2 个 approved 名字只有 1 个出条目、且未登记 → S2 红并点名那个静默的', () => {
    const run = runGate(build({ manifest: onlyAEmits, mappings: twoNames }), GATE)
    expectGateRed(run, {
      marker: '✗ S2 FAIL',
      checks: ['FxCoverProbe ← "Fx/Probe B"', '1/2 个名字出条目', '静默: Fx/Probe B'],
    })
    // ⛔ 关键：S1 在这个 fixture 上是**绿**的 —— 证明红是 S2 贡献的，不是 S1 顺带
    expect(run.stdout).toContain('1 canonicals, 1 covered, 0 exempt, 0 uncovered')
    expect(run.stdout).not.toContain('Uncovered (0 verifier entries')
  })

  it('✅ 对照臂：同一份 fixture 给 B 具名登记 → 转绿（⛔ 证明红来自「未登记」而非「有静默」）', () => {
    const run = runGate(
      build({
        manifest: onlyAEmits,
        mappings: twoNames,
        silent: [{ codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B', reason: 'fixture', addedAt: '2026-09-11' }],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['S2/S3 clean', '1 个静默名字全部具名登记'] })
  })

  it('✅ 另一条对照臂：让 B 也真的出条目 → 不需要任何登记就绿（判据认的是 entry，不是表）', () => {
    const run = runGate(
      build({
        manifest: [...onlyAEmits, { codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B' }],
        mappings: twoNames,
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['0 个静默名字全部具名登记'] })
  })

  it('🔴 S3 shrink-only：登记的那一行现在已经出条目了 → 红并要求删行', () => {
    const run = runGate(
      build({
        manifest: [...onlyAEmits, { codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B' }],
        mappings: twoNames,
        silent: [{ codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B', reason: 'fixture', addedAt: '2026-09-11' }],
      }),
      GATE,
    )
    expectGateRed(run, {
      marker: '✗ S3 FAIL',
      checks: ['FxCoverProbe ← "Fx/Probe B"', '它现在产出 1 条 entry 了'],
    })
  })

  it('🔴 S3：登记的那一行已不在 approved 映射里 → 红（表不许养僵尸行）', () => {
    const run = runGate(
      build({
        manifest: onlyAEmits,
        mappings: [twoNames[0]],
        silent: [{ codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B', reason: 'fixture', addedAt: '2026-09-11' }],
      }),
      GATE,
    )
    expectGateRed(run, { marker: '✗ S3 FAIL', checks: ['已不在 approved 映射里'] })
  })

  it('⛔ must-not-fire：S2 按 (codeComponent, figmaName) 成对判 —— 别的组件出的同名条目不算数', () => {
    const run = runGate(
      build({
        canonicals: ['FxCoverProbe', 'FxOther'],
        // FxOther 出了一条 figmaName 同为 "Fx/Probe B" 的条目，⛔ 不该让 FxCoverProbe 的 B 变成非静默
        manifest: [...onlyAEmits, { codeComponent: 'FxOther', figmaName: 'Fx/Probe B' }],
        mappings: [...twoNames, { figmaName: 'Fx/Probe B', codeNames: ['FxOther'], status: 'approved' }],
      }),
      GATE,
    )
    expectGateRed(run, { marker: '✗ S2 FAIL', checks: ['FxCoverProbe ← "Fx/Probe B"'] })
    // FxOther 自己不该被点名
    expect(run.stdout).not.toContain('FxOther ← "Fx/Probe B"')
  })

  it('⛔ must-not-fire：status ≠ approved 的映射不进 S2 判定面（草稿态不产生静默债）', () => {
    const run = runGate(
      build({
        manifest: onlyAEmits,
        mappings: [twoNames[0], { figmaName: 'Fx/Probe B', codeNames: ['FxCoverProbe'], status: 'draft' }],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['0 个静默名字全部具名登记'] })
  })

  it('fail-closed：豁免表缺文件 → 非零退出并逐字说明「删掉它不能让本闸闭嘴」', () => {
    const run = runGate(build({ manifest: onlyAEmits, mappings: twoNames, writeSilentFile: false }), GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stdout}${run.stderr}`).toContain('读不动具名豁免表')
    expect(`${run.stdout}${run.stderr}`).toContain('删掉它不能让本闸闭嘴')
  })

  it('fail-closed：豁免表形态错（silent 不是数组）→ 非零退出，⛔ 不当成空表', () => {
    const run = runGate(
      build({ manifest: onlyAEmits, mappings: twoNames, files: { [SILENT]: JSON.stringify({ silent: {} }) } }),
      GATE,
    )
    expect(run.status).not.toBe(0)
    expect(`${run.stdout}${run.stderr}`).toContain('顶层 `silent` 不是数组')
    // ⛔ 这一句只有 fail-closed 那条路径会印。没有它，本用例在「闸退化成 fail-open」时
    // 会因为 S2 顺带把退出码弄成非零而**假通过** —— 牙检查当场抓到过。
    expect(`${run.stdout}${run.stderr}`).toContain('删掉它不能让本闸闭嘴')
  })

  it('fail-closed：豁免行缺字段 / addedAt 不是日期 → 非零退出并点名第几行', () => {
    const missing = runGate(
      build({
        manifest: onlyAEmits,
        mappings: twoNames,
        files: { [SILENT]: JSON.stringify({ silent: [{ codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B' }] }) },
      }),
      GATE,
    )
    expect(missing.status).not.toBe(0)
    expect(`${missing.stdout}${missing.stderr}`).toContain('第 0 行缺 reason')
    expect(`${missing.stdout}${missing.stderr}`).toContain('删掉它不能让本闸闭嘴')

    const badDate = runGate(
      build({
        manifest: onlyAEmits,
        mappings: twoNames,
        files: {
          [SILENT]: JSON.stringify({
            silent: [{ codeComponent: 'FxCoverProbe', figmaName: 'Fx/Probe B', reason: 'x', addedAt: '2026/09/11' }],
          }),
        },
      }),
      GATE,
    )
    expect(badDate.status).not.toBe(0)
    expect(`${badDate.stdout}${badDate.stderr}`).toContain('不是 YYYY-MM-DD')
    expect(`${badDate.stdout}${badDate.stderr}`).toContain('删掉它不能让本闸闭嘴')
  })
})

describe('audit:render-verification-coverage — report 滞后提示', () => {
  it('report 条数少于 manifest → 印 ⚠ 提示重跑 render-verification（但不改判据、仍绿）', () => {
    const run = runGate(
      build({
        manifest: [{ codeComponent: 'FxCoverProbe' }, { codeComponent: 'FxCoverProbe' }],
        report: [{ codeComponent: 'FxCoverProbe' }],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['⚠ report has 1 — run pnpm test:render-verification'] })
  })

  it('report 与 manifest 一致 → 不印 ⚠（阴性对照：提示不是恒真）', () => {
    const run = runGate(build(), GATE)
    expect(run.stdout).not.toContain('⚠ report has')
  })
})

describe('audit:render-verification-coverage — 接线钉（判据 → 退出码 → 点名输出）', () => {
  it('MANIFEST 是对象形态（非数组）也能读（Object.values 分支）', () => {
    const run = runGate(build({ manifest: { a: { codeComponent: 'FxCoverProbe' } } }), GATE)
    expectGateGreen(run, { contains: ['1 canonicals, 1 covered'] })
  })

  it('多个 uncovered → 全部逐条印出且计数正确（接线不吞、不止报第一条）', () => {
    const run = runGate(
      build({ canonicals: ['FxA', 'FxB', 'FxC'], manifest: [{ codeComponent: 'FxA' }], mappings: [] }),
      GATE,
    )
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('3 canonicals, 1 covered, 0 exempt, 2 uncovered')
    expect(run.stdout).toContain('✗ FxB')
    expect(run.stdout).toContain('✗ FxC')
  })

  it('⛔ must-not-hit：canonical 目录下的非 .vue 文件不算 canonical', () => {
    const run = runGate(build({ files: { 'src/canonical/notes.md': '# x\n' } }), GATE)
    expectGateGreen(run, { contains: ['1 canonicals,'] })
  })

  it('豁免表 JSON 缺失 → 非零崩溃（fail-closed），⛔ 不是当成空豁免表继续', () => {
    // ⚠️ 如实登记：这是 readFileSync 抛 ENOENT 的**崩溃式** fail-closed，不是判据红。
    // 它的价值在于「SoT 文件没了不会退化成静默放行」。
    const root = createGateFixture({
      gate: GATE,
      prefix: 'rv-coverage-fx-noexempt',
      dirs: ['src/canonical'],
      files: {
        'src/canonical/FxCoverProbe.vue': '<template><div /></template>\n',
        [MANIFEST]: '[]',
        [REPORT]: '{"entries":[]}',
        [MAPPING]: '{"mappings":[]}',
      },
    })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('ENOENT')
  })
})
