// tests/audit-demo-css-page-scope-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-demo-css-page-scope.mjs` 的**整脚本回归面**（[[INFRA-F138]]，2026-08-27）。
//
// WHY 选这条（19 条「非随包 ∧ blocking ∧ 接线零覆盖 ∧ P2 干净 ∧ P3 ∈ {NAMED,DIRLIST}」
//   候选里的排序理由，⛔ 数别抄 —— 跑 `pnpm report:gate-regression-face` 自己算）：
//   **候选集里挂载最深档并列第一 —— L4 pre-commit + L5 gitea-pr-checks + L5 gate-chain
//   三层**（`.husky/pre-commit` 条件触发 · `.gitea/workflows/pr-checks.yml` ·
//   `package.json` 的 `gate-chain`）。同为三层的另一条要多软链一层 `node_modules`
//   （它 import `ajv`）且依赖 `dist/`，本条零第三方依赖、零同仓 import ⇒ 同深度里最便宜。
//
//   🔴 **更要紧的是它 S3 那一层的形态**：`checkDenominator` 的三条 fail-closed 出口
//   （目录不存在 / 0 份 page-specific / 缺 `demo-shared.css`）在**真仓库的数据上永不进入**
//   —— `react-pilot/src/demos/` 永远在、永远 27 份、`demo-shared.css` 永远在。
//   ⇒ 摘掉这三条出口，`node scripts/audit-demo-css-page-scope.mjs` 在真仓库上照样
//   `EXIT=0`、输出逐字不变，**任何「它每次 commit 都跑所以有覆盖」的说法在这里都不成立**。
//   这是 [[INFRA-F138]] 记的第三种形态（「判据全在，而真仓库数据只覆盖一条路径」）的
//   又一个实例，且比上一轮那条更极端：那条要构造一行特殊文本才不进入，这条是**结构上**
//   不可能进入 —— 真仓库要满足它，得先把 docs 站的 demo 目录删掉。
//
// ⛔ 与 `tests/audit-demo-css-page-scope.test.ts` 的分工（**别合并**）：那份 import
//   `extractDeclaredClasses` / `findCollisions` / `checkDenominator` /
//   `findStaleExemptions` / `readDemoCss` 五个纯函数 + 三个常量，覆盖**判据逻辑**（19 条）。
//   本份 spawn 整个脚本，覆盖只有整脚本能看见的五样：
//     · 接线（入口守卫 → CLI 块 → `process.exit(failed ? 1 : 0)` 两个出口）
//     · `readDemoCss()` 被**不带参数**调用 ⇒ root 落在 `REPO_ROOT`（纯函数那份一律显式
//       传 root，对「main 里传错了根」结构上零敏感）
//     · `failed` 是**累加**不是 early-return：S3 红时 S1/S2 的绿档行照印
//     · `fresh = collisions.filter(不在豁免表)` 这一步的接线（豁免表为空时 fresh === collisions）
//     · 三条 S3 fail-closed 出口的**实际路径**（见上，真仓库永不进入）
//
// ⛔ 本面**覆盖不到 S2 的红档**（豁免 shrink-only）：`KNOWN_COLLISIONS` 是模块顶层常量、
//   且现为空表（闸头注释逐字写「表空着是正常终态」）⇒ `findStaleExemptions` 恒返回 []，
//   从 fixture 侧无法在**不改闸**的前提下让它非空（[[INFRA-F138]] 逐字封的路：
//   「别为了让 harness 能用去改活闸」）。S2 的判据逻辑由纯函数那份的两条用例覆盖，
//   本面只钉它的**自印读数**（绿档那句 + 「已登记豁免」整块在空表下不印）。
//   **这是如实登记的边界，不是待补 TODO。**
//
// ⛔ 污染纪律（[[INFRA-F138]] 记的三种形态）：本文件除被测闸自身外**不出现任何别的闸的
//   `.mjs` basename、也不出现任何别的 `audit:` npm key** —— 否则量具会把那条闸误报成
//   「已覆盖」。本闸的 stdout 只引用它自己的输入路径（`react-pilot/src/demos/…`），
//   不引用任何别的闸 ⇒ 这里不需要「断言截短」那一招。fixture 的样式表一律用 `-fx-` 假名。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-demo-css-page-scope.mjs'
const DEMOS = 'react-pilot/src/demos'

afterAll(() => cleanupGateFixtures())

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 真仓库现取 **27** 份 page-specific stylesheet（`node scripts/audit-demo-css-page-scope.mjs`
// 自印那行）。fixture 一律用 2-4 份 ⇒ 绿档钉的是 fixture 自己那个数，反向排除的是 27。
// 「跑错了树」在这里结构上不可能同时对上两侧。
const REAL_REPO_COUNT = '27 page-specific'

