// tests/audit-mockup-source-link-integrity.test.ts
//
// §M23.8.1 —— 标了溯源字样的 annotation，其 ticket 引用必须是指向真实 Jira URL 的 hyperlink。
// 口径与两侧读数的唯一真源 =
//   ai-ds-lab/docs/2026-09-08-q1-m23-8-1-preregistration.md
//
// 🔴 **为什么全部用合成节点树、⛔ 不喂真 payload**：`.gitignore:29` 排掉 `figma-data/mockup/`，
//   `git ls-files` 为空 ⇒ 那 4 份快照（1.1 GB）只在开发者主工作树。真面分母只作为**一次本机
//   读数**登记在判据头注释与预注册里，⛔ 不由闸守。
//
// ── 合成形态的出处（2026-09-08 那次真面测量的现取值，遍历 305,222 节点）──────
//   · 全量 ticket range **217**：主条射程（`Jira requirement` 实例内）**112** ·
//     本闸射程（实例外 + 标了溯源字样）**23** · 实例外未标溯源字样 **82**
//   · 本闸射程内三态：无链 **3** · 链错 **4** · 合规 **16**
//   · 真实溯源行的形态逐字：`JIRA: BM-1047 · BM-1044 · BM-1051 · THS-191 · MC-10 · BM-1083`
//     以及「关联 dev：BM-1044 / BM-1051 / MC-10。」
//   · 链**全部**走 `node.style.hyperlink`（主条实测：override 表里 0 个）
//   · 射程外那批里有形态上排不掉的假阳：`Q2-1`（「PM 已确认三组推荐方案（Q2-1 方案D /
//     Q4 方案③ / Q5 方案A）」= 设计探索问题编号）· `PID-1001`（「Stop preview for PID-1001?」
//     = UI 文案里的进程 ID）⇒ 它们**必须**落在射程外，由 F-T1 承重
import { describe, expect, it } from 'vitest'
import {
  classifySourceLinkIntegrity,
  collectSourceTickets,
  SOURCE_MARKER_RE,
} from '../scripts/audit-mockup-source-link-integrity.mjs'

type AnyNode = Record<string, any>

const BROWSE = (id: string) => `https://tvunetworks.atlassian.net/browse/${id}`

/** 造一个 TEXT 节点；`url` 非空 ⇒ 整串走 `node.style.hyperlink`（真面上的形态）。 */
function text(chars: string, opts: { id?: string; name?: string; url?: string } = {}): AnyNode {
  const n: AnyNode = {
    id: opts.id ?? `t${Math.random().toString(36).slice(2, 6)}:1`,
    name: opts.name ?? 'annotation',
    type: 'TEXT',
    characters: chars,
  }
  if (opts.url) n.style = { hyperlink: { type: 'URL', url: opts.url } }
  return n
}

function doc(...children: AnyNode[]): AnyNode {
  return { id: '0:0', name: 'Document', type: 'DOCUMENT', children }
}

/** `Jira requirement` 实例（主条射程）。 */
function jiraInstance(...children: AnyNode[]): AnyNode {
  return { id: 'I1:1;2:2', name: 'Jira requirement', type: 'INSTANCE', children }
}

