// tests/figma-variables-pipeline.test.ts
// -----------------------------------------------------------------------------
// 变量层刷新管线（2026-09-01 落地，queue #5 抓取侧 + [[INFRA-F143]] 落地侧两半）的回归面。
//
// **为什么是整脚本 fixture-root 而不是抽纯函数单测**：抽出来的纯函数**覆盖不到接线** ——
// 把 normalize 里那三个 throw 整块删掉，纯函数单测照样全绿（`gate-regression-face-whole-script-fixture`
// 那条实证）。所以三条 CLI（ingest / normalize / 新鲜度闸）全部 spawn 整脚本跑。
// 只有 `lib/variables-payload.mjs` 那些**无副作用的形状判据**直接 import 测 —— 它没有接线可漏。
//
// 🔴 **绿档的非空过凭据**：三条 CLI 的绿档都断言了**fixture 自己的数**（5/3/2、两级别名链解出
//    的具体 hex），真仓库现值是 98/54/44 ⇒ 撞不上。⛔ 别把任何绿档降级成只断言 exit 0。
//
// 🔴 **fixture 用「小表 + 真逻辑」**：判据逻辑住在 `lib/variables-payload.mjs` 的
//    `classifyUpstream`（被 copyFiles 拷进 fixture 的**活源**），只有那两张表是 fixture 自己的
//    ——「上游 98 条」那张真表不适合当 fixture：它一收缩（那正是设计意图）测试就得跟着改。
//
// ⛔ **本文件不声明挂载层真的挂上了** —— 那是 gate-ci-parity + gate-mount-declaration 的判据面。
//
// 覆盖：canonical 形状 fail-closed（REST 原文误投 / 悬挂别名 / mode key 对不上 / 空壳）·
//       REST → canonical 转换 · sha256 自证的**两种失真**（sha 写错 / payload 被改）·
//       别名链两级解析 · unknown 变量 fail-closed · 待映射表 shrink-only 与自相矛盾 ·
//       新鲜度闸的恒等式「normalized + 具名待映射 === raw」· 收据缺失 / stale 无 ack。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
import { readFileSync, writeFileSync } from 'node:fs'
import {
  createGateFixture,
  writeFixtureFile,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
  gitCleanEnv,
  REPO_ROOT,
} from './lib/gate-fixture-root'
import {
  canonicalizeVariablesPayload,
  classifyUpstream,
  fromRestVariablesLocal,
  variablesPayloadSha256,
  rgbaToHex,
} from '../figma-sync/lib/variables-payload.mjs'

const LIB = 'figma-sync/lib/variables-payload.mjs'
const INGEST = 'figma-sync/ingest-variables-payload.mjs'
const NORMALIZE = 'figma-sync/normalize.mjs'
const FRESHNESS = 'scripts/audit-figma-variables-freshness.mjs'

afterEach(() => cleanupGateFixtures())

// ── fixture 数据 ──────────────────────────────────────────────────────────────
// 5 个变量：3 个已映射（其中 FX/Top 走**两级**别名链 Top → Mid → Base）+ 2 个具名待映射。
// ⛔ 别把链改成一级：一级链解错时结果仍可能碰巧正确，两级才能证明是递归解的。
const COLLECTIONS = [
  { id: 'VariableCollectionId:1:0', name: 'FX', modes: [{ id: '1:0', name: 'Dark' }, { id: '2:0', name: 'Light' }] },
]
const VARS = [
  { id: 'VariableID:1:1', name: 'FX/Base', collectionId: 'VariableCollectionId:1:0', resolvedType: 'COLOR', values: { Dark: '#111111', Light: '#eeeeee' } },
  { id: 'VariableID:1:2', name: 'FX/Mid', collectionId: 'VariableCollectionId:1:0', resolvedType: 'COLOR', values: { Dark: { alias: 'VariableID:1:1' }, Light: { alias: 'VariableID:1:1' } } },
  { id: 'VariableID:1:3', name: 'FX/Top', collectionId: 'VariableCollectionId:1:0', resolvedType: 'COLOR', values: { Dark: { alias: 'VariableID:1:2' }, Light: { alias: 'VariableID:1:2' } } },
  { id: 'VariableID:1:4', name: 'FX/PendA', collectionId: 'VariableCollectionId:1:0', resolvedType: 'FLOAT', values: { Dark: 4, Light: 4 } },
  { id: 'VariableID:1:5', name: 'FX/PendB', collectionId: 'VariableCollectionId:1:0', resolvedType: 'FLOAT', values: { Dark: 8, Light: 8 } },
]
const payload = () => ({ collections: structuredClone(COLLECTIONS), variables: structuredClone(VARS) })