/** 闸只认 `.css`，且 `demo-shared.css` / `demo-figma-members.css` 不算 page-specific。 */
const SHARED = 'demo-shared.css'
const MEMBERS = 'demo-figma-members.css'

function demosFile(name: string, body: string): [string, string] {
  return [`${DEMOS}/${name}`, body]
}

/** 建一棵只含 demos 目录的 fixture。`files` 的 key 是 demos 目录内的文件名。 */
function fixture(files: Record<string, string>): string {
  return createGateFixture({
    gate: GATE,
    prefix: 'demo-css-scope-fx',
    files: Object.fromEntries(Object.entries(files).map(([n, b]) => demosFile(n, b))),
  })
}

/** 最后一个非空行 —— 取**终态事实**用，见下方「终态」两条用例。 */
function lastLine(s: string): string {
  const lines = s.split('\n').filter((l) => l.trim() !== '')
  return lines[lines.length - 1] ?? ''
}

/** 一份干净的 page-specific 样式表：只声明自己的类。 */
function ownOnly(slug: string): string {
  return `@import './${SHARED}';\n.${slug}-fx-panel { color: red }\n.${slug}-fx-row { display: flex }\n`
}

const CLEAN_THREE = {
  [SHARED]: '.fx-shared-surface { display: grid }\n',
  'alpha-fx-demo.css': ownOnly('alpha'),
  'beta-fx-demo.css': ownOnly('beta'),
  'gamma-fx-demo.css': ownOnly('gamma'),
}

describe('audit-demo-css-page-scope CLI — 绿档（接线走到两个出口里的 exit 0）', () => {
  it('干净目录 ⇒ exit 0，且三层自印的读数全是 **fixture 自己的**', () => {
    const root = fixture(CLEAN_THREE)
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: [
        // S3 的分母 = fixture 里那 3 份（`demo-shared.css` 不计入）
        `✓ S3 denominator — 3 page-specific stylesheet(s), ${SHARED} present`,
        '✓ S1 cross-page collisions — 0 new（0 条已登记豁免）',
        '✓ S2 shrink-only — 豁免表每条都仍在碰撞',
      ],
    })
    // 反向钉：跑错了树（退回真仓库读）就会印 27 —— 与上面的 3 不可能同时成立。
    expect(run.stdout).not.toContain(REAL_REPO_COUNT)
  })

  it('绿档的**终态事实**：闸自己最后那句话就是输出的最后一行（排除「碰巧崩在 exit 0」）', () => {
    const root = fixture(CLEAN_THREE)
    const run = runGate(root, GATE)

    expect(run.status).toBe(0)
    expect(lastLine(run.stdout)).toBe('✓ S2 shrink-only — 豁免表每条都仍在碰撞')
  })

  it('豁免表为空 ⇒ 「已登记豁免」那整块不印（S2 空表终态的自印读数钉）', () => {
    const root = fixture(CLEAN_THREE)
    const run = runGate(root, GATE)

    expect(run.stdout).toContain('（0 条已登记豁免）')
    expect(run.stdout).not.toContain('已登记豁免（owner 视觉门 pending')
    // must-not-hit 的正向锚点：闸确实在这棵 fixture 上跑过（自印了 fixture 自己的分母）。
    expect(run.stdout).toContain('— 3 page-specific stylesheet(s)')
  })

  it('两份 page 文件各自 `@import` 共享表 ⇒ 不算碰撞（否则闸恒红）', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n.fx-shared-card { padding: 8px }\n',
      'alpha-fx-demo.css': `@import './${SHARED}';\n.alpha-fx-panel { color: red }\n`,
      'beta-fx-demo.css': `@import './${SHARED}';\n.beta-fx-panel { color: blue }\n`,
    })
    const run = runGate(root, GATE)

    expectGateGreen(run, { contains: ['✓ S1 cross-page collisions — 0 new'] })
    // 正向锚点：分母是 fixture 自己的 2（`demo-shared.css` 被排除），不是 27。
    expect(run.stdout).toContain('— 2 page-specific stylesheet(s)')
    expect(run.stdout).not.toContain(REAL_REPO_COUNT)
  })

  it('注释里提到同名类 ⇒ 不算声明（剥注释那一步真的接在 main 的读取路径上）', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      'alpha-fx-demo.css': '.fx-only-in-alpha { color: red }\n',
      'beta-fx-demo.css': '/* .fx-only-in-alpha 住在 alpha-fx-demo.css 里 */\n.beta-fx-panel { color: blue }\n',
    })
    const run = runGate(root, GATE)

    expectGateGreen(run, { contains: ['✓ S1 cross-page collisions — 0 new'] })
    expect(run.stdout).toContain('— 2 page-specific stylesheet(s)')
  })

  it('非 page-specific 的两份（共享 / figma-members）与 page 文件同名类 ⇒ 不算碰撞，也不计入分母', () => {
    const root = fixture({
      [SHARED]: '.fx-common-grid { display: grid }\n',
      [MEMBERS]: '.fx-common-grid { gap: 8px }\n',
      'alpha-fx-demo.css': '.fx-common-grid { grid-template-columns: 1fr }\n',
      'beta-fx-demo.css': '.beta-fx-panel { color: blue }\n',
    })
    const run = runGate(root, GATE)

    // 3 个文件声明了 `.fx-common-grid`，但只有 1 个是 page-specific ⇒ 不碰撞。
    expectGateGreen(run, { contains: ['✓ S1 cross-page collisions — 0 new'] })
    // 分母 = 2（4 份 css 减去两份非 page 文件）—— 这同时是「NON_PAGE_FILES 真的被跳过」的读数。
    expect(run.stdout).toContain('— 2 page-specific stylesheet(s)')
  })

  it('空的 `.css` 计入分母但零声明；非 `.css` 文件根本不进分母', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      'alpha-fx-demo.css': '',
      'beta-fx-demo.css': '.beta-fx-panel { color: blue }\n',
      'notes-fx.md': '# 不是样式表\n.beta-fx-panel 在这里被提到\n',
      'legacy-fx.scss': '.beta-fx-panel { color: red }\n',
    })
    const run = runGate(root, GATE)

    // `.md` / `.scss` 都不计入，也不参与碰撞 ⇒ 分母恰好 2（含那份空的）。
    expectGateGreen(run, { contains: ['— 2 page-specific stylesheet(s)', '✓ S1 cross-page collisions — 0 new'] })
  })
})

