// tests/audit-mockup-colors-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-mockup-colors.mjs` 的**整脚本回归面**，射程限定在它那
// **一条绿档 WARN 通路**：变量端点返回 403 ⇒ C2/C3 的判定被降级。
//
// WHY 这条值得单独钉（⛔ 不是「顺手补个测试」）：
//   变量名一个都解析不出来时，闸**照常跑完**。C3 的判据整个被 `if (varName)` 守卫挡住 ⇒
//   **结构上不可能报出任何违例**，而闸打印 `✅ M-COLOR audit pass` 并 exit 0。
//   ⇒ **那两行 WARN 是绿档唯一的免责声明。** 它一旦退化成静默（`console.warn` 被吞、
//   或 403 分支被并进普通 `throw`），**没有任何退出码会变**，于是没有任何人会发现。
//
// 🔴 **本面钉的是那句话的【语义】，⛔ 不只是它的字符串。**
//   只断字符串的话，把 C3 判据整个删掉本面照样绿。⇒ B2 用**同一份文档**跑两臂
//   （403 / 200），让 under-report **真的发生一次**再断言它。
//
// 🔴 **⚠️ 实测发现：WARN 的文案对 C2 说反了方向 —— 本面如实钉住【真实行为】，⛔ 不迁就文案。**
//   闸逐字写「C2/C3 may under-report」，而 2026-09-15 两臂实测：
//     | 判据 | 403（解析不出变量名） | 200（解析得出） |
//     |---|---|---|
//     | C3 | 守卫 `if (varName)` 挡住 ⇒ **一条都报不出**（真 under-report）| 正常报出 |
//     | C2 | `!varName` 分支命中 ⇒ **把合规节点报成违例**（**over**-report，且闸 **exit 1**）| 正常放行 |
//   ⇒ C2 在 403 下不是「漏报」，是「**误报**」——后果方向完全相反（漏报给假绿，误报给假红）。
//   ⇒ **B3 钉死这个真实行为**，于是任何人改 C2 的守卫、或改那句文案时都会当场看见这个差。
//   ⚠️ 本面**⛔ 不主张文案该怎么改** —— 那是被测仓的决定；lab 只把两个方向的读数摆出来。
//
// ⚠️ **装置：`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 同批另一份文件初稿就这么红过一次）。
//
// 🔴 **「stub 没生效」必须能被抓到，⛔ 不能靠信任 —— 否则整组假绿。**
//   陷阱：stub 若没加载，闸会拿着测试里那个**假 token** 去打**真**上游 API，而真 API 对无效
//   token 同样返回 **403** ⇒ 那两行 WARN **照样打出来** ⇒ 只断字符串的用例会通过，
//   而它测的是真网络、不是 stub。
//   ⇒ 反制是 **B2 的 200 臂**：它断言 C3 报出的 `nodeId` / `boundVarName` 是本文件编出来的
//   哨兵值 —— 真 API 不可能返回它。那一臂一旦绿，就同时证明了 stub 真的接管了 fetch、
//   under-report 真的发生、以及这条 WARN ⛔ 不是恒真（200 臂它必须消失）。
//
// ⛔ 断言必须**点名 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. **C1 不在本面射程** —— 它不依赖变量名解析，403 对它无影响。
//   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-colors.mjs'

/** 哨兵：真 API 不可能返回这些值 ⇒ 它们出现在输出里 = stub 确实接管了 fetch。 */
const SENTINEL_GREY_NODE = '9191:8181'
const SENTINEL_HOVER_NODE = '7171:6161'
const GREY_VAR_ID = 'VariableID:4141:3131'
const HOVER_VAR_ID = 'VariableID:2121:1111'
const GREY_VAR_NAME = 'UX/Grey/Grey 4'
const HOVER_VAR_NAME = 'Color Type/Button/Hover'

/** 两行 WARN 原文，逐字取自闸的 403 分支（⛔ 别改成近似写法）。 */
const WARN_L1 = 'Warning: /variables/local returned 403 (likely non-Enterprise plan).'
const WARN_L2 = 'Bound-variable names will be unresolved; C2/C3 may under-report.'

