// tests/audit-mockup-language-isolation-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-mockup-language-isolation.mjs` 的**整脚本回归面**（face = E）。
//
// 🔴 **WHY 这一面与同目录那份纯函数测试不可互相替代**（[[INFRA-F138]]）：
//   `tests/mockup-language-isolation.test.ts` 只 import 判据函数（face = I）——
//   摘掉 `main()` 里 `findLanguageIsolationViolations` 的接线、或把 `process.exit(1)`
//   改成 `exit(0)`，那 24 项（2026-09-15 §M33 判据反转后由 18 扩到 24）照样全绿。
//   本仓 2026-08-24 实测同形态：`audit:doc-shape`
//   有 20 条单测，摘掉 `main()` 接线后它对真违例改印 `✓ PASS` 且 exit 0，全量 vitest
//   1993 passed 一条没红。⇒ 判据 + 接线 + 出口必须 spawn 整个脚本才覆盖得到。
//
// ⚠️ **装置：`NODE_OPTIONS=--import <stub>` 覆写 `globalThis.fetch`，被测对象一行不改。**
//   闸的上游基址是写死常量 ⇒ 不可参数化，而 [[INFRA-F138]] 逐字封死了「为了让 harness
//   能用去改活闸」这条路。⛔ 别给闸加测试专用的本地输入开关来绕开这一点。
//   🔴 **⛔ 本文件不写上游 host 字面量、不写请求头名** —— `audit:figma-env-single-source`
//   的 S5/S6 标记就是这两个，且逐字声明「出现在**注释**里也算命中」。喂给 fixture 的
//   token 是假值，本文件不解析任何凭据。
//
// 🔴 **「stub 没生效」必须能被抓到**：stub 若没加载，闸会拿假 token 去打真上游，真上游对
//   无效 token 返回 403 ⇒ 闸走 could-not-run exit 2。⇒ 反制是**所有臂都断言 exit ∈ {0,1}
//   且 stdout 含哨兵 node id** —— 那个 id 是本文件喂进去的，真上游不可能返回它。
//
// ⛔ 污染纪律：本文件不写死任何别的闸的 `.mjs` basename（peer 用 readdirSync 动态列），
//   也不出现任何别的 `audit:` npm key。
//
// ⚠️ **本面登记的覆盖边界（⛔ 是边界，不是 TODO）**：
//   1. 只走 HTTP 200 这一条上游分支；非 200 的 could-not-run 出口未钉。
//   2. 只钉两条 probe 的**在与不在**和 exit code，不钉逐字散文措辞。
//      —— **例外（review F1，2026-09-15）**：`markedFrames === 0` 的 fail-open 警示行 /
//      三段计数自印行**是**判据反转的直接产物，钉一段关键字（"本闸未判任何东西" /
//      "已标记产品帧 N 个 · …"）是刻意的，不是边界失守；措辞可以润色，但"这行会不会出现"
//      本身要能被测试抓到。
//   3. 环境名：macOS · Node v24.15.0 · vitest。⛔ 别把读数搬到别的环境。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { readdirSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
  createGateFixture,
  runGate,
  writeFixtureFile,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const GATE = 'scripts/audit-mockup-language-isolation.mjs'

/** 闸 import 的同级同族闸 —— 动态列，⛔ 不写死 basename（见污染纪律）。 */
const PEER_SOURCES = readdirSync(join(REPO, 'scripts'))
  .filter((f) => /^audit-mockup-.+\.mjs$/.test(f) && `scripts/${f}` !== GATE)
  .map((f) => `scripts/${f}`)

/** 哨兵：真上游不可能返回这些值 ⇒ 它们出现在 stdout 里就证明 stub 真的接管了。 */
const SENTINEL_BAD = '9191:8181'
const SENTINEL_REF = '9191:8282'
const SENTINEL_OK = '9191:8383'
const SENTINEL_UNMARKED = '9191:8484'
const ZH_FONT = 'Noto Sans SC'

type Node = Record<string, unknown>

function text(id: string, name: string, characters: string, fontFamily = 'Roboto'): Node {
  return { id, name, type: 'TEXT', characters, style: { fontFamily } }
}

/**
 * 把一组节点包进一个产品帧，再包进 CANVAS/DOCUMENT，做成上游文件响应的形状。
 *
 * 🔴 2026-09-15 判据反转：帧名必须带 `SCREEN · ` 才会被认成 product（见
 * `audit-mockup-language-isolation.mjs` 的 `classifyRegion` / `PRODUCT_FRAME_RE`）。
 * 旧帧名 `S1 — Device Screen` 不命中 ⇒ 整棵子树会落进 `unmarked`（一律不判），
 * 下面 E1/E2/E4 的哨兵文本就永远不会被扫到 —— 这不是措辞层面的小改动，是这份
 * fixture 本身要跟着新判据换名字。
 *
 * `frameName` 可覆写：E6 就是靠传回旧的不带标记的名字，反向验证 fail-open 分支
 * （`markedFrames === 0` 时的警示行）真的会被印出来。
 */
function stubFor(children: Node[], frameName = 'SCREEN · Device Screen'): string {
  const doc = JSON.stringify({
    name: 'Probe File',
    document: {
      id: '0:0',
      name: 'Document',
      type: 'DOCUMENT',
      children: [
        {
          id: '0:1',
          name: 'Page 1',
          type: 'CANVAS',
          children: [{ id: '0:2', name: frameName, type: 'FRAME', children }],
        },
      ],
    },
  })
  return `
const DOC = ${doc}
globalThis.fetch = async () => ({
  ok: true, status: 200, statusText: 'OK',
  text: async () => '', json: async () => DOC,
})
`
}