describe('audit-demo-css-page-scope CLI — S1 红档（接线走到 exit 1）', () => {
  it('两份 page 文件声明同一个类 ⇒ exit 1，点名判据 + 逐条点名两个文件（排序稳定）', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      'beta-fx-demo.css': '.fx-collide-grid { grid-template-columns: 1fr }\n',
      'alpha-fx-demo.css': '.fx-collide-grid { grid-template-columns: 2fr }\n.alpha-fx-panel { color: red }\n',
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S1 cross-page class collisions（新增）',
      checks: ['.fx-collide-grid 被这些 page-specific 文件各自声明：alpha-fx-demo.css, beta-fx-demo.css'],
    })
    // 修法段是接线的一部分（它只在 `fresh.length` 分支里印）
    expect(run.stderr).toContain('修法：真跨页共享的类搬进')
    // 红档里 S3/S2 的绿行照印 ⇒ `failed` 是累加、不是 early return
    expect(run.stdout).toContain('✓ S3 denominator — 2 page-specific stylesheet(s)')
    expect(run.stdout).toContain('✓ S2 shrink-only')
  })

  it('红档的**终态事实**：stderr 最后一行是闸自己的修法末句，不是崩溃栈尾', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      'alpha-fx-demo.css': '.fx-collide-grid { color: red }\n',
      'beta-fx-demo.css': '.fx-collide-grid { color: blue }\n',
    })
    const run = runGate(root, GATE)

    expect(run.status).toBe(1)
    expect(lastLine(run.stderr)).toContain('那只是把碰撞改名')
  })

  it('碰撞只出现在 `@media` 块内 ⇒ 照样红（@media 内层计入这条接线真的走到）', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      'alpha-fx-demo.css': '@media (max-width: 700px) { .fx-narrow-only { display: none } }\n',
      'beta-fx-demo.css': '@media (max-width: 900px) { .fx-narrow-only { display: block } }\n',
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S1 cross-page class collisions（新增）',
      checks: ['.fx-narrow-only 被这些 page-specific 文件各自声明：alpha-fx-demo.css, beta-fx-demo.css'],
    })
  })

  it('碰撞类紧跟在 `@import …;` 之后 ⇒ 照样红（等长剥 at-statement 是承重的，不剥会漏掉真声明）', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      // ⚠️ 这两份的第一个 `{` 之前有一整条 `@import …;`。闸若不先等长剥掉它，
      //    选择器文本会被连着 `@import` 一起吞进来、判成 at-rule prelude 而整条跳过
      //    ⇒ `.fx-after-import` 这个**真声明**被漏掉 ⇒ 假绿。
      'alpha-fx-demo.css': `@import './${SHARED}';\n.fx-after-import { color: red }\n`,
      'beta-fx-demo.css': `@import './${SHARED}';\n.fx-after-import { color: blue }\n`,
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S1 cross-page class collisions（新增）',
      checks: ['.fx-after-import 被这些 page-specific 文件各自声明：alpha-fx-demo.css, beta-fx-demo.css'],
    })
  })

  it('三份文件撞同一个类 ⇒ 三个文件名全部点到（不是只报前两个）', () => {
    const root = fixture({
      [SHARED]: '.fx-shared-surface { display: grid }\n',
      'gamma-fx-demo.css': '.fx-triple { color: green }\n',
      'alpha-fx-demo.css': '.fx-triple { color: red }\n',
      'beta-fx-demo.css': '.fx-triple { color: blue }\n',
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S1 cross-page class collisions（新增）',
      checks: ['.fx-triple 被这些 page-specific 文件各自声明：alpha-fx-demo.css, beta-fx-demo.css, gamma-fx-demo.css'],
    })
  })
})

