// tests/audit-mockup-feedback-not-selfdrawn.test.ts
//
// §M41 [3] + §M12 [2] —— 反馈组件（`Notification` / `Message`）必须是库 instance，
// 不许自画 / detach 自建。
// 口径与两侧读数的唯一真源 =
//   ai-ds-lab/docs/2026-09-08-q1-b2-batch2-preregistration.md
//
// 🔴 **为什么全部用合成节点树，⛔ 不喂真 payload**（与 jira-hyperlink 同一理由，已亲验）：
//   `.gitignore:29` 逐字排掉 `figma-data/mockup/`，`git ls-files figma-data/mockup/` **为空**
//   ⇒ 那四份快照（1.1 GB）**只存在于开发者主工作树**。任何依赖它们的断言在别的 clone / CI
//   上都取不到文件 ⇒ 要么红、要么被 skip 掉变成零信息。
//   ⇒ 本文件用**照真形态合成**的节点树；真 payload 上的分母只作为**一次本机读数**登记在
//     判据头注释与预注册里，⛔ 不由闸守。
//
// ── 合成形态的出处（都是 2026-09-08 那次真 payload 测量的现取值，探针走过 302,766 节点）──
//   · 自画 dialog：`name: 'Notification'` · `type: 'FRAME'` · 无 `componentId` ·
//     children 逐字 `FRAME:"Notification_content"` + `FRAME:"comfirm"` · 480×161 / 480×182
//   · 合规 dialog：`name: 'Notification'` · `type: 'INSTANCE'` · 有 `componentId` ·
//     children 逐字 `FRAME:"content 1"` + `FRAME:"comfirm"`
//     ⚠️ 注意 `comfirm` 这个拼写错**在合规 instance 里也有** ⇒ 它来自库组件本身，
//     ⛔ 不是手打痕迹（这一条更正了上一轮的推断）。区分二者的 children 差异是
//     `Notification_content` vs `content 1` —— 但判据 ⛔ 不用 children 名（那是代理指标），
//     判据是 `type`。
//   · 词族污染（放宽成 `includes` 就会全部变假阳，共 73 处）：
//     `icon/Message/Help 1` INSTANCE（真 payload 里 445 处）· `Notification_content` SLOT（30）
//     · `Notification_content row` FRAME · `Notification --> Flow_text` VECTOR · 散文 TEXT
import { describe, expect, it } from 'vitest'
import {
  classifyFeedbackSelfDrawn,
  collectFeedbackNodes,
  FEEDBACK_NAMES,
  USAGE_TYPES,
  PROBES,
} from '../scripts/audit-mockup-feedback-not-selfdrawn.mjs'

type AnyNode = Record<string, any>

/** 造一个节点。 */
function node(name: string, type: string, extra: AnyNode = {}): AnyNode {
  return { id: extra.id ?? `${Math.random().toString(36).slice(2, 6)}:1`, name, type, ...extra }
}

/** 把一组节点包进一棵最小 document。 */
function doc(...children: AnyNode[]): AnyNode {
  return { id: '0:0', name: 'Document', type: 'DOCUMENT', children }
}

/** 照真形态造一个**自画** dialog（违例侧）。 */
function selfDrawnDialog(id: string, h = 182): AnyNode {
  return node('Notification', 'FRAME', {
    id,
    absoluteBoundingBox: { width: 480, height: h },
    children: [
      node('Notification_content', 'FRAME', { id: `${id}-c` }),
      node('comfirm', 'FRAME', { id: `${id}-b` }),
    ],
  })
}

/** 照真形态造一个**合规** dialog instance（阴性侧）。 */
function libraryDialog(id: string): AnyNode {
  return node('Notification', 'INSTANCE', {
    id,
    componentId: '1408:17154',
    absoluteBoundingBox: { width: 480, height: 152 },
    children: [
      node('content 1', 'FRAME', { id: `${id}-c` }),
      node('comfirm', 'FRAME', { id: `${id}-b` }),
    ],
  })
}