/**
 * fixture 版 variable-map：小表 + 委派给被拷进来的**活源** classifyUpstream。
 *
 * 🔴 **2026-09-01 下半场跟着活源从两张表变三张**（owner 回送审单后，`Basic Size/*` 从
 *    「待映射」搬进「定案不映射」）。这里 `FX/PendB` 相应改挂 `INTENTIONALLY_UNMAPPED`，
 *    于是 fixture 的三个数是 **3 mapped + 1 pending + 1 intentional = 5 raw** ——
 *    ⇒ 恒等式的**三项各自非零**，任何一项被漏算都会让下面那些用例红。
 *    （若三项里有一项是 0，「漏算它」和「算了它」结果相同 = 那一项没有被真正覆盖。）
 */
const VARIABLE_MAP_STUB = `
import { classifyUpstream } from './lib/variables-payload.mjs'
export const FIGMA_NAME_TO_CSS = { 'FX/Base': '--fx-base', 'FX/Mid': '--fx-mid', 'FX/Top': '--fx-top' }
export const UNMAPPED_PENDING = { 'FX/PendA': 'fx' }
export const INTENTIONALLY_UNMAPPED = { 'FX/PendB': 'fx-intentional' }
export function classifyUpstreamVariables(vars) {
  return classifyUpstream(vars, {
    nameToCss: FIGMA_NAME_TO_CSS,
    pending: UNMAPPED_PENDING,
    intentional: INTENTIONALLY_UNMAPPED,
  })
}
export const pendingReason = (n) => (UNMAPPED_PENDING[n] ? 'fx — fixture' : null)
export const intentionalReason = (n) => (INTENTIONALLY_UNMAPPED[n] ? 'fx-intentional — fixture' : null)
export const toCssVar = (n) => FIGMA_NAME_TO_CSS[n] ?? null
export const FLOAT_CSS_UNIT = { '--fx-base': 'px', '--fx-mid': 'px', '--fx-top': 'px' }
export const cssUnitFor = (v) => FLOAT_CSS_UNIT[v] ?? null
`

const rawFile = (vars = VARS, extra: Record<string, unknown> = {}) =>
  `${JSON.stringify({ extractedAt: '2026-09-01T00:00:00.000Z', figmaFileKey: 'FIXTUREKEY0000000000000', collections: COLLECTIONS, variables: vars, total: vars.length, ...extra }, null, 2)}\n`

const receipt = (at: string, count: number) =>
  `${JSON.stringify({ lastSuccess: { at, figmaFileKey: 'FIXTUREKEY0000000000000', variableCount: count, payloadSha256: 'x'.repeat(64) }, lastFailure: null }, null, 2)}\n`

