// tests/audit-mockup-binding-fidelity-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-mockup-binding-fidelity.mjs` 的**整脚本回归面**，射程限定在它那
// **一条绿档 WARN 通路**：`/variables/local` 返回 403 ⇒ B-SEM under-report。
//
// WHY 这条值得单独钉（⛔ 不是「顺手补个测试」）：
//   它发生在 **exit 0** 那一趟里 —— 变量名一个都解析不出来，于是 B-SEM 的判据
//   `resolve(boundVarId(fill))` 恒返回 `null`，**整条 B-SEM 结构上不可能报出任何违例**，
//   而闸打印 `✅ D16 binding-fidelity audit pass` 并 exit 0。
//   ⇒ **那条 WARN 是绿档唯一的免责声明。** 它一旦退化成静默（`console.warn` 被吞、
//   或 403 分支被并进普通 `throw`），**没有任何退出码会变**，于是没有任何人会发现。
//
// 🔴 **本面⛔ 不只断言那句 WARN 的字符串 —— 它钉的是那句话的【语义】。**
//   WARN 逐字说「B-SEM 将 under-report（无法解析 grey vs semantic）。B-TYPO/B-EFFECT/B-SCALE
//   不受影响」。三条用例分别钉这句话的三半：
//     · A2 双臂：**同一份**文档，403 ⇒ B-SEM 报不出 / 200 ⇒ B-SEM 报出 1 条
//       ⇒ under-report **真的发生了**，⛔ 不是文案自称。
//     · A3：403 臂里 B-TYPO **照样**报得出 ⇒「不受影响」那半句成立。
//     · A1：绿档那一趟 WARN 确实打在 stderr 上。
//   ⇒ 只断字符串的话，把 B-SEM 判据整个删掉本面照样绿（那正是 2026-09-15 同轮在另一条闸上
//     实测到的假绿形态）。
//
// ⚠️ **装置：`NODE_OPTIONS=--import <stub>` 覆写 `globalThis.fetch`，被测对象一行不改。**
//   闸的上游 API 基址是写死常量（见闸源码的 `const API = …`）⇒ 不可参数化，
//   而 [[INFRA-F138]] 逐字封死了「为了让 harness 能用去改活闸」这条路。
//   ⇒ stub 是唯一零改动入口。它随 fixture 写进 fixture root，⛔ 不落进真仓库。
//   🔴 **⛔ 别在本文件里写那个 host 字面量、也别写请求头名** ——
//   `audit:figma-env-single-source` 的 S5/S6 标记就是这两个，且逐字声明「出现在**注释**里
//   也算命中…这是刻意的 fail-closed 方向」。本文件不解析凭据（那个 token 是喂给 fixture 的
//   假值）⇒ 写进来只会制造一条误报。2026-09-15 本文件初稿就因此让全量红 1 条。
//
// 🔴 **「stub 没生效」必须能被抓到，⛔ 不能靠信任 —— 否则整组假绿。**
//   陷阱：stub 若没加载，闸会拿着测试里那个**假 token** 去打**真**上游 API，
//   而真 API 对无效 token 同样返回 **403** ⇒ 那句 WARN **照样打出来** ⇒ 断言字符串的用例
//   会通过，而它测的是真网络、不是 stub。
//   ⇒ 本面的反制是 **A2 的 200 臂**：它断言 B-SEM 报出的 `nodeId` / `boundVarName` 是
//   **本文件编出来的哨兵值**（`SENTINEL_NODE_ID`）——真 API 不可能返回它。
//   ⇒ 那一臂一旦绿，就同时证明了三件事：stub 真的接管了 fetch · under-report 真的发生 ·
//     这条 WARN ⛔ 不是恒真（200 臂 stderr 里它必须消失）。
//
// ⛔ 断言必须**点名 WARN 的原文**，⛔ 不许写 `expect(stderr).not.toBe('')` ——
//   fixture 不是 git 仓，闸链路里任何 `execSync git` 的 `fatal: not a git repository`
//   都会透传进 stderr ⇒「非空」这类断言恒真。
//
// ⛔ 污染纪律：本文件除被测闸自身外**不出现任何别的闸的 `.mjs` basename、也不出现任何
//   别的 `audit:` npm key** —— 否则按名字扫的量具会把那条闸误报成「已覆盖」。
//
// ⚠️ **本面登记的覆盖边界（⛔ 是边界，不是 TODO）**：
//   1. 只抓 **must-hit** 那一侧（「摘掉 WARN 会不会红」）。若某处断言的是「WARN **不**出现」，
//      摘掉它反而不红 ⇒ 本面结构上看不见那类覆盖。
//   2. **只覆盖 403 这一个错误码。** 其它 HTTP 错误走 `throw err`（闸崩、非零退出），
//      本面⛔ 未钉那条路径。
//   3. **不覆盖 B-COVERAGE**（要 `--coverage` flag）与真实 Figma 文档的形态多样性 ——
//      fixture 的文档是手写的最小树，只为触发各条判据各一次。
//   4. `--import` 需 **Node ≥ 20.6**。本仓 `engines` 是 `>=20`，CI 矩阵写的是 `'20'`/`'22'`
//      （setup-node 解析到该主版本最新 ⇒ 满足）。⚠️ 但**lab 只在本机 Node v24.15.0 上实测过**，
//      ⛔ 没在 CI 上验过这一条。
//   5. **环境名**：macOS · Node v24.15.0 · vitest。⛔ 别把读数搬到别的环境。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
  createGateFixture,
  runGate,
  writeFixtureFile,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-mockup-binding-fidelity.mjs'