describe('判定对象 —— 逐字相等，⛔ 不做子串匹配', () => {
  it('判据串点名的两个 set 名逐字就是 Notification / Message', () => {
    expect([...FEEDBACK_NAMES].sort()).toEqual(['Message', 'Notification'])
  })

  it('【正向 A】名为 Notification 而 type=FRAME ⇒ 报 M41/M12-A', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(doc(selfDrawnDialog('4528:3228')))
    expect(scanned).toBe(1)
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M41/M12-A')
    expect(violations[0].nodeId).toBe('4528:3228')
  })

  it('【正向 B】名为 Message 而 type=FRAME ⇒ 报 M41/M12-B（⛔ 不是 A —— M41 只管 dialog）', () => {
    const { violations } = classifyFeedbackSelfDrawn(doc(node('Message', 'FRAME', { id: 'm:1' })))
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M41/M12-B')
    expect(PROBES['M41/M12-B'].sections).toBe('§M12 [2]')
  })

  // ── F-N1 的阴性对照（放宽成 includes ⇒ 下面每一条都会转红）───────────────
  it('【F-N1 对照】Notification_content 的 SLOT ⇒ 不进判定面（真 payload 里 30 处）', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(node('Notification_content', 'SLOT', { id: 's:1' })),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('【F-N1 对照】Notification_content / … row 的 FRAME ⇒ 不进判定面', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(
        node('Notification_content', 'FRAME', { id: 'f:1' }),
        node('Notification_content row', 'FRAME', { id: 'f:2' }),
      ),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('【F-N1 对照】icon/Message/* 图标实例 ⇒ 不进判定面（真 payload 里约 668 处，是最大污染源）', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(
        node('icon/Message/Help 1', 'INSTANCE', { id: 'i:1' }),
        node('icon/Message/Success 2', 'INSTANCE', { id: 'i:2' }),
        node('icon/Message/warning 1', 'INSTANCE', { id: 'i:3' }),
      ),
    )
    // ⚠️ 这三个是 INSTANCE ⇒ 放宽后**也不会**变违例；它们咬人的地方是**分母**：
    //    放宽后 scanned 会从 0 变 3 ⇒ 这一行是「防假分母」的那颗牙。
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('【F-N1 对照】Notification Settings 是功能名不是反馈组件 ⇒ 不进判定面', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(node('Notification Settings', 'FRAME', { id: 'ns:1' })),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('【F-N1 对照】连线 VECTOR 的图层名 Notification --> Flow_text ⇒ 不进判定面', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(
        node('Notification --> Flow_text', 'VECTOR', { id: 'v:1' }),
        node('Button/dark M --> Notification', 'VECTOR', { id: 'v:2' }),
      ),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('【F-N1 对照】整段规格散文当图层名的 TEXT ⇒ 不进判定面（真 payload 里 23 处）', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(
        node(
          'Rules: Notification warning variant · can’t reduce below assigned · M23.9 frame-外 + connector.',
          'TEXT',
          { id: 't:1' },
        ),
        node('Messages', 'TEXT', { id: 't:2' }),
        node('Message body　正文', 'TEXT', { id: 't:3' }),
      ),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('name 两端空白被 trim ⇒ 仍命中（⛔ 不靠调用方先清理）', () => {
    const { scanned, violations } = classifyFeedbackSelfDrawn(
      doc(node('  Notification  ', 'FRAME', { id: 'w:1' })),
    )
    expect(scanned).toBe(1)
    expect(violations).toHaveLength(1)
  })
})

describe('豁免集 USAGE_TYPES —— ⛔ 必须两侧都有牙', () => {
  it('豁免集逐字是 INSTANCE / COMPONENT / COMPONENT_SET', () => {
    expect([...USAGE_TYPES].sort()).toEqual(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
  })

  it('INSTANCE ⇒ 不报（合规侧）', () => {
    const { violations, scanned, byType } = classifyFeedbackSelfDrawn(doc(libraryDialog('7355:2501')))
    expect(scanned).toBe(1)
    expect(violations).toHaveLength(0)
    expect(byType).toEqual({ INSTANCE: 1 })
  })

  it('【F-N2 豁免侧】名为 Notification 的 COMPONENT / COMPONENT_SET 是**定义**不是取用 ⇒ 不报', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(
        node('Notification', 'COMPONENT_SET', { id: '1408:17154' }),
        node('Notification', 'COMPONENT', { id: '1408:17155' }),
      ),
    )
    expect(scanned).toBe(2)
    expect(violations).toHaveLength(0)
  })

  it('【F-N2 双侧】同一棵树里 COMPONENT 不报、FRAME 报 —— ⛔ 钉住非豁免侧', () => {
    // 🔴 这一条是本仓上一轮新立纪律的兑现：只断言「豁免侧为 0」的用例，
    //    拆掉整段豁免照样绿（那是「恒真 ⇒ 零信息」的反向版本，恒绿更难发现）。
    //    ⇒ 必须让**同一次调用**里两侧的结果不同。
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(
        node('Notification', 'COMPONENT_SET', { id: 'def:1' }),
        selfDrawnDialog('use:1'),
      ),
    )
    expect(scanned).toBe(2)
    expect(violations.map((v) => v.nodeId)).toEqual(['use:1'])
  })
})

describe('分母 —— scanned === 0 ⇒ ⛔ 不可读成「通过」', () => {
  it('树里没有反馈组件 ⇒ scanned 0（模块据此报 unverified）', () => {
    const { violations, scanned } = classifyFeedbackSelfDrawn(
      doc(node('Table', 'FRAME', { id: 'x:1' }), node('Button/dark L', 'INSTANCE', { id: 'x:2' })),
    )
    expect(scanned).toBe(0)
    expect(violations).toHaveLength(0)
  })

  it('scanned 数的是**全部**逐字命中（含合规的），⛔ 不是只数违例', () => {
    const { violations, scanned, byType } = classifyFeedbackSelfDrawn(
      doc(selfDrawnDialog('a:1'), libraryDialog('b:1'), libraryDialog('b:2')),
    )
    expect(scanned).toBe(3)
    expect(violations).toHaveLength(1)
    expect(byType).toEqual({ FRAME: 1, INSTANCE: 2 })
  })

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

describe('报文 —— 逐条说清是哪一段的哪一条', () => {
  it('why 里带 type 与祖先路径（reader 要能在 Figma 里找到它）', () => {
    const tree = doc(
      node('Video Sync — Multi-Session Monitoring & Sync Status 20260519', 'CANVAS', {
        id: 'p:1',
        children: [
          node('M9 · Remove Confirmation — M7 base + Notification dialog overlay', 'FRAME', {
            id: 'p:2',
            children: [selfDrawnDialog('4598:4676')],
          }),
        ],
      }),
    )
    const { violations } = classifyFeedbackSelfDrawn(tree)
    expect(violations).toHaveLength(1)
    expect(violations[0].why).toContain('`FRAME`')
    expect(violations[0].why).toContain('M9 · Remove Confirmation')
    expect(violations[0].why).toContain('§M41 [3] + §M12 [2]')
  })

  it('两条 probe 的 hint 各自指向对的改法（⛔ 别把 Message 的改法写成插 Notification）', () => {
    expect(PROBES['M41/M12-A'].hint).toContain('TVU 库的 `Notification` instance')
    expect(PROBES['M41/M12-B'].hint).toContain('`Message` set 的 instance')
    // §M12 逐字：需按钮就升级到 Notification，⛔ 不是自画一个
    expect(PROBES['M41/M12-B'].hint).toContain('升级到带按钮的 `Notification`')
  })
})

describe('真形态回归 —— 照 2026-09-08 那次本机读数的形态', () => {
  it('三个自画 dialog（480×161 / 480×182 ×2）⇒ 恰好 3 条 A，零误伤', () => {
    const tree = doc(
      node('backdrop', 'FRAME', {
        id: 'bd:1',
        children: [
          node('variants-row', 'FRAME', {
            id: 'vr:1',
            children: [selfDrawnDialog('4528:3228', 161), selfDrawnDialog('4528:3240', 182)],
          }),
        ],
      }),
      selfDrawnDialog('4598:4676', 182),
    )
    const { violations, scanned } = classifyFeedbackSelfDrawn(tree)
    expect(scanned).toBe(3)
    expect(violations).toHaveLength(3)
    expect(violations.every((v) => v.probe === 'M41/M12-A')).toBe(true)
    expect(violations.map((v) => v.nodeId).sort()).toEqual(['4528:3228', '4528:3240', '4598:4676'])
  })

  it('⚠️ variants 区**不豁免** —— M41 给 variants 区的义务是 [4] 标注，⛔ 不是「可以自画」', () => {
    const tree = doc(
      node('variant-V1 · OFF session', 'FRAME', {
        id: 'v1',
        children: [node('backdrop', 'FRAME', { id: 'bd', children: [selfDrawnDialog('4528:3228')] })],
      }),
    )
    const { violations } = classifyFeedbackSelfDrawn(tree)
    expect(violations).toHaveLength(1)
    expect(violations[0].why).toContain('backdrop')
  })

  it('55 个 Message instance 的形态 ⇒ 零违例（B 侧今日就是这个读数）', () => {
    const tree = doc(
      ...Array.from({ length: 5 }, (_, i) =>
        node('Message', 'INSTANCE', {
          id: `6415:${180 + i}`,
          componentId: 'msg:set',
          children: [node('Frame 2502', 'FRAME', { id: `c${i}` }), node('Content', 'SLOT', { id: `s${i}` })],
        }),
      ),
    )
    const { violations, scanned } = classifyFeedbackSelfDrawn(tree)
    expect(scanned).toBe(5)
    expect(violations).toHaveLength(0)
  })
})

describe('collectFeedbackNodes —— 判定面本身', () => {
  it('递归进任意深度，且祖先链只留最近 3 层', () => {
    let cur: AnyNode = selfDrawnDialog('deep:1')
    for (let i = 0; i < 6; i++) cur = node(`L${i}`, 'FRAME', { id: `l${i}`, children: [cur] })
    const hits = collectFeedbackNodes(doc(cur))
    expect(hits).toHaveLength(1)
    expect(hits[0].ancestors).toHaveLength(3)
    // 包裹顺序：i=0 最内 ⇒ 祖先链 Document › L5 › … › L0 › Notification ⇒ 最近 3 层是 L2/L1/L0
    expect(hits[0].ancestors).toEqual(['L2', 'L1', 'L0'])
  })

  it('name 缺失 / 非字符串不炸，也不进判定面', () => {
    const hits = collectFeedbackNodes(
      doc({ id: 'n:1', type: 'FRAME' }, { id: 'n:2', type: 'FRAME', name: 42 } as AnyNode),
    )
    expect(hits).toHaveLength(0)
  })
})