// ── 1. 形状契约（无接线可漏 ⇒ 直接 import）──────────────────────────────────
describe('canonical 形状契约（fail closed）', () => {
  it('合法 payload 通过，并按 id 排序、保留 values 的 key 顺序', () => {
    const shuffled = { collections: COLLECTIONS, variables: [VARS[2], VARS[0], VARS[1], VARS[4], VARS[3]] }
    const c = canonicalizeVariablesPayload(shuffled)
    expect(c.variables.map((v: { id: string }) => v.id)).toEqual([
      'VariableID:1:1', 'VariableID:1:2', 'VariableID:1:3', 'VariableID:1:4', 'VariableID:1:5',
    ])
    expect(Object.keys(c.variables[0].values)).toEqual(['Dark', 'Light'])
  })

  it('把 REST 原文误投进来时**点名它**，不是只说「缺 collections」', () => {
    expect(() => canonicalizeVariablesPayload({ meta: { variableCollections: {}, variables: {} } }))
      .toThrow(/REST/)
  })

  it('variables[] 为空 ⇒ 抛（抓到空壳 / 错误负载也长这样）', () => {
    expect(() => canonicalizeVariablesPayload({ collections: COLLECTIONS, variables: [] })).toThrow(/空/)
  })

  it('悬挂别名 ⇒ 抛（否则 normalize 会解析出 undefined 而不是报错）', () => {
    const p = payload()
    p.variables[1].values.Dark = { alias: 'VariableID:9:9' }
    expect(() => canonicalizeVariablesPayload(p)).toThrow(/VariableID:9:9/)
  })

  it('values 的 key 与 collection 的 mode 名对不上 ⇒ 抛', () => {
    const p = payload()
    p.variables[0].values = { Dark: '#111111', Bright: '#eeeeee' } as never
    expect(() => canonicalizeVariablesPayload(p)).toThrow(/mode/)
  })

  it('resolvedType 不认识 ⇒ 抛', () => {
    const p = payload()
    p.variables[0].resolvedType = 'GRADIENT' as never
    expect(() => canonicalizeVariablesPayload(p)).toThrow(/GRADIENT/)
  })

  it('id 重复 ⇒ 抛', () => {
    const p = payload()
    p.variables[1].id = p.variables[0].id
    expect(() => canonicalizeVariablesPayload(p)).toThrow(/重复/)
  })
})

describe('REST 原文 → canonical（这条分支实际 403，但转换本身可测）', () => {
  it('别名与 0–1 RGBA 都被编码成 canonical 形态', () => {
    const c = fromRestVariablesLocal({
      meta: {
        variableCollections: {
          'VariableCollectionId:1:0': { id: 'VariableCollectionId:1:0', name: 'FX', modes: [{ modeId: '1:0', name: 'Dark' }, { modeId: '2:0', name: 'Light' }] },
        },
        variables: {
          'VariableID:1:1': { id: 'VariableID:1:1', name: 'FX/Base', variableCollectionId: 'VariableCollectionId:1:0', resolvedType: 'COLOR', valuesByMode: { '1:0': { r: 1, g: 0, b: 0, a: 1 }, '2:0': { r: 0, g: 0, b: 0, a: 0.5 } } },
          'VariableID:1:2': { id: 'VariableID:1:2', name: 'FX/Mid', variableCollectionId: 'VariableCollectionId:1:0', resolvedType: 'COLOR', valuesByMode: { '1:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:1:1' }, '2:0': { type: 'VARIABLE_ALIAS', id: 'VariableID:1:1' } } },
        },
      },
    })
    expect(c.variables[0].values).toEqual({ Dark: '#ff0000', Light: '#00000080' })
    expect(c.variables[1].values.Dark).toEqual({ alias: 'VariableID:1:1' })
  })

  it('缺 meta ⇒ 抛（403 / 错误负载也长这样）', () => {
    expect(() => fromRestVariablesLocal({ status: 403, error: true })).toThrow(/meta/)
  })

  it('a === 1 不带 alpha 位，a < 1 才带', () => {
    expect(rgbaToHex({ r: 1, g: 1, b: 1, a: 1 })).toBe('#ffffff')
    expect(rgbaToHex({ r: 1, g: 1, b: 1, a: 0 })).toBe('#ffffff00')
  })
})

describe('classifyUpstream（normalize 与新鲜度闸共用的那一份）', () => {
  const tables = { nameToCss: { 'FX/Base': '--fx-base' }, pending: { 'FX/PendA': 'fx' } }

  it('三堆分对', () => {
    const r = classifyUpstream(VARS, tables)
    expect(r.mapped.map((v: { name: string }) => v.name)).toEqual(['FX/Base'])
    expect(r.pending.map((v: { name: string }) => v.name)).toEqual(['FX/PendA'])
    expect(r.unknown.map((v: { name: string }) => v.name)).toEqual(['FX/Mid', 'FX/Top', 'FX/PendB'])
  })

  it('待映射表里上游已没有的名字被点成 stalePending（shrink-only）', () => {
    const r = classifyUpstream([VARS[0]], tables)
    expect(r.stalePending).toEqual(['FX/PendA'])
  })

  it('同名同时在两张表 ⇒ doubleListed', () => {
    const r = classifyUpstream(VARS, { nameToCss: { 'FX/PendA': '--x' }, pending: { 'FX/PendA': 'fx' } })
    expect(r.doubleListed).toEqual(['FX/PendA'])
  })
})

