// tests/audit-source-discipline-identifiers.test.ts
//
// §M-DISCIPLINE.SOURCE [2] —— 真源条文里的裸 node id / file key 只许缩。
// 口径与两侧读数的唯一真源 =
//   ai-ds-lab/docs/2026-09-08-q1-b3-source-discipline-preregistration.md
//
// 🔴 **为什么单测全喂合成文档、⛔ 不断言真仓那 4 份 md**：闸自己每次跑就是在断言真面，
//   测试再断言一遍 = 同一件事两个真源 —— 那 4 份文档天天在改，测试会变成「改文档就红」的
//   第二道闸，而它给的信息与闸完全重复。⇒ 测试守**判据的识别逻辑**，闸守**真面的存量**。
//
// ── 合成形态的出处（都是 2026-09-08 那次真面测量的现取值）────────────────
//   · 真 node id 几乎总在反引号内：`8878:2` / `83:9846` / `1408:17154` / `9257:2`
//     —— 注意首段 2 位与次段 1 位都真实存在 ⇒ **任何按位数切的形态都会误伤真阳**
//   · 比例习语总是裸的：`1:1 对应` / `≈ 2:1 / ≈ 1:2` / `4.5:1 / 大字与 UI 组件 3:1`
//   · 22 位假阳全是驼峰标识符且**无数字**：`sectionInternalOverlap`（22 字符）/
//     `layoutSizingHorizontal` / `setRangeTextDecoration` / `copyDesignTokensPlugin`
import { describe, expect, it } from 'vitest'
import {
  classifySourceDiscipline,
  codeSpans,
  scanDoc,
  KNOWN_NODE_IDS,
  NOT_NODE_IDS,
  KNOWN_FILE_KEYS,
  SCAN_FILES,
} from '../scripts/audit-source-discipline-identifiers.mjs'

/**
 * 造一个只含指定文件的判定面。
 * ⚠️ 默认让**每个**具名集合成员都出现一次 —— 否则 SRC-SHRINK 会为 25+2 个值各报一条，
 * 把每个用例的 violations 都灌满噪音。`extra` 是这个用例真正要测的行。
 */
function fixture(extra: string[] = [], opts: { includeKnown?: boolean } = {}) {
  const { includeKnown = true } = opts
  const lines: string[] = []
  if (includeKnown) {
    for (const v of KNOWN_NODE_IDS) lines.push(`- 存量：\`${v}\``)
    for (const [v] of NOT_NODE_IDS) lines.push(`- 豁免：\`${v}\``)
    for (const v of KNOWN_FILE_KEYS) lines.push(`- key：\`${v}\``)
  }
  lines.push(...extra)
  const text = lines.join('\n')
  return classifySourceDiscipline(
    () => text,
    () => true,
    ['docs/internal/mockup-conventions.md'],
  )
}

describe('SRC-A —— 反引号内未登记的 node id', () => {
  it('新增一个未登记的 node id ⇒ 报 SRC-A 并点名值与行号', () => {
    const { violations } = fixture(['新加的一行 `1234:5678` 在这里'])
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('SRC-A')
    expect(violations[0].value).toBe('1234:5678')
    expect(violations[0].why).toContain('必须能说明它为什么跨产品通用')
  })

  it('已登记的存量 ⇒ 不报（棘轮的地板不是 0）', () => {
    const { violations } = fixture()
    expect(violations).toHaveLength(0)
  })

  it('首段 2 位 / 次段 1 位的真 node id 都在集合里 —— 钉住「不能按位数切」', () => {
    // 这两个值是真面上的实测形态；若哪天有人把判据改成 \d{3,}: 或要求次段 ≥2 位，
    // 它们会掉出集合 ⇒ SRC-SHRINK 当场红。
    expect(KNOWN_NODE_IDS.has('83:9846')).toBe(true)
    expect(KNOWN_NODE_IDS.has('9257:2')).toBe(true)
  })
})

