import { describe, it, expect } from 'vitest'
import {
  compileConfig,
  collectRowGroups,
  collectDividers,
  isDividerCandidate,
  solidPaintKeys,
  modeOf,
  probeR1,
  probeR2,
  probeR3,
  probeKeysFor,
  runProbes,
  NO_PAINT,
} from '../scripts/audit-mockup-geometry-consistency.mjs'
import rule from '../scripts/mockup-rules/geometry-consistency.mjs'

/**
 * CANONICAL-F104 — the `--family rows` half (R1/R2/R3).
 *
 * Closes domain-tvu.md §M17 Acceptance ④「行高与分隔线间距」: G1/G2/G3 implemented
 * check items ①②③ of that same Acceptance line and ④ was never built.
 *
 * Same discipline as the G-probe suite (§M-DISCIPLINE.SCOPE 硬约束 5 ⑤): every
 * probe gets a 双向探针 pair — one construction that MUST report and one that
 * MUST report zero. One direction alone passes for the wrong reason.
 *
 * The reference numbers are REAL measurements taken 2026-08-27 from the three
 * tracked snapshots in `figma-data/mockup/`, so the fixtures encode the actual
 * shapes this family exists to separate:
 *   • compliant row grids   — 40×7 gap 0 / 42×3 gap 0 / 35×9+36 gap 0
 *   • page-level stacks     — 46 / 210 / 36 (nav / content / footer)
 *   • parameter stacks      — 32 / 38 / 37 / 37 at 84 / 60 / 50 / 43 wide
 *   • screen-frame stacks   — 320 / 516 / 788 tall
 *   • the reported symptom  — gaps 16×7 then 34 under one Auto Layout parent
 *
 * ⚠️ Coverage boundary (INFRA-F138): these are CRITERIA tests. `main()` is not
 * exercised — this gate needs a Figma-API stub before a whole-script fixture
 * harness is possible (STATUS: `net+cred`, un-filed investment). Removing the
 * CLI wiring would not turn any of this red. ⛔ Do not read green as "wired".
 */

type Paint = { type: string; visible?: boolean; opacity?: number; color?: { r: number; g: number; b: number; a?: number } }
type N = {
  id: string
  name: string
  type: string
  visible?: boolean
  layoutMode?: string
  itemSpacing?: number
  fills?: Paint[]
  strokes?: Paint[]
  absoluteBoundingBox?: { x: number; y: number; width: number; height: number }
  children?: N[]
}

const bbox = (x: number, y: number, width: number, height: number) => ({ x, y, width, height })

const row = (id: string, name: string, y: number, h: number, w = 456, x = 0): N => ({
  id,
  name,
  type: 'FRAME',
  absoluteBoundingBox: bbox(x, y, w, h),
})

/** A vertical stack laid out with a constant gap. */
const stack = (id: string, name: string, heights: number[], gap = 0, w = 456, extra: Partial<N> = {}): N => {
  const children: N[] = []
  let y = 0
  heights.forEach((h, i) => {
    children.push(row(`${id}:r${i}`, `row ${i}`, y, h, w))
    y += h + gap
  })
  return { id, name, type: 'FRAME', layoutMode: 'VERTICAL', itemSpacing: gap, absoluteBoundingBox: bbox(0, 0, w, y), children, ...extra }
}

const solid = (hex: string, opacity?: number): Paint => ({
  type: 'SOLID',
  ...(opacity === undefined ? {} : { opacity }),
  color: {
    r: parseInt(hex.slice(0, 2), 16) / 255,
    g: parseInt(hex.slice(2, 4), 16) / 255,
    b: parseInt(hex.slice(4, 6), 16) / 255,
  },
})

const divider = (id: string, name: string, y: number, thickness: number, hex: string | null, w = 456): N => ({
  id,
  name,
  type: 'RECTANGLE',
  absoluteBoundingBox: bbox(0, y, w, thickness),
  ...(hex ? { fills: [solid(hex)] } : {}),
})

const cfg = compileConfig()

// ---------------------------------------------------------------- classifier