// ── 2. ingest 整脚本：sha256 自证 ────────────────────────────────────────────
describe(`${INGEST}（整脚本）`, () => {
  const mkRoot = () =>
    createGateFixture({
      gate: INGEST,
      prefix: 'varpipe-ingest',
      copyFiles: [LIB],
      dirs: ['figma-data/raw'],
    })

  const writePayload = (root: string, p: unknown) => {
    const f = join(root, 'payload.json')
    writeFileSync(f, JSON.stringify(p))
    return f
  }

  it('sha 相符 ⇒ 落盘 raw + 收据，且自印 fixture 自己的条数', () => {
    const root = mkRoot()
    const p = payload()
    const f = writePayload(root, p)
    const run = runGate(root, INGEST, ['--payload', f, '--sha256', variablesPayloadSha256(p), '--file-key', 'FIXTUREKEY0000000000000'])
    expectGateGreen(run, { contains: ['自证通过', '5 个变量', 'COLOR 3 + FLOAT 2'] })
    const raw = JSON.parse(readFileSync(join(root, 'figma-data/raw/variables.json'), 'utf8'))
    expect(raw.total).toBe(5)
    expect(raw.source).toBe('figma-plugin-api/use_figma')
    const rec = JSON.parse(readFileSync(join(root, 'figma-data/variables-sync-status.json'), 'utf8'))
    expect(rec.lastSuccess.variableCount).toBe(5)
  })

  it('sha 写错一位 ⇒ 红，且**什么都不写**', () => {
    const root = mkRoot()
    const f = writePayload(root, payload())
    const good = variablesPayloadSha256(payload())
    const bad = `${good.slice(0, 63)}${good.at(-1) === 'a' ? 'b' : 'a'}`
    const run = runGate(root, INGEST, ['--payload', f, '--sha256', bad, '--file-key', 'FIXTUREKEY0000000000000'])
    expectGateRed(run, { marker: 'sha256 不符' })
    expect(() => readFileSync(join(root, 'figma-data/raw/variables.json'), 'utf8')).toThrow()
    // ⛔ 失败收据也不该写：抓取是成功的，坏的是转抄；混进收据会污染新鲜度闸的判据
    expect(() => readFileSync(join(root, 'figma-data/variables-sync-status.json'), 'utf8')).toThrow()
  })

  it('payload 被改一个字符（sha 保持真值）⇒ 红 —— 这才是「AI 转抄失真」的真形态', () => {
    const root = mkRoot()
    const good = variablesPayloadSha256(payload())
    const tampered = payload()
    tampered.variables[0].values.Dark = '#111112'
    const f = writePayload(root, tampered)
    const run = runGate(root, INGEST, ['--payload', f, '--sha256', good, '--file-key', 'FIXTUREKEY0000000000000'])
    expectGateRed(run, { marker: 'sha256 不符' })
  })

  it('不传 --sha256 ⇒ 红（⛔ 它没有默认值，也不该有逃逸口）', () => {
    const root = mkRoot()
    const f = writePayload(root, payload())
    expectGateRed(runGate(root, INGEST, ['--payload', f, '--file-key', 'FIXTUREKEY0000000000000']), { marker: '缺 --sha256' })
  })

  it('外面多包一层 `{ payload: … }` 也吃（use_figma 返回体常被整个存下来）', () => {
    const root = mkRoot()
    const p = payload()
    const f = writePayload(root, { total: 5, payloadSha256: 'ignored', payload: p })
    expectGateGreen(runGate(root, INGEST, ['--payload', f, '--sha256', variablesPayloadSha256(p), '--file-key', 'FIXTUREKEY0000000000000']), {
      contains: ['5 个变量'],
    })
  })
})