/** 哨兵：真 API 不可能返回这些值 ⇒ 它们出现在输出里 = stub 确实接管了 fetch。 */
const SENTINEL_NODE_ID = '9999:8888'
const SENTINEL_VAR_NAME = 'UX/Grey/Grey 4'
const SENTINEL_VAR_ID = 'VariableID:7777:6666'

/** WARN 原文，逐字取自闸的 403 分支（⛔ 别改成近似写法）。 */
const WARN_403 =
  'Warning: /variables/local 403 (non-Enterprise); B-SEM 将 under-report（无法解析 grey vs semantic）。B-TYPO/B-EFFECT/B-SCALE 不受影响。'

/**
 * 一个 TEXT 节点，fill 绑在 grey 原语上 —— **B-SEM 的靶子**。
 * 它报不报得出来，完全取决于 `/variables/local` 能不能解析出变量名。
 *
 * ⚠️ 刻意带 `styles.text` ⇒ **不触发 B-TYPO**，让 A2 两臂的差只来自 B-SEM 一条。
 */
const greyBoundText = () => ({
  id: SENTINEL_NODE_ID,
  name: 'Sentinel Label',
  type: 'TEXT',
  styles: { text: 'S:sometextstyle' },
  style: { fontSize: 14 },
  fills: [{ type: 'SOLID', visible: true, boundVariables: { color: { id: SENTINEL_VAR_ID } } }],
})

/** 一个没套 text style 的 TEXT ⇒ **B-TYPO 的靶子**，⛔ 不依赖变量名解析。 */
const unstyledText = () => ({
  id: '1111:2222',
  name: 'Unstyled Label',
  type: 'TEXT',
  style: { fontSize: 13 },
  fills: [],
})

const docWith = (children: unknown[]) => ({
  document: { id: '0:1', name: 'Page 1', type: 'CANVAS', children },
  components: {},
  componentSets: {},
})

/**
 * 生成 stub 源码：覆写 `globalThis.fetch`。
 * @param variablesStatus 403 = 故障通路（本面主角）；200 = 对照臂（变量名可解析）
 */
function fetchStub(variablesStatus: 403 | 200, children: unknown[]): string {
  const doc = JSON.stringify(docWith(children))
  const vars = JSON.stringify({
    meta: { variables: { [SENTINEL_VAR_ID]: { name: SENTINEL_VAR_NAME } } },
  })
  return `
const DOC = ${doc}
const VARS = ${vars}
globalThis.fetch = async (url) => {
  const u = String(url)
  if (u.includes('/variables/local')) {
    ${
      variablesStatus === 403
        ? `return { ok: false, status: 403, statusText: 'Forbidden',
             text: async () => '{"status":403,"err":"Not available for this plan"}',
             json: async () => ({}) }`
        : `return { ok: true, status: 200, statusText: 'OK',
             text: async () => '', json: async () => VARS }`
    }
  }
  return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => DOC }
}
`
}

