// tests/audit-component-affordances.test.ts
// -----------------------------------------------------------------------------
// `audit:component-affordances`（L4 pre-commit + L5 gitea-pr-checks）的**整脚本**回归面。
//
// 为什么是整脚本而不是 import 判据函数：该闸 221 行**零导出**、五类判据（A–E）全写在顶层，
// 结构上没有可 import 的符号。量具 `pnpm report:gate-regression-face` 把它记为
// 「零判据覆盖 · P1/P2 双 PASS」。本文件是那条判定的兑现（[[INFRA-F138]]）。
// ⛔ 闸本体一行没改。
//
// ⚠️ 这条闸 `import` 同仓的 `scripts/generate-component-affordances.mjs`，而那个文件也用
// `import.meta.url` 自算 REPO_ROOT ⇒ 它**必须拷进 fixture、不能软链**（软链会让 ESM 走
// realpath 把它的 root 解析回真仓库，闸就去读真仓库的 JSON 了，而且**照样绿**）。
// harness 的 `copyFiles` 就是为这条加的，理由逐字写在 `gate-fixture-root.ts`。
//
// ⚠️ 期望 MD **不在本文件里抄**：直接 import 活源的 `renderMarkdown` 渲染 fixture 的 data。
// 抄一份渲染结果就是第二份会漂的副本，而判据 E 恰恰是「MD 与 JSON 是否同步」。
//
// 覆盖：绿档非空过 + A/B/C/D/E 五类判据各自的致败与阴性对照 + C 的四条分叉
// （不存在 / 解析到别的文件 / code_import 指错子路径 / null 却被导出）+ 3 条接线钉。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'
// 活源渲染器 —— 用它算期望 MD，⛔ 别在本文件里维护渲染结果的副本
import { renderMarkdown } from '../scripts/generate-component-affordances.mjs'

const GATE = 'scripts/audit-component-affordances.mjs'
const GENERATOR = 'scripts/generate-component-affordances.mjs'
const JSON_PATH = 'docs/internal/component-affordances.json'
const MD_PATH = 'docs/internal/component-affordances.md'
const PKG = '@fx/affordance-probe'

afterEach(cleanupGateFixtures)

/** 一条形态完整的 entry（十个必填字段齐全）。 */
function entry(over: Record<string, unknown> = {}) {
  return {
    code_name: 'FxWidget',
    figma_name: 'Fx/Widget',
    category: 'general',
    summary: 'fixture 组件',
    when_to_use: '仅用于本测试',
    built_in_features: [],
    do_not_hand_compose: '',
    composition: {},
    code_props: [{ name: 'label', type: 'string' }],
    status: 'stable',
    npm_export: 'FxWidget',
    code_import: `import { FxWidget } from '${PKG}'`,
    ...over,
  }
}

const WIDGET_VUE = `<template><div /></template>
<script setup lang="ts">
defineProps<{
  label?: string
  size?: string
}>()
</script>
`

type Overrides = {
  components?: Record<string, unknown>[]
  /** src/canonical/ 下的 .vue：组件名 → 内容 */
  vue?: Record<string, string>
  /** 覆盖 src/index.ts 内容 */
  indexTs?: string
  /** 覆盖 src/chart.ts 内容；传 null 表示该文件不存在 */
  chartTs?: string | null
  /** 覆盖 MD 内容（不传 = 用活源渲染器按 data 渲，⇒ E 判据绿） */
  md?: string
  /** 覆盖整份 JSON 的原始文本（造解析失败用） */
  rawJson?: string
  files?: Record<string, string>
}