// ── 3. normalize 整脚本：F143 那条接线 ───────────────────────────────────────
describe(`${NORMALIZE}（整脚本 · [[INFRA-F143]] 的接线）`, () => {
  const mkRoot = (files: Record<string, string> = {}) =>
    createGateFixture({
      gate: NORMALIZE,
      prefix: 'varpipe-normalize',
      copyFiles: [LIB],
      dirs: ['figma-data/normalized'],
      files: { 'figma-sync/variable-map.mjs': VARIABLE_MAP_STUB, 'figma-data/raw/variables.json': rawFile(), ...files },
    })

  it('读的是 raw、写的是 normalized，且**两级别名链**被解成具体值', () => {
    const root = mkRoot()
    expectGateGreen(runGate(root, NORMALIZE), {
      contains: ['上游 5 → normalized 3', '具名待映射 1', '定案不映射 1'],
    })
    const norm = JSON.parse(readFileSync(join(root, 'figma-data/normalized/variables.json'), 'utf8'))
    expect(norm.variables).toHaveLength(3)
    const top = norm.variables.find((v: { cssVar: string }) => v.cssVar === '--fx-top')
    expect(top).toMatchObject({ Dark: '#111111', Light: '#eeeeee' })
    expect(norm.modeNames).toEqual({ Dark: 'Dark', Light: 'Light' })
    expect(norm.extractedAt).toBe('2026-09-01T00:00:00.000Z')
  })

  it('⛔ 修好的自环不会复发：raw 缺失时红，不是「读 normalized 自己」照样绿', () => {
    const root = createGateFixture({
      gate: NORMALIZE,
      prefix: 'varpipe-normalize-selfloop',
      copyFiles: [LIB],
      files: {
        'figma-sync/variable-map.mjs': VARIABLE_MAP_STUB,
        // 只有下游、没有上游 —— 自环版本在这个输入下会绿
        'figma-data/normalized/variables.json': JSON.stringify({ modeNames: { Dark: 'Dark' }, variables: [{ id: 'VariableID:1:1', figmaName: 'FX/Base', Dark: '#111111', cssVar: '--fx-base' }] }),
      },
    })
    expectGateRed(runGate(root, NORMALIZE), { marker: '缺 figma-data/raw/variables.json' })
  })

  it('上游冒出两张表都不认识的变量 ⇒ 红并逐条点名（⛔ 不静默丢掉）', () => {
    const root = mkRoot({
      'figma-data/raw/variables.json': rawFile([
        ...VARS,
        { id: 'VariableID:1:6', name: 'FX/BrandNew', collectionId: 'VariableCollectionId:1:0', resolvedType: 'FLOAT', values: { Dark: 1, Light: 1 } },
      ]),
    })
    expectGateRed(runGate(root, NORMALIZE), { checks: ['不认识', 'FX/BrandNew'] })
  })

  it('待映射表里有上游已经没有的名字 ⇒ 红（shrink-only）', () => {
    const root = mkRoot({ 'figma-data/raw/variables.json': rawFile(VARS.slice(0, 4)) })
    expectGateRed(runGate(root, NORMALIZE), { checks: ['shrink-only', 'FX/PendB'] })
  })

  it('别名成环 ⇒ 红（⛔ 不静默返回 null）', () => {
    const looped = structuredClone(VARS)
    looped[0].values = { Dark: { alias: 'VariableID:1:3' }, Light: { alias: 'VariableID:1:3' } } as never
    const root = mkRoot({ 'figma-data/raw/variables.json': rawFile(looped) })
    expectGateRed(runGate(root, NORMALIZE), { marker: '成环' })
  })
})