/** 建 fixture + 写 stub，返回跑闸所需的 env。 */
function fixtureWith(variablesStatus: 403 | 200, children: unknown[]) {
  const root = createGateFixture({ gate: GATE, prefix: 'binding-fidelity-cli' })
  const stubRel = 'stub/fetch.mjs'
  writeFixtureFile(root, stubRel, fetchStub(variablesStatus, children))
  const env = {
    FIGMA_PERSONAL_ACCESS_TOKEN: 'fixture-token-not-a-real-credential',
    NODE_OPTIONS: `--import ${pathToFileURL(join(root, stubRel)).href}`,
  }
  return { root, env }
}

afterAll(() => cleanupGateFixtures())

describe('audit-mockup-binding-fidelity CLI —— /variables/local 403 的绿档免责声明', () => {
  it('A1 403 且零违例 ⇒ exit 0 + ✅PASS，且 stderr 打出那条 under-report WARN', () => {
    const { root, env } = fixtureWith(403, [])
    const run = runGate(root, GATE, ['--file', 'SENTINELFILEKEY'], env)

    // 绿档：闸放行了
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('✅ D16 binding-fidelity audit pass')
    // 免责声明必须在 —— ⛔ 点名原文，不写「stderr 非空」
    expect(run.stderr).toContain(WARN_403)
  })

  it('A2 under-report 真的发生：同一份文档，403 ⇒ B-SEM 报不出；200 ⇒ 报出那一条', () => {
    const children = [greyBoundText()]

    // --- 故障臂：403 ---
    const a = fixtureWith(403, children)
    const red = runGate(a.root, GATE, ['--file', 'SENTINELFILEKEY', '--json'], a.env)
    expect(red.status).toBe(0) // 报不出违例 ⇒ 绿
    const redJson = JSON.parse(red.stdout)
    expect(redJson.probes['B-SEM'].pass).toBe(true)
    expect(redJson.probes['B-SEM'].violations).toHaveLength(0)
    expect(red.stderr).toContain(WARN_403)

    // --- 对照臂：200（变量名解析得出来）---
    const b = fixtureWith(200, children)
    const green = runGate(b.root, GATE, ['--file', 'SENTINELFILEKEY', '--json'], b.env)
    expect(green.status).toBe(1) // 同一份文档，这次报得出来 ⇒ 红
    const greenJson = JSON.parse(green.stdout)
    expect(greenJson.probes['B-SEM'].pass).toBe(false)
    expect(greenJson.probes['B-SEM'].violations).toHaveLength(1)

    // 哨兵：这两个值是本文件编的，真 API 不可能返回 ⇒ stub 确实接管了 fetch
    expect(greenJson.probes['B-SEM'].violations[0].nodeId).toBe(SENTINEL_NODE_ID)
    expect(greenJson.probes['B-SEM'].violations[0].boundVarName).toBe(SENTINEL_VAR_NAME)

    // 这条 WARN ⛔ 不是恒真：变量名拿得到时它必须消失
    expect(green.stderr).not.toContain(WARN_403)
  })

  it('A3 WARN 说「B-TYPO 不受影响」—— 403 臂里 B-TYPO 照样报得出', () => {
    const { root, env } = fixtureWith(403, [unstyledText()])
    const run = runGate(root, GATE, ['--file', 'SENTINELFILEKEY', '--json'], env)

    expect(run.status).toBe(1)
    const json = JSON.parse(run.stdout)
    expect(json.probes['B-TYPO'].pass).toBe(false)
    expect(json.probes['B-TYPO'].violations).toHaveLength(1)
    // 403 下 B-SEM 仍然是那条被压住的 —— 两条判据的命运在同一趟里不同
    expect(json.probes['B-SEM'].pass).toBe(true)
    expect(run.stderr).toContain(WARN_403)
  })
})