describe('row classifier — collectRowGroups', () => {
  it('accepts a uniform row grid (the real LCD 42×3 settings rows)', () => {
    const { groups, rejected } = collectRowGroups(stack('g:1', 'Frame 1994', [42, 42, 42]), cfg)
    expect(groups).toHaveLength(1)
    expect(groups[0].heights).toEqual([42, 42, 42])
    expect(groups[0].gaps).toEqual([0, 0])
    expect(Object.values(rejected).every((n) => n === 0)).toBe(true)
  })

  it('accepts the 7-row LCD settings list (40×7, gap 0)', () => {
    const { groups } = collectRowGroups(stack('g:2', 'Frame 1982', Array(7).fill(40)), cfg)
    expect(groups).toHaveLength(1)
    expect(groups[0].rows).toHaveLength(7)
  })

  // 反向探针 ①：页面级三段栈（导航条 / 内容区 / 底栏）—— 实测 LCD `L1.5` / `C0`-`C2`
  it('REJECTS a page-level nav/content/footer stack (46 / 210 / 36)', () => {
    const { groups, rejected } = collectRowGroups(stack('g:3', 'L1.5 - Settings', [46, 210, 36], 8, 480), cfg)
    expect(groups).toHaveLength(0)
    // 众数占比 1/3 = 33% < 0.6
    expect(rejected.modeShareLow).toBe(1)
  })

  // 反向探针 ②：参数堆叠 —— 实测 LCD `video parameter`，子宽 84/60/50/43
  it('REJECTS a parameter stack whose children are not equal width', () => {
    const node: N = {
      id: 'g:4',
      name: 'video parameter',
      type: 'FRAME',
      layoutMode: 'VERTICAL',
      itemSpacing: 12,
      absoluteBoundingBox: bbox(0, 0, 84, 180),
      children: [
        row('g:4:a', 'Live Button', 0, 32, 84),
        row('g:4:b', 'Frame 1987', 44, 38, 60),
        row('g:4:c', 'Frame 1988', 94, 37, 50),
        row('g:4:d', 'Frame 1989', 143, 37, 43),
      ],
    }
    const { groups, rejected } = collectRowGroups(node, cfg)
    expect(groups).toHaveLength(0)
    expect(rejected.notEqualWidth).toBe(1)
  })

  // 反向探针 ③：屏幕帧堆叠 —— 实测 `Home page` 320 / `FB-8203` 516 / `FB-7010` 788
  it('REJECTS a stack of screen frames (320 tall) as too tall to be rows', () => {
    const { groups, rejected } = collectRowGroups(stack('g:5', 'Home page', [320, 320, 320], 103, 480), cfg)
    expect(groups).toHaveLength(0)
    expect(rejected.tooTall).toBe(1)
  })

  // 反向探针 ④：单个巨块 + 若干行 —— 实测 `Frame 19` 635/51/51/51/51（众数 80% 但幅度 12×）
  it('REJECTS a stack with an out-of-scale outlier (635 among 51s)', () => {
    const { groups, rejected } = collectRowGroups(stack('g:6', 'Frame 19', [635, 51, 51, 51, 51], 0, 300), cfg)
    expect(groups).toHaveLength(0)
    expect(rejected.outlier).toBe(1)
    // …and it is NOT silently dropped: the tally is what gets printed.
    expect(Object.values(rejected).reduce((a, b) => a + b, 0)).toBeGreaterThan(0)
  })

  it('REJECTS overlapping children (not a stack at all)', () => {
    const node: N = {
      id: 'g:7',
      name: 'overlay',
      type: 'FRAME',
      absoluteBoundingBox: bbox(0, 0, 456, 80),
      children: [row('g:7:a', 'a', 0, 40), row('g:7:b', 'b', 20, 40)],
    }
    const { groups, rejected } = collectRowGroups(node, cfg)
    expect(groups).toHaveLength(0)
    expect(rejected.notStacked).toBe(1)
  })

  it('excludes dividers from the row population (they sit BETWEEN rows)', () => {
    const node: N = {
      id: 'g:8',
      name: 'list',
      type: 'FRAME',
      layoutMode: 'VERTICAL',
      absoluteBoundingBox: bbox(0, 0, 456, 100),
      children: [
        row('g:8:r1', 'row 1', 0, 40),
        { ...divider('g:8:d', 'Rectangle 2', 40, 1, '252525'), type: 'FRAME' },
        row('g:8:r2', 'row 2', 41, 40),
      ],
    }
    const { groups } = collectRowGroups(node, cfg)
    expect(groups).toHaveLength(1)
    // 2 rows, not 3 — the 1px node is R3's subject, not a row.
    expect(groups[0].rows.map((r: { height: number }) => r.height)).toEqual([40, 40])
  })

  it('skips invisible children', () => {
    const node = stack('g:9', 'x', [40, 40, 40])
    node.children![1].visible = false
    const { groups } = collectRowGroups(node, cfg)
    expect(groups[0].rows).toHaveLength(2)
  })

  it('classifier boundaries are overridable via --config (project material stays out of DS)', () => {
    const wide = compileConfig({ rows: { maxRowHeight: 400 } })
    const { groups } = collectRowGroups(stack('g:10', 'Home page', [320, 320, 320], 103, 480), wide)
    expect(groups).toHaveLength(1)
  })
})