describe('【F-S3 对照】裸形态 ⇒ ⛔ 不进判定面', () => {
  it('裸写的比例习语一个都不报（真面上这类共 32 处）', () => {
    const { violations, scanned } = fixture([
      '逐行 ZH 比 Layout B 上下分节更易 1:1 对应',
      '- Section 长宽比（≈ 2:1 / ≈ 1:2）',
      '按 WCAG AA 判（正文 4.5:1 / 大字与 UI 组件 3:1）',
      '单独 1:1 邮件——禁止入交付卡',
    ])
    expect(violations).toHaveLength(0)
    // 分母也不许被它们灌大：scanned 只应是具名集合那些。
    // ⚠️ 期望写**字面 25**（23 存量 + 2 豁免），⛔ 不写 `KNOWN.size + NOT.size` ——
    //    后者与被测对象耦合，拆掉豁免表时两边同时变小 ⇒ 断言照样绿 ⇒ 零信息。
    expect(scanned.nodeIds).toBe(25)
  })

  it('反引号**外**的 node id 形态确实探不到 —— 如实登记的射程边界', () => {
    // 🔴 这里必须用一个**未登记**的值（`5555:6666`）。第一版写的是 `4518:3244` ——
    //    它在 KNOWN_NODE_IDS 里 ⇒ 扫到也不违例 ⇒ 那条断言**恒真、零信息**，
    //    造故障 F-S3 拆掉反引号约束时它照样绿（预测 2 条实测 1 条，就是这样抓出来的）。
    const { violations } = fixture(['这里有个裸写的 5555:6666 却不在反引号里'])
    expect(violations).toHaveLength(0)
  })
})

describe('SRC-B —— file key，且「含 ≥1 数字」是判据的一部分', () => {
  it('新增一个未登记的 22 位含数字 key ⇒ 报 SRC-B', () => {
    const { violations } = fixture(['新文件 `AbCdEf0123456789012345` 的 key'])
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('SRC-B')
    expect(violations[0].value).toBe('AbCdEf0123456789012345')
  })

  it('【F-S4 对照】恰好 22 字符但无数字的驼峰标识符 ⇒ ⛔ 不报（真面上这类 8 处、假阳率 73%）', () => {
    const camel = ['sectionInternalOverlap', 'layoutSizingHorizontal', 'setRangeTextDecoration', 'copyDesignTokensPlugin']
    for (const c of camel) expect(c).toHaveLength(22) // 先钉住前提：它们真的是 22 字符
    const { violations, scanned } = fixture(camel.map((c) => `- \`${c}\``))
    expect(violations).toHaveLength(0)
    expect(scanned.fileKeys).toBe(KNOWN_FILE_KEYS.size)
  })

  it('已登记的 DS 库自己的 fileKey ⇒ 不报（它逐字是「跨产品通用」的那一类）', () => {
    expect(KNOWN_FILE_KEYS.has('YbsPRUVmNdsbN40NNwh1Gn')).toBe(true)
    const { violations } = fixture()
    expect(violations).toHaveLength(0)
  })
})

describe('【F-S5】具名豁免 NOT_NODE_IDS —— ⛔ 必须两侧都有牙', () => {
  it('豁免侧：`280:15`（ESLint 的行:列）⇒ 不报', () => {
    const { violations } = fixture(['ESLint 逐字点名 `280:15`'])
    expect(violations).toHaveLength(0)
  })

  it('双侧：同一次调用里豁免值不报、新值报 —— ⛔ 钉住非豁免侧', () => {
    // 🔴 只断言「豁免侧为 0」的用例，拆掉整段豁免照样绿（恒真 ⇒ 零信息的反向版本）。
    const { violations } = fixture(['`7:16` 与 `280:15` 都豁免，而 `4321:8765` 不豁免'])
    expect(violations.map((v) => v.value)).toEqual(['4321:8765'])
  })

  it('豁免表的具名内容 + 每条都带逐字理由（⛔ 空表不许过 —— 那是恒真断言）', () => {
    // 🔴 只写 `for (const [v, r] of NOT_NODE_IDS) expect(r.length)…` 是**恒真断言**：
    //    把整张豁免表清空 ⇒ 循环不执行 ⇒ 照样绿。⇒ 先钉住具名内容，再查理由。
    expect([...NOT_NODE_IDS.keys()]).toEqual(['7:16', '280:15'])
    for (const [v, reason] of NOT_NODE_IDS) {
      expect(reason.length, `豁免 ${v} 缺理由`).toBeGreaterThan(20)
    }
  })
})