function build(o: Overrides = {}) {
  const components = o.components ?? [entry()]
  const data = { _meta: { purpose: 'fixture' }, components }
  const vue = o.vue ?? { FxWidget: WIDGET_VUE }

  const files: Record<string, string> = {
    'package.json': JSON.stringify({ name: PKG }),
    [JSON_PATH]: o.rawJson ?? JSON.stringify(data, null, 2),
    [MD_PATH]: o.md ?? renderMarkdown(data),
    'src/index.ts':
      o.indexTs ??
      `import FxWidget from './canonical/FxWidget.vue'\nexport { FxWidget }\n`,
  }
  if (o.chartTs !== null) {
    files['src/chart.ts'] = o.chartTs ?? `export { default as FxChart } from './canonical/FxChart.vue'\n`
  }
  for (const [name, body] of Object.entries(vue)) files[`src/canonical/${name}.vue`] = body

  return createGateFixture({
    gate: GATE,
    prefix: 'affordances-fx',
    copyFiles: [GENERATOR],
    dirs: ['src/canonical', 'docs/internal'],
    files: { ...files, ...(o.files ?? {}) },
  })
}

describe('audit:component-affordances — 绿档 + 自印非空过', () => {
  it('五类判据全过 → exit 0，且自印的三个数是 fixture 自己的（≠ 真仓库 ⇒ 非空过）', () => {
    const run = runGate(build(), GATE)
    // 真仓库是 30+ entries / 30+ exported components —— 「1 entries; coverage 1/1 … = 2」对不上。
    // 「2 exported components」= index.ts 的 FxWidget + chart.ts 的 FxChart（后者无 entry，
    // 只进出口面不进 comps 循环）—— 两条出口面都真的被解析了，这是 C 判据非空过的凭据。
    expectGateGreen(run, {
      contains: [
        '[audit:component-affordances] PASS — 1 entries; coverage 1/1;',
        'npm_export checked against 2 package entry points (. + ./chart) = 2 exported components',
      ],
    })
  })
})

describe('audit:component-affordances — 判据 A：必填字段与 category 闭集', () => {
  it('缺一个必填字段 → 红并点名是哪个字段', () => {
    const e = entry()
    delete (e as Record<string, unknown>).summary
    const run = runGate(build({ components: [e] }), GATE)
    expectGateRed(run, {
      marker: '[audit:component-affordances] FAIL',
      checks: ['[A] FxWidget: missing required field "summary"'],
    })
  })

  it('category 不在闭集 → 红并印出允许值', () => {
    const run = runGate(build({ components: [entry({ category: 'bogus' })] }), GATE)
    expectGateRed(run, {
      checks: ['[A] FxWidget: invalid category "bogus"', 'allowed: form-input, data-display, navigation, feedback, general'],
    })
  })

  it('⛔ must-not-hit：闭集里的五个 category 逐个放行', () => {
    for (const cat of ['form-input', 'data-display', 'navigation', 'feedback', 'general']) {
      const run = runGate(build({ components: [entry({ category: cat })] }), GATE)
      expect(run.status, `category=${cat} 应放行`).toBe(0)
    }
  })
})

describe('audit:component-affordances — 判据 B：双向覆盖', () => {
  it('entry 有、.vue 没有 → 红（entry 指向不存在的组件）', () => {
    const run = runGate(build({ components: [entry({ code_name: 'FxGhost', npm_export: null, code_import: null })], vue: {} }), GATE)
    expectGateRed(run, { checks: ['[B] entry "FxGhost" has no src/canonical/FxGhost.vue'] })
  })

  it('.vue 有、entry 没有 → 红并明说是 silent gap（新组件忘了登记）', () => {
    const run = runGate(build({ vue: { FxWidget: WIDGET_VUE, FxOrphan: '<template><div /></template>\n' } }), GATE)
    expectGateRed(run, {
      checks: ['[B] src/canonical/FxOrphan.vue has no entry in component-affordances.json (silent gap)'],
    })
  })
})