// ------------------------------------------------------------------------ R1

describe('R1 row-height-uniform', () => {
  // 正向：实测 LCD `Format options` overlay —— 9 行 35px + 1 行 36px
  it('reports the real 1px outlier (35×9 + 36)', () => {
    const { groups } = collectRowGroups(stack('r1:1', 'Format options', [...Array(9).fill(35), 36], 0, 240), cfg)
    const f = probeR1(groups, cfg)
    expect(f).toHaveLength(1)
    expect(f[0].modeHeight).toBe(35)
    expect(f[0].heights).toEqual([35, 36])
    expect(f[0].nodes).toHaveLength(1)
    expect(f[0].nodes[0].height).toBe(36)
  })

  // 🔴 回归钉：这条差异恰好是 1px，而 `widthTolerance` 是 1 —— 复用它会把整类缺陷吞掉。
  it('does NOT reuse widthTolerance (1px row drift must still report)', () => {
    expect(cfg.widthTolerance).toBe(1)
    expect(cfg.rows.heightTolerance).toBeLessThan(1)
    const { groups } = collectRowGroups(stack('r1:2', 'grid', [35, 36, 35], 0), cfg)
    expect(probeR1(groups, cfg)).toHaveLength(1)
  })

  // 反向：完全等高必须零报
  it('reports zero on a perfectly uniform grid', () => {
    const { groups } = collectRowGroups(stack('r1:3', 'Frame 1982', Array(7).fill(40), 0), cfg)
    expect(probeR1(groups, cfg)).toHaveLength(0)
  })

  // 反向：亚像素浮点噪声不报
  it('absorbs sub-pixel float noise (40 vs 40.3)', () => {
    const { groups } = collectRowGroups(stack('r1:4', 'grid', [40, 40.3, 40], 0), cfg)
    expect(probeR1(groups, cfg)).toHaveLength(0)
  })
})

// ------------------------------------------------------------------------ R2