// ── 4. 新鲜度闸整脚本：恒等式 ────────────────────────────────────────────────
describe(`${FRESHNESS}（整脚本 · V3 恒等式）`, () => {
  const normFile = (n: number) =>
    `${JSON.stringify({ extractedAt: '2026-09-01T00:00:00.000Z', modeNames: { Dark: 'Dark', Light: 'Light' }, variables: Array.from({ length: n }, (_, i) => ({ id: `VariableID:1:${i + 1}`, figmaName: `FX/Mapped${i}`, Dark: '#111111', Light: '#eeeeee', cssVar: `--fx-${i}` })) }, null, 2)}\n`

  const mkRoot = (files: Record<string, string> = {}) =>
    createGateFixture({
      gate: FRESHNESS,
      prefix: 'varpipe-freshness',
      // 🔴 NORMALIZE 在这里不是「被测闸」，是**闸的判据真源**：2026-09-14 起 V4 靠
      //    `computeNormalizedVariables()` 重算 normalized 来判手改（原判据是两个 git 时间，
      //    在「真手改」与「映射表变更后合法重算」上读数相同 ⇒ 判不了要判的事）。
      //    ⛔ 漏拷它的话闸在 import 阶段就崩，所有红档会变成「模块解析失败」的红 —— 那是假红。
      copyFiles: [LIB, NORMALIZE],
      files: {
        'figma-sync/variable-map.mjs': VARIABLE_MAP_STUB,
        'figma-data/raw/variables.json': rawFile(),
        'figma-data/normalized/variables.json': normFile(3),
        'figma-data/variables-sync-status.json': receipt(new Date().toISOString(), 5),
        ...files,
      },
    })

  it('恒等式成立 ⇒ 绿，且自印 fixture 自己的三个数', () => {
    // 三项各自非零（3 + 1 + 1 = 5）—— 漏算任何一项都会让这条断言红，见 VARIABLE_MAP_STUB 上方
    expectGateGreen(runGate(mkRoot(), FRESHNESS), {
      contains: ['raw 5 = normalized 3 + 具名待映射 1 + 定案不映射 1'],
    })
  })

  it('上游多一条、两张表都没登记 ⇒ 红（旧的「两边条数相等」判据在这里是绿的）', () => {
    const root = mkRoot({
      'figma-data/raw/variables.json': rawFile([
        ...VARS,
        { id: 'VariableID:1:6', name: 'FX/BrandNew', collectionId: 'VariableCollectionId:1:0', resolvedType: 'FLOAT', values: { Dark: 1, Light: 1 } },
      ]),
    })
    expectGateRed(runGate(root, FRESHNESS), { checks: ['V3', '恒等式不成立'] })
  })

  it('待映射表有上游已没有的名字 ⇒ 红（否则它会用一个不存在的分量把恒等式凑平）', () => {
    const root = mkRoot({
      'figma-data/raw/variables.json': rawFile(VARS.slice(0, 4)),
      'figma-data/normalized/variables.json': normFile(3),
    })
    expectGateRed(runGate(root, FRESHNESS), { checks: ['shrink-only', 'FX/PendB'] })
  })

  it('已生成 CSS 的名字还挂在「不生成 CSS」的表里 ⇒ 红（表自相矛盾）', () => {
    const norm = JSON.parse(normFile(3))
    norm.variables[0].figmaName = 'FX/PendA'
    const root = mkRoot({ 'figma-data/normalized/variables.json': `${JSON.stringify(norm, null, 2)}\n` })
    expectGateRed(runGate(root, FRESHNESS), { checks: ['表本身矛盾', 'FX/PendA'] })
  })

  it('定案不映射表里的名字被生成了 CSS ⇒ 同样红（覆盖第三项，不只是 pending 那一项）', () => {
    // ⚠️ 这条是上一条的**第三项对照**：判据若只查 UNMAPPED_PENDING，这里会绿 ——
    //    而那正是「恒等式多了一项、但矛盾检查只查了两项」的漏法。
    const norm = JSON.parse(normFile(3))
    norm.variables[0].figmaName = 'FX/PendB'
    const root = mkRoot({ 'figma-data/normalized/variables.json': `${JSON.stringify(norm, null, 2)}\n` })
    expectGateRed(runGate(root, FRESHNESS), { checks: ['表本身矛盾', 'FX/PendB'] })
  })

  it('raw 自述 total 与实际条数不符 ⇒ 红', () => {
    const root = mkRoot({ 'figma-data/raw/variables.json': rawFile(VARS, { total: 99 }) })
    expectGateRed(runGate(root, FRESHNESS), { checks: ['V3', 'total=99'] })
  })

  it('没有运行收据 ⇒ 红（V1：收据是唯一能区分「值没变」与「抓不到」的信号）', () => {
    const root = mkRoot()
    execFileSync('rm', [join(root, 'figma-data/variables-sync-status.json')], { env: gitCleanEnv() })
    expectGateRed(runGate(root, FRESHNESS), { marker: 'V1' })
  })

  it('超阈值且无 ack ⇒ 红（V5）', () => {
    const old = new Date(Date.now() - 90 * 86_400_000).toISOString()
    expectGateRed(runGate(mkRoot({ 'figma-data/variables-sync-status.json': receipt(old, 5) }), FRESHNESS), { marker: 'V5' })
  })
})