describe('audit:component-affordances — 判据 C：npm_export 对账真实出口面', () => {
  it('npm_export 写了一个谁都没导出的名字 → 红并点出查了哪几个出口面', () => {
    const run = runGate(build({ components: [entry({ npm_export: 'NotExported' })] }), GATE)
    expectGateRed(run, {
      checks: ['[C] FxWidget: npm_export "NotExported" not exported from any package entry point (. / ./chart)'],
    })
  })

  it('npm_export 存在但解析到**别的** .vue → 红并点名它实际解析到谁', () => {
    const run = runGate(
      build({
        indexTs: `import FxWidget from './canonical/FxOther.vue'\nexport { FxWidget }\n`,
        vue: { FxWidget: WIDGET_VUE, FxOther: '<template><div /></template>\n' },
        components: [entry(), entry({ code_name: 'FxOther', npm_export: null, code_import: null, code_props: [] })],
      }),
      GATE,
    )
    expectGateRed(run, {
      checks: ['[C] FxWidget: npm_export "FxWidget" actually resolves to FxOther.vue, not FxWidget.vue'],
    })
  })

  it('code_import 指向错的子路径 → 红（Chart 从 ./chart 导出却写主入口，正是它防的那个错）', () => {
    const run = runGate(
      build({
        vue: { FxWidget: WIDGET_VUE, FxChart: '<template><div /></template>\n' },
        components: [
          entry(),
          entry({
            code_name: 'FxChart',
            npm_export: 'FxChart',
            code_import: `import { FxChart } from '${PKG}'`, // ← 错：它其实在 ./chart
            code_props: [],
          }),
        ],
      }),
      GATE,
    )
    expectGateRed(run, {
      checks: [`[C] FxChart: code_import does not point at '${PKG}/chart'`],
    })
  })

  it('同一条只把 code_import 改成 ./chart → 绿（子路径出口被正确识别，形态一解析生效）', () => {
    const run = runGate(
      build({
        vue: { FxWidget: WIDGET_VUE, FxChart: '<template><div /></template>\n' },
        components: [
          entry(),
          entry({
            code_name: 'FxChart',
            npm_export: 'FxChart',
            code_import: `import { FxChart } from '${PKG}/chart'`,
            code_props: [],
          }),
        ],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['2 entries; coverage 2/2;', '= 2 exported components'] })
  })

  it('npm_export = null 但包其实导出了它 → 红（2026-08-11 那次「静默答错」的钉）', () => {
    const run = runGate(build({ components: [entry({ npm_export: null, code_import: null })] }), GATE)
    expectGateRed(run, {
      checks: [`[C] FxWidget: npm_export is null but '${PKG}' exports it as "FxWidget" — set npm_export`],
    })
  })

  it('出口解析形态变了（一个都没解析出来）→ 红并要求修 parser，⛔ 不静默放行', () => {
    // 闸头注释逐字：解析 0 个就必须炸，否则 null 分支全部空过 = 假绿。
    const run = runGate(
      build({ indexTs: `export const notAComponent = 1\n`, components: [entry({ npm_export: null, code_import: null })] }),
      GATE,
    )
    expectGateRed(run, { checks: ['[C] parsed 0 component exports from src/index.ts — export shape changed, fix the parser'] })
  })

  it('出口文件缺失 → 红并说明是 package exports 的哪个子路径没源', () => {
    const run = runGate(build({ chartTs: null }), GATE)
    expectGateRed(run, { checks: ['[C] entry point src/chart.ts missing — package exports["./chart"] has no source'] })
  })

  it('report-only：公开出口不来自 src/canonical/ → 印 note 但**不**拦（exit 0）', () => {
    const run = runGate(
      build({
        indexTs: `import FxWidget from './components/FxWidget.vue'\nexport { FxWidget }\n`,
        files: { 'src/components/FxWidget.vue': WIDGET_VUE },
      }),
      GATE,
    )
    expectGateGreen(run, {
      contains: ['note — 1 public export(s) not sourced from src/canonical/:', 'FxWidget ← ./components/FxWidget.vue'],
    })
  })
})

describe('audit:component-affordances — 判据 D：code_props 对账 defineProps', () => {
  it('code_props 里的名字不在 defineProps → 红（stale entry）', () => {
    const run = runGate(build({ components: [entry({ code_props: [{ name: 'ghostProp', type: 'string' }] })] }), GATE)
    expectGateRed(run, { checks: ['[D] FxWidget: code_props "ghostProp" not found in defineProps (stale entry?)'] })
  })

  it('⛔ must-not-hit：defineProps 里真有的名字放行（含第二个 prop）', () => {
    const run = runGate(
      build({ components: [entry({ code_props: [{ name: 'label', type: 'string' }, { name: 'size', type: 'string' }] })] }),
      GATE,
    )
    expectGateGreen(run, { contains: ['PASS — 1 entries'] })
  })

  it('列了 code_props 但 .vue 根本没有 typed defineProps → 红', () => {
    const run = runGate(build({ vue: { FxWidget: '<template><div /></template>\n' } }), GATE)
    expectGateRed(run, { checks: ['[D] FxWidget: lists code_props but its .vue has no typed defineProps block'] })
  })

  it('⛔ must-not-hit：没有 typed defineProps 且 code_props 为空 → 绿（slot-only 容器的正常形态）', () => {
    const run = runGate(
      build({ vue: { FxWidget: '<template><div /></template>\n' }, components: [entry({ code_props: [] })] }),
      GATE,
    )
    expectGateGreen(run, { contains: ['PASS — 1 entries'] })
  })
})

describe('audit:component-affordances — 判据 E：MD 与 JSON 同步', () => {
  it('MD 落后于 JSON → 红并给出重生成命令', () => {
    const run = runGate(build({ md: '# 手改过的旧内容\n' }), GATE)
    expectGateRed(run, {
      checks: ['[E] component-affordances.md is stale — run `pnpm generate:component-affordances`'],
    })
  })

  it('MD 文件整个不存在 → 同样红（空字符串 ≠ 期望渲染）', () => {
    const run = runGate(build({ files: {} , md: '' }), GATE)
    expectGateRed(run, { checks: ['[E] component-affordances.md is stale'] })
  })

  it('⛔ must-HIT 对照：改了 JSON 却没重渲 MD 一定红（E 不是恒绿）', () => {
    const data = { _meta: { purpose: 'fixture' }, components: [entry()] }
    // 用旧 data 渲染的 MD 配上改过 summary 的新 JSON
    const run = runGate(
      build({ components: [entry({ summary: '改过了' })], md: renderMarkdown(data) }),
      GATE,
    )
    expectGateRed(run, { checks: ['[E] component-affordances.md is stale'] })
  })
})

describe('audit:component-affordances — 接线钉（判据 → 退出码 → 点名输出）', () => {
  it('多类判据同时违例 → 计数正确且每条逐行印出（接线不吞、不止报第一类）', () => {
    const e = entry({ category: 'bogus', code_props: [{ name: 'ghostProp', type: 'string' }] })
    const run = runGate(build({ components: [e], md: '# stale\n' }), GATE)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('[audit:component-affordances] FAIL (3)')
    expect(run.stderr).toContain('[A] FxWidget: invalid category "bogus"')
    expect(run.stderr).toContain('[D] FxWidget: code_props "ghostProp" not found in defineProps')
    expect(run.stderr).toContain('[E] component-affordances.md is stale')
    // ⛔ PASS 行不许同时出现
    expect(run.stdout).not.toContain('PASS —')
  })

  it('JSON 解析失败 → exit 1 且明说是没 parse 成（⛔ 不是当成空 components 继续）', () => {
    const run = runGate(build({ rawJson: '{ not json' }), GATE)
    expectGateRed(run, { marker: '[audit:component-affordances] FAIL — JSON did not parse:' })
  })

  it('JSON 文件整个不存在 → 同样走 parse 失败分支（fail-closed）', () => {
    const root = createGateFixture({
      gate: GATE,
      prefix: 'affordances-fx-nojson',
      copyFiles: [GENERATOR],
      dirs: ['src/canonical', 'docs/internal'],
      files: { 'package.json': JSON.stringify({ name: PKG }), 'src/index.ts': 'export {}\n' },
    })
    const run = runGate(root, GATE)
    expectGateRed(run, { marker: '[audit:component-affordances] FAIL — JSON did not parse:' })
  })
})