describe('R2 row-gap-uniform', () => {
  // 正向：用户报的那一态 —— Auto Layout itemSpacing=16，但最后一段 gap 是 34。
  // 实测 DtZc `Frame 3071`（8 处）。
  it('reports the reported symptom: equal rows, one wider gap (16×7 then 34)', () => {
    const node: N = {
      id: 'r2:1',
      name: 'Frame 3071',
      type: 'FRAME',
      layoutMode: 'VERTICAL',
      itemSpacing: 16,
      absoluteBoundingBox: bbox(0, 0, 456, 500),
      children: [],
    }
    let y = 0
    for (let i = 0; i < 9; i++) {
      node.children!.push(row(`r2:1:${i}`, `row ${i}`, y, 32))
      y += 32 + (i === 7 ? 34 : 16)
    }
    const { groups } = collectRowGroups(node, cfg)
    expect(groups).toHaveLength(1)
    const f = probeR2(groups, cfg)
    expect(f).toHaveLength(1)
    expect(f[0].distinctGaps).toEqual([16, 34])
    expect(f[0].itemSpacing).toBe(16)
  })

  // 🔴 R1 结构上看不见这一态 —— 这正是处方要求 R1/R2 分开的理由。
  it('R1 is BLIND to it (rows are all 32) — proving the two probes are not redundant', () => {
    const node: N = {
      id: 'r2:2', name: 'Frame 3071', type: 'FRAME', layoutMode: 'VERTICAL', itemSpacing: 16,
      absoluteBoundingBox: bbox(0, 0, 456, 500), children: [],
    }
    let y = 0
    for (let i = 0; i < 9; i++) { node.children!.push(row(`r2:2:${i}`, `row ${i}`, y, 32)); y += 32 + (i === 7 ? 34 : 16) }
    const { groups } = collectRowGroups(node, cfg)
    expect(probeR1(groups, cfg)).toHaveLength(0)
    expect(probeR2(groups, cfg)).toHaveLength(1)
  })

  // …and the mirror case: unequal rows, equal gaps → R2 blind, R1 reports.
  it('R2 is BLIND to unequal rows with equal gaps (the mirror half)', () => {
    const { groups } = collectRowGroups(stack('r2:3', 'grid', [40, 44, 40], 8), cfg)
    expect(probeR2(groups, cfg)).toHaveLength(0)
    expect(probeR1(groups, cfg)).toHaveLength(1)
  })

  // 正向：绝对定位下的亚像素 + 一个大间隙（实测 TPC `Menu/2` 12.3/12.4/18.4）
  it('reports absolute-positioned drift (12.3 / 12.4 / 18.4)', () => {
    const node: N = {
      id: 'r2:4', name: 'Menu/2', type: 'FRAME', absoluteBoundingBox: bbox(0, 0, 200, 220),
      children: [
        row('r2:4:a', 'a', 0, 40, 200), row('r2:4:b', 'b', 52.3, 40, 200),
        row('r2:4:c', 'c', 104.7, 40, 200), row('r2:4:d', 'd', 163.1, 40, 200),
      ],
    }
    const { groups } = collectRowGroups(node, cfg)
    const f = probeR2(groups, cfg)
    expect(f).toHaveLength(1)
    expect(f[0].layoutMode).toBeNull()
  })

  // 反向：gap 全等（含 gap=0 的 LCD 常态）必须零报
  it('reports zero when every gap is equal (gap 0 — the LCD norm)', () => {
    const { groups } = collectRowGroups(stack('r2:5', 'Frame 1994', [42, 42, 42], 0), cfg)
    expect(probeR2(groups, cfg)).toHaveLength(0)
  })

  it('reports zero when every gap is equal and non-zero (gap 8)', () => {
    const { groups } = collectRowGroups(stack('r2:6', 'grid', [40, 40, 40], 8), cfg)
    expect(probeR2(groups, cfg)).toHaveLength(0)
  })
})

// ------------------------------------------------------------------------ R3