// ── 4b. V4「下游被单独手改」──────────────────────────────────────────────────
// 🔴 **三个臂缺一不可，⛔ 别只留红档**：只有故障臂报警说明不了判据有区分力。
//    被换掉的那个 git 时间判据正是栽在这里 —— 同一组造故障实测，它在**三个臂上全部报 1 条**
//    （对照臂也报），读数恒定 ⇒ 零区分力，而它长得像一条尽责的 WARN。
//
// ⚠️ **本 describe 的 normalized 必须由 NORMALIZE 真跑生成**，⛔ 不能像上面 §4 那样手写：
//    手写的那份本来就算不出来 ⇒ V4 在那些 fixture 上恒报 ⇒ 「不报」这一档在那儿是测不到的。
describe(`${FRESHNESS}（整脚本 · V4 手改检测 · 三臂）`, () => {
  /** 建 fixture 并**先跑一遍 normalize**，让 normalized 真的由 raw + 映射表算出来。 */
  const mkNormalizedRoot = (files: Record<string, string> = {}) => {
    const root = createGateFixture({
      gate: FRESHNESS,
      prefix: 'varpipe-v4',
      copyFiles: [LIB, NORMALIZE],
      dirs: ['figma-data/normalized'],
      files: {
        'figma-sync/variable-map.mjs': VARIABLE_MAP_STUB,
        'figma-data/raw/variables.json': rawFile(),
        'figma-data/variables-sync-status.json': receipt(new Date().toISOString(), 5),
        ...files,
      },
    })
    // 非空过凭据：normalize 真跑成功了才继续，⛔ 别让「没生成」伪装成「生成了且一致」
    expectGateGreen(runGate(root, NORMALIZE), { contains: ['上游 5 → normalized 3'] })
    return root
  }

  const v4Hit = (out: string) => (out.match(/无法由当前/g) ?? []).length

  it('臂 A 对照：normalized 就是重算出来的 ⇒ V4 **不报**', () => {
    const res = runGate(mkNormalizedRoot(), FRESHNESS)
    expect(res.status).toBe(0)
    expect(v4Hit(res.stdout + res.stderr)).toBe(0)
  })

  it('臂 B 故障：手改 normalized 一个值、raw 不动 ⇒ V4 **报并逐条点名**', () => {
    const root = mkNormalizedRoot()
    const p = join(root, 'figma-data/normalized/variables.json')
    const norm = JSON.parse(readFileSync(p, 'utf8'))
    const top = norm.variables.find((v: { cssVar: string }) => v.cssVar === '--fx-top')
    expect(top.Dark).toBe('#111111') // 阳性对照：改之前确实是这个值，⛔ 别改一个本来就不同的位置
    top.Dark = '#deadbe'
    writeFileSync(p, JSON.stringify(norm, null, 2))

    const res = runGate(root, FRESHNESS)
    const out = res.stdout + res.stderr
    expect(v4Hit(out)).toBe(1)
    // 逐条定位必须点到**具体哪条、哪个 mode、两侧各是什么** —— 只说「不一致」等于让人自己去 diff
    expect(out).toContain('FX/Top.Dark')
    expect(out).toContain('#deadbe')
    expect(out).toContain('#111111')
    // V4 是 WARN 不是 FAIL ⇒ ⛔ 别顺手把它升级成红，那会改掉这条闸的退出码语义
    expect(res.status).toBe(0)
  })

  it('臂 C 正常：映射表加一条 + 重跑 normalize（上游没动）⇒ V4 **不报**', () => {
    const root = mkNormalizedRoot()
    // 把 `FX/PendA` 从「待映射」挪进「生成 CSS」—— 这是这条链**最常见的正常动作**
    //（上游不动、接线表动），也正是被换掉的 git 时间判据把它误报成「手改」的那一种。
    const mapped = VARIABLE_MAP_STUB.replace(
      "export const FIGMA_NAME_TO_CSS = { 'FX/Base': '--fx-base', 'FX/Mid': '--fx-mid', 'FX/Top': '--fx-top' }\nexport const UNMAPPED_PENDING = { 'FX/PendA': 'fx' }",
      "export const FIGMA_NAME_TO_CSS = { 'FX/Base': '--fx-base', 'FX/Mid': '--fx-mid', 'FX/Top': '--fx-top', 'FX/PendA': '--fx-pend-a' }\nexport const UNMAPPED_PENDING = {}",
    ).replace(
      "export const FLOAT_CSS_UNIT = { '--fx-base': 'px', '--fx-mid': 'px', '--fx-top': 'px' }",
      "export const FLOAT_CSS_UNIT = { '--fx-base': 'px', '--fx-mid': 'px', '--fx-top': 'px', '--fx-pend-a': 'px' }",
    )
    // ⛔ 注入本身要验：`String.replace` 不命中时**静默返回原串** ⇒ 这一臂会退化成臂 A 的复制品
    //    而照样绿（`fault-injection-marker-count-must-rise` 的同一形态）。
    expect(mapped).toContain("'FX/PendA': '--fx-pend-a'")
    expect(mapped).toContain("export const UNMAPPED_PENDING = {}")
    expect(mapped).toContain("'--fx-pend-a': 'px'")

    writeFixtureFile(root, 'figma-sync/variable-map.mjs', mapped)
    // normalized 从 3 条变 4 条 = 下游**真的**被合法重算了（⛔ 不是「什么都没发生所以不报」）
    expectGateGreen(runGate(root, NORMALIZE), { contains: ['上游 5 → normalized 4', '具名待映射 0'] })

    const res = runGate(root, FRESHNESS)
    expect(v4Hit(res.stdout + res.stderr)).toBe(0)
  })

  it('闸是只读的：跑完 V4 不得改动任何产物（重算走的是纯函数，⛔ 不是 normalize CLI）', () => {
    const root = mkNormalizedRoot()
    const p = join(root, 'figma-data/normalized/variables.json')
    const before = readFileSync(p, 'utf8')
    runGate(root, FRESHNESS)
    expect(readFileSync(p, 'utf8')).toBe(before)
  })
})