describe('SRC-SHRINK —— 集合必须是活源的镜像（防 Q8 那族「总数守住、身份变了」）', () => {
  it('集合里的值在判定面里消失 ⇒ 报 SRC-SHRINK 并说这是好事', () => {
    // 只放一个存量，其余全部"消失"
    const text = '- 只剩这一个：`8878:2`'
    const { violations } = classifySourceDiscipline(
      () => text,
      () => true,
      ['docs/internal/mockup-conventions.md'],
    )
    const shrink = violations.filter((v) => v.probe === 'SRC-SHRINK')
    // 字面 26 = 23 存量 − 1 留下的 + 2 豁免 + 2 file key（⛔ 不用 .size 算式，理由同上）
    expect(shrink).toHaveLength(26)
    expect(shrink.some((v) => v.why.includes('这是好事'))).toBe(true)
    expect(shrink.every((v) => v.value !== '8878:2')).toBe(true)
  })

  it('一个换一个 —— 个数不变但身份变了，仍然红（这正是 Q8 那族的病）', () => {
    const swapped = [...KNOWN_NODE_IDS].slice(0, -1)
    const text = [
      ...swapped.map((v) => `\`${v}\``),
      '`9999:1111`', // 顶替掉最后那个
      ...[...NOT_NODE_IDS.keys()].map((v) => `\`${v}\``),
      ...[...KNOWN_FILE_KEYS].map((v) => `\`${v}\``),
    ].join('\n')
    const { violations } = classifySourceDiscipline(
      () => text,
      () => true,
      ['docs/internal/mockup-conventions.md'],
    )
    // 新增 1 条 SRC-A + 消失 1 条 SRC-SHRINK ⇒ 两侧都被点出来
    expect(violations.filter((v) => v.probe === 'SRC-A').map((v) => v.value)).toEqual(['9999:1111'])
    expect(violations.filter((v) => v.probe === 'SRC-SHRINK')).toHaveLength(1)
  })
})

describe('fail-closed —— 判定面缺一份 ⇒ 不是「零违例」', () => {
  it('文件读不到 ⇒ 报 SRC-MISSING 并逐字说别读成零违例', () => {
    const { violations } = classifySourceDiscipline(
      () => '',
      () => false,
      ['docs/internal/nope.md'],
    )
    const missing = violations.filter((v) => v.probe === 'SRC-MISSING')
    expect(missing).toHaveLength(1)
    expect(missing[0].why).toContain('⛔ 别读成「零违例」')
  })

  it('判定面就是那四份规则真源，⛔ 不是 glob', () => {
    expect(SCAN_FILES).toEqual([
      'docs/internal/mockup-conventions.md',
      'docs/internal/design-process.md',
      'docs/internal/code-conventions.md',
      'docs/internal/domain-tvu.md',
    ])
  })
})

describe('判定面单元 —— codeSpans / scanDoc', () => {
  it('codeSpans 只取反引号内的内容', () => {
    expect(codeSpans('前 `a:1` 中 `b:2` 后')).toEqual(['a:1', 'b:2'])
    expect(codeSpans('一个反引号都没有')).toEqual([])
  })

  it('一个 code span 里的多个标识符都取到（`component_set 1408:17154` 那种形态）', () => {
    const { nodeIds } = scanDoc('- 必用（`component_set 1408:17154`、旧 set `4482:1197` 不存在）')
    expect(nodeIds.map((n) => n.value)).toEqual(['1408:17154', '4482:1197'])
  })

  it('行号从 1 起算', () => {
    const { nodeIds } = scanDoc('第一行\n第二行 `77:88`')
    expect(nodeIds).toEqual([{ value: '77:88', line: 2 }])
  })

  it('URL / 小数点旁的数字不算（词边界断言）', () => {
    const { nodeIds } = scanDoc('`udp://237.0.0.1:1234` 与 `v1.2:3`')
    expect(nodeIds.map((n) => n.value)).not.toContain('1:1234')
  })
})