describe('R3 divider-consistency', () => {
  it('isDividerCandidate: thickness, aspect ratio and visibility all gate', () => {
    expect(isDividerCandidate(divider('d:1', 'r', 0, 1, '252525'), cfg)).toBe(true)
    // too thick
    expect(isDividerCandidate(divider('d:2', 'r', 0, 6, '252525'), cfg)).toBe(false)
    // too stubby (a 2x2 dot is not a divider)
    expect(isDividerCandidate(divider('d:3', 'r', 0, 2, '252525', 4), cfg)).toBe(false)
    // 🔴 invisible paint — 实测 36 个这样的节点污染了 LCD 的分隔线分母
    expect(isDividerCandidate(divider('d:4', 'r', 0, 1, null), cfg)).toBe(false)
  })

  it('solidPaintKeys composites the WHOLE stack, never fills[0]', () => {
    const node = {
      name: 'x',
      fills: [solid('000000'), { ...solid('ffffff'), visible: false }, solid('252525')],
      strokes: [solid('444444')],
    }
    const key = solidPaintKeys(node)
    expect(key).toContain('#000000+#252525')
    expect(key).toContain('stroke:#444444')
    expect(key).not.toContain('#ffffff')
  })

  it('solidPaintKeys reports NO_PAINT for an unpainted node', () => {
    expect(solidPaintKeys({ name: 'x' })).toBe(NO_PAINT)
  })

  // 正向：同一行栅格里两种颜色（实测 DtZc `2|#2fb54e` vs `2|#7b7b7b`）
  it('reports two divider colours inside ONE row grid', () => {
    const group = { id: 'g', name: 'session-list' }
    const dividers = [
      { id: 'a', name: 'line', thickness: 2, colors: 'fill:#2fb54e' },
      { id: 'b', name: 'line', thickness: 2, colors: 'fill:#7b7b7b' },
      { id: 'c', name: 'line', thickness: 2, colors: 'fill:#7b7b7b' },
    ]
    const f = probeR3([{ group, dividers }], cfg)
    expect(f).toHaveLength(1)
    expect(f[0].distinctColours).toHaveLength(2)
    expect(f[0].label).toBe('session-list')
  })

  it('reports differing thickness at equal colour', () => {
    const group = { id: 'g', name: 'grid' }
    const dividers = [
      { id: 'a', name: 'line', thickness: 1, colors: 'fill:#252525' },
      { id: 'b', name: 'line', thickness: 2, colors: 'fill:#252525' },
    ]
    expect(probeR3([{ group, dividers }], cfg)).toHaveLength(1)
  })

  // 反向：15 条同厚同色（实测 LCD `RECTANGLE h=0.9 #252525` ×15）必须零报
  it('reports zero for 15 identical dividers', () => {
    const group = { id: 'g', name: 'Content' }
    const dividers = Array.from({ length: 15 }, (_, i) => ({
      id: `d${i}`, name: 'Rectangle 2', thickness: 0.9, colors: 'fill:#252525',
    }))
    expect(probeR3([{ group, dividers }], cfg)).toHaveLength(0)
  })

  // 反向：同色 + 亚像素抖动是取整，不是设计分叉
  it('absorbs sub-pixel thickness jitter at one colour (0.9 vs 1.0)', () => {
    const group = { id: 'g', name: 'grid' }
    const dividers = [
      { id: 'a', name: 'l', thickness: 0.9, colors: 'fill:#252525' },
      { id: 'b', name: 'l', thickness: 1.0, colors: 'fill:#252525' },
    ]
    expect(probeR3([{ group, dividers }], cfg)).toHaveLength(0)
  })

  // 反向：不同行栅格之间的差异不是缺陷（M17 ④ scope =「同一设置页」）
  it('does NOT compare dividers ACROSS row grids', () => {
    const f = probeR3([
      { group: { id: 'g1', name: 'screen A' }, dividers: [
        { id: 'a', name: 'l', thickness: 1, colors: 'fill:#252525' },
        { id: 'b', name: 'l', thickness: 1, colors: 'fill:#252525' }] },
      { group: { id: 'g2', name: 'screen B' }, dividers: [
        { id: 'c', name: 'l', thickness: 2, colors: 'fill:#666666' },
        { id: 'd', name: 'l', thickness: 2, colors: 'fill:#666666' }] },
    ], cfg)
    expect(f).toHaveLength(0)
  })

  it('ignores `_`-prefixed annotation lines (the LCD _connector case)', () => {
    const grid: N = {
      id: 'g:1', name: 'grid', type: 'FRAME', layoutMode: 'VERTICAL',
      absoluteBoundingBox: bbox(0, 0, 456, 100),
      children: [
        divider('d:1', 'Rectangle 2', 0, 1, '252525'),
        divider('d:2', '_connector-line-h', 10, 1.5, '33a4fd'),
      ],
    }
    const found = collectDividers(grid, cfg, [grid])
    expect(found.map((d: { name: string }) => d.name)).toEqual(['Rectangle 2'])
  })
})

// ------------------------------------------------------- family + fail-closed