// ── 5. 真仓库的活源钉（非 fixture）───────────────────────────────────────────
describe('真仓库活源', () => {
  // 🔴 **2026-09-14 放宽了这条判据的一处写死，⛔ 别读成放松**：它原来钉的是字面
  //    `resolve(DATA, …)`，而 `DATA` 是个**局部变量名**，不是被钉住的那个事实。V4 换成内容级
  //    判据时，重算被抽成 `computeNormalizedVariables({ dataDir })`（闸要传自己的 ROOT，
  //    ⛔ 不能依赖它默认等于 cwd）—— 变量名一改，这条钉子就红，而实现完全没有退化。
  //    ⇒ 那是 `gate-regression-face-whole-script-fixture` 那条实证的反面：**钉子钉到实现细节上
  //    就会惩罚正当修复**。改成钉 identifier 无关的形态，F143 自环仍然会让它红（已造故障验过）。
  it('normalize 读的是 raw/ 而不是 normalized/ 自身（F143 的自环不会悄悄回来）', () => {
    const src = readFileSync(join(REPO_ROOT, NORMALIZE), 'utf8')
    expect(src).toMatch(/resolve\(\w+, 'raw\/variables\.json'\)/)
    expect(src).not.toMatch(/readFileSync\(resolve\(\w+, 'normalized\/variables\.json'\)/)
  })

  // 🔴 **这条用例第一版是假红的，留个记号**：判据原来对**整份文件**匹配 `--skip-sha`，
  //    而被测文件头注释里逐字写着「⛔ 别加 `--skip-sha` / `--force`」⇒ 它命中的是那句禁令本身。
  //    （`fault-probe-name-must-be-absent-from-repo` 的第二种形态：探针值撞被测文件自己的注释。）
  //    修法 = 先剥注释再匹配，⛔ 不是改实现、也不是把禁令从注释里删掉。
  it('ingest 的**代码**里没有任何跳过 sha 自证的逃逸口', () => {
    const code = readFileSync(join(REPO_ROOT, INGEST), 'utf8')
      .replace(/\/\*[\s\S]*?\*\//g, '')
      .replace(/^\s*\/\/.*$/gm, '')
    expect(code).toMatch(/expectedSha256/) // 阴性对照：剥完注释后代码还在，不是把整份文件剥没了
    expect(code).not.toMatch(/skip-sha|skipSha|noVerify/)
  })
})