function runWith(
  children: Node[],
  args: string[] = ['--file', 'SENTINELFILEKEY'],
  frameName?: string,
) {
  const root = createGateFixture({
    gate: GATE,
    prefix: 'mockup-language-isolation-cli',
    copyFiles: PEER_SOURCES,
  })
  const stubRel = 'stub/fetch.mjs'
  writeFixtureFile(root, stubRel, stubFor(children, frameName))
  return runGate(root, GATE, args, {
    FIGMA_PERSONAL_ACCESS_TOKEN: 'fixture-token-not-a-real-credential',
    NODE_OPTIONS: `--import ${pathToFileURL(join(root, stubRel)).href}`,
  })
}

afterAll(() => cleanupGateFixtures())

describe('audit-mockup-language-isolation —— 整脚本接线（判据 → 输出 → exit code）', () => {
  it('E1 违例臂：product 区混入中文 ⇒ exit 1 + 点名 probe 与哨兵节点', () => {
    const run = runWith([text(SENTINEL_BAD, 'dialog-title', 'No Receiver  未选择 Receiver')])
    expect(run.status).toBe(1) // ⛔ 接线被摘 ⇒ 这里会变 0
    expect(run.stdout).toContain('product-ui-han')
    expect(run.stdout).toContain(SENTINEL_BAD) // stub 真的接管了
  })

  it('E2 对照臂：product 区全英文 ⇒ exit 0 + PASS（⇒ ⛔ 不是恒红）', () => {
    const run = runWith([text(SENTINEL_OK, 'title', 'Network Settings')])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('PASS')
    expect(run.stdout).not.toContain('product-ui-han')
  })

  // 🔴 2026-09-15 判据反转：这条臂的读数整个翻了过来。旧三分下 `ZH ref caption`
  // 从 SCREEN · 子树里"逃逸"进 reference 档 ⇒ exit 0；二分下 product 子树内不再放
  // 任何命名逃逸（真有例外走 --allow-node），⇒ 同样的中文、同样的命名，现在改成 exit 1。
  // 这条臂就是用来钉住"反向兜底"没有借着重构悄悄换个位置又长回来。
  it('E3 🔴 SCREEN · 子树内的 reference 命名不再逃逸 ⇒ exit 1（判据反转点在整脚本里真的生效）', () => {
    const run = runWith([text(SENTINEL_REF, 'ZH ref caption', 'No Receiver  未选择 Receiver')])
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('product-ui-han')
    expect(run.stdout).toContain(SENTINEL_REF)
    // review F1：换掉旧的 `reference 1` 断言后，计数自印行一度没人钉了 —— 这里补回来。
    // 树只有一个 SCREEN · 帧（markedFrames=1）、一个判过的 TEXT（scannedNodes=1）、
    // 没有任何 unmarked FRAME（unmarkedSkipped=0）。
    expect(run.stdout).toContain('已标记产品帧 1 个 · 判过的 TEXT 1 个 · 未标记帧 0 个')
  })

  it('E4 P2 臂：product 区用注释字体但无中文 ⇒ exit 1 + product-ui-zh-font', () => {
    const run = runWith([text(SENTINEL_BAD, 'label', 'Skip', ZH_FONT)])
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('product-ui-zh-font')
    expect(run.stdout).toContain(SENTINEL_BAD)
  })

  it('E5 usage 出口：不传 --file ⇒ exit 2（⛔ 不是静默跑全量）', () => {
    const run = runWith([text(SENTINEL_OK, 'title', 'x')], [])
    expect(run.status).toBe(2)
  })

  // 🔴 review F1（2026-09-15）：fail-open 警示行此前零测试覆盖 —— E1-E4 的 fixture 全部
  // 命中 SCREEN · ，`markedFrames === 0` 这条分支在整脚本层面从没被跑到过。这是 Global
  // Constraints「fail-open 必须如实登记，⛔ 不许让它长得像覆盖了」唯一的落地物，不能是
  // 哑代码 —— 这条臂把帧名换回判据反转前的旧名 `S1 — Device Screen`（不带 SCREEN · ），
  // 证明：① exit 仍是 0（没有标记 ⇒ 没有违例 ⇒ 不能阻断）；② 但 stdout 必须印出
  // "本闸未判任何东西" 的警示文案，不能悄悄绿掉；③ 帧内即使塞了中文，哨兵也不会出现在
  // 输出里 —— 真的完全没被扫，不是扫了但没报。
  it('E6 🔴 fail-open 警示臂：帧名不带 SCREEN · ⇒ exit 0，但必须印出「未判任何东西」警示', () => {
    const run = runWith(
      [text(SENTINEL_UNMARKED, 'title', '未标记帧内的中文 未选择 Receiver')],
      ['--file', 'SENTINELFILEKEY'],
      'S1 — Device Screen',
    )
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('已标记产品帧 0 个 · 判过的 TEXT 0 个 · 未标记帧 1 个')
    expect(run.stdout).toContain('本闸未判任何东西')
    expect(run.stdout).not.toContain(SENTINEL_UNMARKED) // 真没扫到，不是扫了但没报
  })
})