describe('family selection & fail-closed denominator', () => {
  it('probeKeysFor maps the three families', () => {
    expect(probeKeysFor()).toEqual(['G1', 'G2', 'G3'])
    expect(probeKeysFor('rows')).toEqual(['R1', 'R2', 'R3'])
    expect(probeKeysFor('all')).toEqual(['G1', 'G2', 'G3', 'R1', 'R2', 'R3'])
  })

  it('the controls family stays byte-identical (no row counters leak in)', () => {
    const res = runProbes(stack('x', 'grid', [40, 40], 0), cfg, new Map())
    expect(Object.keys(res.scanned).sort()).toEqual(['buttons', 'controls'])
    expect(res).not.toHaveProperty('rowsRejected')
    expect(Object.keys(res.findings).sort()).toEqual(['G1', 'G2', 'G3'])
  })

  // 上一条的**对称一侧**（双向夹逼）。上一条单独存在时，「rows 跑法不报 control 计数」
  // 这个方向没有任何断言 —— 而那正是 `controls = wantControls ? … : []` 让
  // `scanned.controls/buttons` **结构上恒 0** 的那一侧：把「从没看」印成「扫了零个」。
  it('the rows family reports NO control counters it never looked at', () => {
    const res = runProbes(stack('x', 'Frame 1994', [42, 42, 42], 0), cfg, new Map(), 'rows')
    // 先证这一臂真的跑了 rows（否则「没有 control 键」可能只是整个 runProbes 没跑）
    expect(res.scanned.rowGroups).toBe(1)
    // must-not-hit：⛔ 不是 0，是**键不存在**
    expect(Object.keys(res.scanned).sort()).toEqual(['dividers', 'rowGroups', 'rows'])
    expect(res.scanned).not.toHaveProperty('controls')
    expect(res.scanned).not.toHaveProperty('buttons')
    // 同一纪律的另一半：controls 侧的 rejected 登记面也不该在 rows 跑法下冒出来
    expect(res).not.toHaveProperty('stateFramesRejected')
  })

  it('family "all" reports BOTH halves\' counters (the negative control for the two above)', () => {
    const res = runProbes(stack('x', 'grid', [40, 44, 40], 0), cfg, new Map(), 'all')
    expect(Object.keys(res.scanned).sort()).toEqual([
      'buttons', 'controls', 'dividers', 'rowGroups', 'rows',
    ])
  })

  it('a rows run with ZERO classified grids exposes an empty denominator', () => {
    // A lone page-level stack: nothing the row classifier accepts.
    const res = runProbes(stack('x', 'L1.5', [46, 210, 36], 8, 480), cfg, new Map(), 'rows')
    expect(res.scanned.rowGroups).toBe(0)
    // ⛔ zero findings here must NOT read as clean — the CLI turns this into exit 2.
    expect(res.findings.R1).toHaveLength(0)
    expect(res.rowsRejected.modeShareLow).toBeGreaterThan(0)
  })

  it('a real grid gives a NON-empty denominator (the negative control)', () => {
    const res = runProbes(stack('x', 'Frame 1994', [42, 42, 42], 0), cfg, new Map(), 'rows')
    expect(res.scanned.rowGroups).toBe(1)
    expect(res.scanned.rows).toBe(3)
  })

  it('family "all" runs both halves', () => {
    const res = runProbes(stack('x', 'grid', [40, 44, 40], 0), cfg, new Map(), 'all')
    expect(Object.keys(res.findings).sort()).toEqual(['G1', 'G2', 'G3', 'R1', 'R2', 'R3'])
    expect(res.findings.R1).toHaveLength(1)
  })

  it('modeOf picks the modal value and its share', () => {
    expect(modeOf([35, 35, 36, 35])).toEqual({ value: 35, count: 3, share: 0.75 })
    expect(modeOf([])).toEqual({ value: null, count: 0, share: 0 })
  })
})

// --------------------------------------------------- the rule-module consumer
//
// `scripts/mockup-rules/geometry-consistency.mjs` reads `scanned.controls` /
// `scanned.buttons` to build BOTH its human-readable line and its denominator.
// Conditioning those keys in `runProbes` moves the ground under it, so the
// denominator regression lives here rather than in the probe suite.
describe('mockup-rules/geometry-consistency — denominator + output must survive', () => {
  const ctxFor = (root: unknown, family: string) => ({
    payload: async () => ({ document: root, components: {}, componentSets: {} }),
    flag: (f: string) => (f === '--geometry-family' ? family : undefined),
    nodeUrl: (id: string) => `figma://${id}`,
  })

  it('rows family: checkedUnits counts rows+dividers only, and the text omits control counters', async () => {
    const res = await rule.run(ctxFor(stack('x', 'Frame 1994', [42, 42, 42], 0), 'rows') as never)
    // no-op 回归：rows 跑法的分母本来就守着（family === 'rows' ? rowUnits : …），
    // 本次是输出面改动 ⇒ 这个数必须逐位不变。3 rows + 0 dividers = 3
    expect(res.checkedUnits).toBe(3)
    expect(res.checkedUnit).toBe('row-or-divider')
    // must-not-hit：⛔ 不许再印 "0 control instance(s) / 0 action button(s)"
    expect(res.text).not.toMatch(/control instance\(s\)/)
    expect(res.extra.scanned).not.toHaveProperty('controls')
  })

  it('controls family: still prints the control counters and counts them (must-hit 对照)', async () => {
    const res = await rule.run(ctxFor(stack('x', 'grid', [40, 40], 0), 'controls') as never)
    expect(res.text).toMatch(/control instance\(s\)/)
    expect(res.extra.scanned).toHaveProperty('controls')
    expect(res.checkedUnit).toBe('control-or-button')
  })
})