/** shape fill 绑 grey 原语 ⇒ **C3 的靶子**（只有解析得出变量名时才报得出来）。 */
const greyBoundShape = () => ({
  id: SENTINEL_GREY_NODE,
  name: 'Sentinel Card',
  type: 'RECTANGLE',
  fills: [{ type: 'SOLID', visible: true, boundVariables: { color: { id: GREY_VAR_ID } } }],
})

/**
 * 名字带 `:hover` 且绑的是**合规**的 `/Hover` 变量 ⇒ **C2 的靶子**。
 * ⚠️ 它是**合规**的：200 臂必须放行。403 臂报它 = 误报（见头注释那张方向表）。
 */
const compliantHoverShape = () => ({
  id: SENTINEL_HOVER_NODE,
  name: 'Sentinel Button:hover',
  type: 'RECTANGLE',
  fills: [{ type: 'SOLID', visible: true, boundVariables: { color: { id: HOVER_VAR_ID } } }],
})

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

/** 生成 stub 源码：覆写 `globalThis.fetch`。403 = 故障通路；200 = 对照臂。 */
function fetchStub(variablesStatus: 403 | 200, children: unknown[]): string {
  const doc = JSON.stringify(docWith(children))
  const vars = JSON.stringify({
    meta: {
      variables: {
        [GREY_VAR_ID]: { name: GREY_VAR_NAME },
        [HOVER_VAR_ID]: { name: HOVER_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 }
}
`
}

function fixtureWith(variablesStatus: 403 | 200, children: unknown[]) {
  const root = createGateFixture({ gate: GATE, prefix: 'mockup-colors-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-colors CLI —— 变量端点 403 的绿档免责声明', () => {
  it('B1 403 且零违例 ⇒ exit 0 + ✅PASS，且 stderr 打出那两行 WARN', () => {
    const { root, env } = fixtureWith(403, [])
    const run = runGate(root, GATE, ['--file', 'SENTINELFILEKEY'], env)

    expect(run.status).toBe(0)
    expect(run.stdout).toContain('✅ M-COLOR audit pass')
    // 两行都要在 —— 第二行才是「这次的绿不可全信」那句
    expect(run.stderr).toContain(WARN_L1)
    expect(run.stderr).toContain(WARN_L2)
  })

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

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

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

    // 哨兵：这两个值是本文件编的，真 API 不可能返回 ⇒ stub 确实接管了 fetch
    expect(fJson.probes.C3.violations[0].nodeId).toBe(SENTINEL_GREY_NODE)
    expect(fJson.probes.C3.violations[0].boundVarName).toBe(GREY_VAR_NAME)

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

  it('B3 C2 在 403 下是 over-report ——【如实钉住真实行为】，与 WARN 文案的方向相反', () => {
    // 这个节点是**合规**的：名字带 :hover，绑的就是 /Hover 变量。
    const children = [compliantHoverShape()]

    // --- 200 臂（先跑，确立「它本来合规」这个事实）---
    const ok = fixtureWith(200, children)
    const clean = runGate(ok.root, GATE, ['--file', 'SENTINELFILEKEY', '--json'], ok.env)
    expect(clean.status).toBe(0)
    expect(JSON.parse(clean.stdout).probes.C2.pass).toBe(true)

    // --- 403 臂：同一个合规节点被报成违例，且闸整体变红 ---
    const bad = fixtureWith(403, children)
    const noisy = runGate(bad.root, GATE, ['--file', 'SENTINELFILEKEY', '--json'], bad.env)
    expect(noisy.status).toBe(1) // ⚠️ 不是绿档 —— 403 让这条闸【红】
    const nJson = JSON.parse(noisy.stdout)
    expect(nJson.probes.C2.pass).toBe(false)
    expect(nJson.probes.C2.violations).toHaveLength(1)
    expect(nJson.probes.C2.violations[0].nodeId).toBe(SENTINEL_HOVER_NODE)
    // 误报的痕迹：解析不出名字时闸把 varId 原样填进 boundVarName
    expect(nJson.probes.C2.violations[0].boundVarName).toBe(`(unresolved:${HOVER_VAR_ID})`)
    // 免责声明仍在（它说的是 under-report，而这里发生的是 over-report）
    expect(noisy.stderr).toContain(WARN_L2)
  })
})