describe('射程 —— 只判「标了溯源字样」且「不在 Jira requirement 实例内」的', () => {
  it('溯源字样闭集逐字就是规则第 5 款给的四个词', () => {
    for (const w of ['来源', '出处', 'Source', '讨论结论']) expect(SOURCE_MARKER_RE.test(w)).toBe(true)
    // ⛔ 这两个**不在**规则里 —— 加它们就是发明射程（真面上它们确实出现过，但那不是依据）
    expect(SOURCE_MARKER_RE.test('JIRA:')).toBe(false)
    expect(SOURCE_MARKER_RE.test('关联 dev')).toBe(false)
  })

  it('【正向 A】标了「来源」而 ticket 是纯文本 ⇒ 报 M23.8.1-A', () => {
    const { violations, scanned } = classifySourceLinkIntegrity(
      doc(text('来源：BM-1047 主需求', { id: 'a:1' })),
    )
    expect(scanned).toBe(1)
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M23.8.1-A')
    expect(violations[0].ticket).toBe('BM-1047')
    expect(violations[0].why).toContain('禁纯文本 ID')
  })

  it('【正向 B】有链但不是 Jira browse 地址 ⇒ 报 M23.8.1-B（真面上这类 4 处）', () => {
    const { violations } = classifySourceLinkIntegrity(
      doc(text('出处：FB-9398 讨论', { id: 'b:1', url: 'https://tvu.slack.com/archives/C123/p456' })),
    )
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M23.8.1-B')
    expect(violations[0].why).toContain('atlassian.net/browse')
  })

  it('【阴性】标了溯源字样且带 Jira 链 ⇒ 不报（真面上这类 16 处）', () => {
    const { violations, scanned } = classifySourceLinkIntegrity(
      doc(text('Source: V4-2333', { id: 'c:1', url: BROWSE('V4-2333') })),
    )
    expect(scanned).toBe(1)
    expect(violations).toHaveLength(0)
  })

  it('【F-T1 对照】没标溯源字样的 ticket ⇒ 不进分母，只计 outOfScopeCount', () => {
    // 🔴 这条是射程收窄那颗牙：放宽射程会把下面这些**形态上排不掉的假阳**一起判红。
    const { violations, scanned, outOfScopeCount } = classifySourceLinkIntegrity(
      doc(
        text('PM 已确认三组推荐方案（Q2-1 方案D / Q4 方案③ / Q5 方案A）', { id: 'q:1' }),
        text('Stop preview for PID-1001?', { id: 'p:1' }),
        text('关联 dev：BM-1044 / BM-1051 / MC-10。', { id: 'd:1' }),
      ),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
    // Q2-1 · PID-1001 · BM-1044 · BM-1051 · MC-10 = 5 个 range 被记数但不判
    expect(outOfScopeCount).toBe(5)
  })

  it('【F-T2 对照】Jira requirement 实例内的 ⇒ 跳过（那是 §M23.8 主条射程，⛔ 不双报）', () => {
    const { violations, scanned, insideMainCount } = classifySourceLinkIntegrity(
      doc(jiraInstance(text('来源：SEC-1 客户 Epic', { id: 'in:1', name: 'Issue key' }))),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
    expect(insideMainCount).toBe(1)
  })

  it('实例内外同时存在 ⇒ 各归各家，⛔ 不互相污染', () => {
    const { violations, scanned, insideMainCount } = classifySourceLinkIntegrity(
      doc(
        jiraInstance(text('来源：SEC-1', { id: 'in:2', name: 'Issue key' })),
        text('来源：BM-1051 准入 gate', { id: 'out:2' }),
      ),
    )
    expect(insideMainCount).toBe(1)
    expect(scanned).toBe(1)
    expect(violations.map((v) => v.ticket)).toEqual(['BM-1051'])
  })
})

describe('多 ticket 的溯源行 —— 真面上的主形态', () => {
  it('一行里多个 ticket 逐个判，逐个报（`JIRA: A · B · C` 那种形态）', () => {
    const { violations, scanned } = classifySourceLinkIntegrity(
      doc(text('来源 JIRA: BM-1047 · BM-1044 · THS-191 · MC-10', { id: 'm:1' })),
    )
    expect(scanned).toBe(4)
    expect(violations).toHaveLength(4)
    expect(violations.map((v) => v.ticket)).toEqual(['BM-1047', 'BM-1044', 'THS-191', 'MC-10'])
  })

  it('整串加链（style.hyperlink）会覆盖行内每个 ticket ⇒ 全合规', () => {
    // ⚠️ 这是真面上的实际形态：主条实测 109 个链**全部**走 style、override 表 0 个。
    const { violations, scanned } = classifySourceLinkIntegrity(
      doc(text('来源：BM-1047 · BM-1044', { id: 'm:2', url: BROWSE('BM-1047') })),
    )
    expect(scanned).toBe(2)
    expect(violations).toHaveLength(0)
  })

  it('逐 range 加链（characterStyleOverrides）也认', () => {
    const chars = '来源：BM-1047 与 FB-9398'
    const i = chars.indexOf('FB-9398')
    const cso: (number | null)[] = Array.from({ length: chars.length }, () => null)
    for (let k = i; k < i + 'FB-9398'.length; k++) cso[k] = 7
    const node = text(chars, { id: 'm:3' })
    node.characterStyleOverrides = cso
    node.styleOverrideTable = { 7: { hyperlink: { type: 'URL', url: BROWSE('FB-9398') } } }
    const { violations } = classifySourceLinkIntegrity(doc(node))
    // BM-1047 无链 ⇒ 报 1 条；FB-9398 有链 ⇒ 不报
    expect(violations.map((v) => v.ticket)).toEqual(['BM-1047'])
  })
})

describe('分母 —— scanned === 0 ⇒ ⛔ 不可读成「通过」', () => {
  it('没有任何带溯源字样的 TEXT ⇒ scanned 0（模块据此报 unverified）', () => {
    const { violations, scanned, markedTextCount } = classifySourceLinkIntegrity(
      doc(text('普通说明文字，没有 ticket', { id: 'z:1' })),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
    expect(markedTextCount).toBe(0)
  })

  it('标了溯源字样但**没有** ticket ⇒ markedTextCount 计上、scanned 仍 0', () => {
    // ⚠️ 这一格如实反映射程边界：第 5 款还要求「出处本身可点」（Slack 链等），
    //    而本闸只判 ticket ID 那一支 ⇒ 这种「来源：Slack 讨论」不带 ticket 的**探不到**。
    const { scanned, markedTextCount } = classifySourceLinkIntegrity(
      doc(text('来源：Slack 讨论', { id: 'z:2' })),
    )
    expect(scanned).toBe(0)
    expect(markedTextCount).toBe(1)
  })

  it('空树 / 空对象不炸', () => {
    expect(classifySourceLinkIntegrity({}).scanned).toBe(0)
    expect(classifySourceLinkIntegrity(null as any).scanned).toBe(0)
  })
})

describe('collectSourceTickets —— 判定面本身', () => {
  it('递归进任意深度，并带出 range 位置', () => {
    let cur: AnyNode = text('来源：V4-2318', { id: 'deep:1' })
    for (let i = 0; i < 5; i++) cur = { id: `l${i}`, name: `L${i}`, type: 'FRAME', children: [cur] }
    const { inScope } = collectSourceTickets(doc(cur))
    expect(inScope).toHaveLength(1)
    expect(inScope[0].ticket).toBe('V4-2318')
    expect(inScope[0].start).toBe('来源：'.length)
  })

  it('嵌套在 Jira 实例内部深处的也算 inside（⛔ 不只看直接父节点）', () => {
    const deep = { id: 'g:1', name: 'Group', type: 'FRAME', children: [text('来源：SEC-9', { id: 'g:2' })] }
    const { inScope, insideMainCount } = collectSourceTickets(doc(jiraInstance(deep)))
    expect(inScope).toHaveLength(0)
    expect(insideMainCount).toBe(1)
  })

  it('characters 非字符串 / 缺失不炸', () => {
    const { inScope } = collectSourceTickets(
      doc({ id: 'n:1', name: '来源', type: 'TEXT' }, { id: 'n:2', name: 'x', type: 'TEXT', characters: 42 } as AnyNode),
    )
    expect(inScope).toHaveLength(0)
  })
})

// ── `urlTextNoLink` —— 第 5 款「出处本身必须可点」那一支的**重开条件观测点** ────────────
//
// 🔴 **这组用例存在的唯一理由：那个计数在今日真面上恒 0**（4 份 payload / 305,222 节点，
//    「溯源 TEXT 里有裸 URL 文本」实测 **0** ⇒ 「把 Slack 链接贴成纯文本」这个形态今天不存在）。
//    一个恒 0 的计数与一个**根本没接线**的计数，在读数上完全无法区分 ——
//    本仓已登记这条病（「只断言豁免侧为 0，分辨不了『被豁免』与『本来就不命中』」）。
//    ⇒ 牙必须落在这里：证明它**真的会变正**，并钉住它**不**产生 violation。
//
// ⛔ 期望一律写字面数，**不用被测对象的 `.length` / `.size` 表达** —— 那是恒真断言的常见伪装。
describe('urlTextNoLink —— 重开条件的观测点（⛔ 只计数、不判）', () => {
  const SLACK = 'https://tvunetworks.slack.com/archives/C021E1BNTPF/p1787823326421399'

  it('溯源 TEXT 里的裸 URL 没链 ⇒ 计数变正，且 ⛔ 不产生 violation', () => {
    const r = classifySourceLinkIntegrity(doc(text(`来源：Slack 讨论结论 ${SLACK}`, { id: 'u:1' })))
    expect(r.urlTextTotal).toBe(1)
    expect(r.urlTextNoLink).toBe(1)
    // ⛔ 判据没扩 —— 这一支只报数
    expect(r.violations).toHaveLength(0)
    expect(r.scanned).toBe(0) // 没有 ticket 引用 ⇒ 本闸的分母仍是 0
  })

  it('同一个 URL 被 hyperlink 覆盖 ⇒ 计数回 0（阴性对照：钉住它读的是链、不是「有没有 URL」）', () => {
    const r = classifySourceLinkIntegrity(doc(text(`来源：Slack 讨论结论 ${SLACK}`, { id: 'u:2', url: SLACK })))
    expect(r.urlTextTotal).toBe(1)
    expect(r.urlTextNoLink).toBe(0)
  })

  it('URL 在**没标溯源字样**的 TEXT 里 ⇒ 两个计数都不动（射程对照）', () => {
    const r = classifySourceLinkIntegrity(doc(text(`见 ${SLACK}`, { id: 'u:3' })))
    expect(r.urlTextTotal).toBe(0)
    expect(r.urlTextNoLink).toBe(0)
  })

  it('一个 TEXT 里两条裸 URL ⇒ 各算一次（⛔ 不是「这个节点有没有问题」的布尔）', () => {
    const r = classifySourceLinkIntegrity(
      doc(text(`出处：${SLACK} 与 https://tvunetworks.atlassian.net/wiki/x`, { id: 'u:4' })),
    )
    expect(r.urlTextTotal).toBe(2)
    expect(r.urlTextNoLink).toBe(2)
  })

  it('⛔ 射程词单独出现**不是**分母 —— 产品术语照样计入 markedTextCount', () => {
    // 🔴 这条钉死 2026-09-09 那次更正：`Source` 在 TVU 同时是产品第一术语（信号源）。
    //    这四行逐字取自真面（`Source Switcher` 现取 26 处 · `Source From` 8 处）。
    const r = classifySourceLinkIntegrity(
      doc(
        text('Source Switcher', { id: 'p:1' }),
        text('Selected Source:', { id: 'p:2' }),
        text('Source From', { id: 'p:3' }),
        text('IP Source: Switched to IP Source.', { id: 'p:4' }),
      ),
    )
    expect(r.markedTextCount).toBe(4) // ⇐ 全部命中射程词
    expect(r.scanned).toBe(0) // ⇐ 而分母是 0 ⇒ **markedTextCount ≠ 分母**
    expect(r.violations).toHaveLength(0)
  })
})

// ── 射程按「段容器」放宽一层（§M23.8.1 2026-09-10 订正）─────────────────────
//
// 🔴 **缘起是一次 checkedUnits=0 的假绿**：2026-09-10 那份 PRD 卡的 Source 段里，
//   段标题 `Source 需求来源` 是**一个独立 TEXT**，8 处引用在**另外三个兄弟 TEXT** 里，
//   那三个自己不含射程四词 ⇒ 按「同一个 TEXT 内」判射程时 **8 处引用一个都不在分母里**，
//   闸报的是 `checkedUnits=0`（分母为空的绿，⛔ 不是判过）。
// ⛔ **只放宽到「直接兄弟」，⛔ 不向下多层传播** —— 传播越多层，越接近被 F-T1 否掉的
//   「所有 ticket 形态都判」，而那条路已实测会引入形态上排不掉的假阳。
describe('射程按段容器放宽一层 —— 兄弟标了溯源字样，同容器内的 ticket 就进分母', () => {
  /** 一个段容器（对应 Figma 的 `sec/Source` frame）。 */
  function frame(id: string, ...children: AnyNode[]): AnyNode {
    return { id, name: 'sec/Source', type: 'FRAME', children }
  }

  it('【正向 · 2026-09-10 真形态】标题含 Source、兄弟行的 ticket 是纯文本 ⇒ 进分母并报 A', () => {
    const r = classifySourceLinkIntegrity(
      doc(
        frame(
          'sec:1',
          text('Source 需求来源', { id: 'h:1' }),
          text('Jira V4-1646 — The Wi-Fi function is missing the Other option.', { id: 'b:1' }),
        ),
      ),
    )
    expect(r.scanned).toBe(1)
    expect(r.inScopeBySiblingCount).toBe(1) // ⇐ 靠兄弟进的射程，不是自身命中
    expect(r.markedTextCount).toBe(1) // ⇐ 自身命中的仍只有标题那一个
    expect(r.violations.map((v) => v.probe)).toEqual(['M23.8.1-A'])
  })

  it('【阴性对照】同一结构、ticket 带 Jira 链 ⇒ 仍在分母里但不报（钉住判的是链不是射程）', () => {
    const r = classifySourceLinkIntegrity(
      doc(
        frame(
          'sec:2',
          text('Source 需求来源', { id: 'h:2' }),
          text('Jira V4-1646 — ...', { id: 'b:2', url: BROWSE('V4-1646') }),
        ),
      ),
    )
    expect(r.scanned).toBe(1) // ⇐ 分母不为 0 ⇒ 真的判过了
    expect(r.violations).toHaveLength(0)
  })

  it('⛔ 只放宽一层：祖父容器里有 Source、直接父容器内没有 ⇒ ⛔ 不进分母', () => {
    const r = classifySourceLinkIntegrity(
      doc(
        frame(
          'sec:3',
          text('Source 需求来源', { id: 'h:3' }),
          // 深一层的子容器：它自己的直接子里没有任何 marked 兄弟
          { id: 'inner:3', name: 'inner', type: 'FRAME', children: [text('see V4-9999', { id: 'b:3' })] },
        ),
      ),
    )
    expect(r.scanned).toBe(0)
    expect(r.outOfScopeCount).toBe(1)
    expect(r.violations).toHaveLength(0)
  })

  it('【F-T1 不回归】容器内没有任何兄弟标溯源字样 ⇒ 射程外那批仍在外', () => {
    const r = classifySourceLinkIntegrity(
      doc(
        frame(
          'sec:4',
          text('PM 已确认三组推荐方案（Q2-1 方案D / Q4 方案③ / Q5 方案A）', { id: 'q:4' }),
          text('Stop preview for PID-1001?', { id: 'p:4' }),
        ),
      ),
    )
    expect(r.scanned).toBe(0)
    expect(r.inScopeBySiblingCount).toBe(0)
    expect(r.outOfScopeCount).toBe(2)
  })

  it('⚠️ 如实登记的已知假阳面：产品术语与 ticket 同层时会被兄弟拉进分母', () => {
    // `Source Switcher` 是 TVU 的产品第一术语（信号源）。若它与一个 ticket 恰好同一个
    // 直接父容器，本条放宽会把那个 ticket 拉进分母 —— **这是本次放宽的代价，如实钉住**。
    // 真面上二者通常分处产品帧与交付卡（不同 frame）⇒ 未观测到实例；⛔ 一旦观测到，
    // 重开条件 = 给溯源 annotation 一个身份信号（见判据头注释 [3] 那一段）。
    const r = classifySourceLinkIntegrity(
      doc(frame('sec:5', text('Source Switcher', { id: 's:5' }), text('see V4-1646', { id: 'b:5' }))),
    )
    expect(r.scanned).toBe(1)
    expect(r.inScopeBySiblingCount).toBe(1)
  })
})