describe('audit-demo-css-page-scope CLI — S3 三条 fail-closed 出口（真仓库数据永不进入的那批）', () => {
  it('demos 目录整个不存在 ⇒ 红且说「不存在」，⛔ 不是「扫到 0 个文件所以通过」', () => {
    // ⚠️ 这棵 fixture 里**没有** `react-pilot/` —— 真仓库里这一支永远在，
    //    所以这条出口在 `pnpm audit:demo-css-page-scope` 的日常运行中结构上不可达。
    const root = createGateFixture({ gate: GATE, prefix: 'demo-css-scope-fx-nodir' })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S3 denominator (fail closed)',
      checks: [`${DEMOS} 不存在 —— 目录被改名/搬走了？改 DEMOS_DIR，别让闸扫 0 个文件当通过`],
    })
    // 无 fallback 回真仓库的凭据：真仓库那棵在，若退回去读就会印 27 而不是这条 finding。
    expect(`${run.stdout}${run.stderr}`).not.toContain(REAL_REPO_COUNT)
  })

  it('目录在但 0 份 page-specific（只有共享表）⇒ 红', () => {
    const root = fixture({ [SHARED]: '.fx-shared-surface { display: grid }\n' })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S3 denominator (fail closed)',
      checks: [`${DEMOS} 下 0 份 page-specific *.css —— 命名约定变了？`],
    })
  })

  it('有 page 文件但 `demo-shared.css` 不在 ⇒ 红（修法建议会失效）', () => {
    const root = fixture({
      'alpha-fx-demo.css': '.alpha-fx-panel { color: red }\n',
      'beta-fx-demo.css': '.beta-fx-panel { color: blue }\n',
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S3 denominator (fail closed)',
      checks: [`${DEMOS}/${SHARED} 不在 —— 跨页共享类没有合法落点，本闸的修法建议会失效`],
    })
  })

  it('S3 红时 S1/S2 照常判并照印绿行 ⇒ `failed` 是累加不是 early return', () => {
    const root = createGateFixture({ gate: GATE, prefix: 'demo-css-scope-fx-nodir2' })
    const run = runGate(root, GATE)

    expect(run.status).toBe(1)
    // 目录不存在 ⇒ pageFiles 空 ⇒ S1 无碰撞、S2 空表 ⇒ 两条绿行仍然印出来
    expect(run.stdout).toContain('✓ S1 cross-page collisions — 0 new（0 条已登记豁免）')
    expect(run.stdout).toContain('✓ S2 shrink-only — 豁免表每条都仍在碰撞')
  })

  it('S3 与 S1 同时红 ⇒ 两条判据都点名，退出码仍是 1（不是撞上第一条就走）', () => {
    const root = fixture({
      // 缺 `demo-shared.css` ⇒ S3 红；两份 page 文件撞同名类 ⇒ S1 也红
      'alpha-fx-demo.css': '.fx-both-red { color: red }\n',
      'beta-fx-demo.css': '.fx-both-red { color: blue }\n',
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S3 denominator (fail closed)',
      checks: [
        `${DEMOS}/${SHARED} 不在`,
        '✗ S1 cross-page class collisions（新增）',
        '.fx-both-red 被这些 page-specific 文件各自声明：alpha-fx-demo.css, beta-fx-demo.css',
      ],
    })
  })
})
