#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""真浏览器验收：翻页交互 / 演讲者视图 / 沙盒三场景 / 零落盘 / SVG / 溢出 / 敏感词 / 手机宽度。"""
import io
import json
import os
import re
import subprocess
import sys
import time
import urllib.parse
from playwright.sync_api import sync_playwright

W = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
DECK = os.path.join(W, '培训与上手', '培训', '价格谈判培训_攻心为上_deck.html')
NAME = os.path.basename(DECK)
PORT = 8791
BASE = 'http://127.0.0.1:%d/' % PORT
URL = BASE + urllib.parse.quote(NAME)
OUT = os.path.join(W, '培训与上手', '培训', '_deck_verify.json')

ok = True


def say(label, val, good):
    global ok
    ok = ok and good
    print('%s %-44s %s' % ('✅' if good else '❌', label, val))


srv = subprocess.Popen([sys.executable, '-m', 'http.server', str(PORT),
                        '--directory', os.path.join(W, '培训与上手', '培训')],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1.5)

INIT = """
window.__probe = {fetch:0, xhr:0, beacon:0, lsSet:0, ssSet:0, cookieSet:0};
try{ var _f = window.fetch; window.fetch = function(){ window.__probe.fetch++; return _f.apply(this, arguments); }; }catch(e){}
try{ var _o = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(){ window.__probe.xhr++; return _o.apply(this, arguments); }; }catch(e){}
try{ var _b = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
  if(_b) navigator.sendBeacon = function(){ window.__probe.beacon++; return _b.apply(navigator, arguments); }; }catch(e){}
try{ var _ls = Storage.prototype.setItem; Storage.prototype.setItem = function(){ window.__probe.lsSet++; return _ls.apply(this, arguments); }; }catch(e){}
try{ var _ss = window.sessionStorage.setItem; Storage.prototype.removeItem = Storage.prototype.removeItem; }catch(e){}
try{ var d = Object.getOwnPropertyDescriptor(Document.prototype,'cookie');
  Object.defineProperty(document,'cookie',{get:function(){return d.get.call(document);},
    set:function(v){ window.__probe.cookieSet++; return d.set.call(document,v); }}); }catch(e){}
"""

res = {}
with sync_playwright() as p:
    br = p.chromium.launch(channel='chrome', headless=True)
    pg = br.new_page(viewport={'width': 1500, 'height': 900})
    pg.add_init_script(INIT)
    pg.goto(URL)
    pg.wait_for_load_state('load')
    pg.wait_for_timeout(700)

    N = pg.evaluate("document.querySelectorAll('#track > .slide').length")
    say('页面数', N, N == 35)
    res['pages'] = N

    def state():
        return pg.evaluate("""(() => {
          const t = document.getElementById('track');
          const m = /translateX\\((-?[\\d.]+)px\\)/.exec(getComputedStyle(t).transform === 'none'
              ? t.style.transform : t.style.transform);
          return {pager: document.getElementById('pager').textContent.trim(),
                  title: document.getElementById('curtitle').textContent.trim(),
                  idx: window.__idx()};
        })()""")

    # ── ① 键盘翻页 ───────────────────────────────────────────
    def press(k):
        pg.keyboard.press(k)
        pg.wait_for_timeout(120)
        return state()

    s = state()
    say('初始状态', s, s['idx'] == 0 and s['pager'].startswith('01 / 35'))
    st = press('ArrowRight'); say('→ 下一页', st, st['idx'] == 1 and st['pager'].startswith('02'))
    st = press('ArrowRight'); say('→ 再下一页', st, st['idx'] == 2)
    st = press('ArrowLeft');  say('← 上一页', st, st['idx'] == 1)
    st = press(' ');          say('空格 下一页', st, st['idx'] == 2)
    st = press('End');        say('End 末页', st, st['idx'] == 34 and st['pager'].startswith('35'))
    st = press('Home');       say('Home 首页', st, st['idx'] == 0 and st['pager'].startswith('01'))
    press('ArrowLeft'); st = state()
    say('首页再按 ← 不越界', st['idx'], st['idx'] == 0)
    st = press('End'); press('ArrowRight'); st = state()
    say('末页再按 → 不越界', st['idx'], st['idx'] == 34)
    st = press('PageDown'); say('PageDown 生效', st['idx'], st['idx'] == 34)
    press('Home')
    st = press('ArrowDown'); say('↓ 生效', st['idx'], st['idx'] == 1)
    press('Home')

    # ── ② 滚轮翻页 ───────────────────────────────────────────
    pg.mouse.move(750, 450)
    pg.mouse.wheel(0, 320); pg.wait_for_timeout(650)
    a = state()['idx']
    pg.mouse.wheel(0, -320); pg.wait_for_timeout(650)
    b = state()['idx']
    say('滚轮下/上翻页', '%d → %d' % (a, b), a == 1 and b == 0)

    # ── ③ 目录 ──────────────────────────────────────────────
    pg.keyboard.press('o'); pg.wait_for_timeout(260)
    vis = pg.evaluate("document.getElementById('toc').classList.contains('on')")
    cnt = pg.evaluate("document.querySelectorAll('#toc .ti').length")
    say('O 打开目录', 'on=%s / %d 项' % (vis, cnt), vis and cnt == 35)
    pg.evaluate("document.querySelectorAll('#toc .ti')[30].click()")
    pg.wait_for_timeout(320)
    st = state()
    say('目录跳页到第 31 页并关闭', st, st['idx'] == 30 and not pg.evaluate(
        "document.getElementById('toc').classList.contains('on')"))
    pg.keyboard.press('o'); pg.wait_for_timeout(200)
    pg.keyboard.press('Escape'); pg.wait_for_timeout(200)
    say('Esc 关闭目录', pg.evaluate("document.getElementById('toc').classList.contains('on')"), True)
    pg.keyboard.press('Home'); pg.wait_for_timeout(200)

    # ── ④ 演讲者视图 ─────────────────────────────────────────
    pg.keyboard.press('s'); pg.wait_for_timeout(420)
    pr = pg.evaluate("""(() => {
      const on = document.getElementById('pres').classList.contains('on');
      const cur = document.querySelector('#pv-cur .pv-inner .slide');
      const nxt = document.querySelector('#pv-next .pv-inner .slide');
      return {on: on,
        curTitle: cur ? cur.getAttribute('data-title') : null,
        nextTitle: nxt ? nxt.getAttribute('data-title') : null,
        curW: cur ? cur.getBoundingClientRect().width : 0,
        notes: (document.getElementById('pv-talk').textContent || '').trim().length,
        title: document.getElementById('pv-title').textContent.trim(),
        purpose: document.getElementById('pv-purpose').textContent.trim().length,
        trans: document.getElementById('pv-trans').textContent.trim().length,
        meta: document.getElementById('pv-meta').textContent.trim(),
        clock: document.getElementById('presclock').textContent.trim()};
    })()""")
    say('S 打开演讲者视图', pr['on'], pr['on'])
    say('  当前页预览 = 第 1 页', pr['curTitle'], pr['curTitle'] == '价格谈判培训 · 攻心为上')
    say('  下一页预览 = 第 2 页', pr['nextTitle'], pr['nextTitle'] == '目录 · 今天走这五步')
    say('  预览已缩放挂载(px)', pr['curW'], pr['curW'] > 100)
    say('  备注/目的/转场非空', 'notes=%d purpose=%d trans=%d' % (
        pr['notes'], pr['purpose'], pr['trans']), pr['notes'] > 5 and pr['purpose'] > 3 and pr['trans'] > 3)
    say('  元信息含页码/章节/时长', pr['meta'], '第 1 / 35 页' in pr['meta'] and '分钟' in pr['meta'])
    say('  计时器存在', pr['clock'], bool(re.match(r'^\d\d:\d\d$', pr['clock'])))
    # 计时器跑一下
    pg.click('#t-start'); pg.wait_for_timeout(1300); pg.click('#t-start')
    c2 = pg.evaluate("document.getElementById('presclock').textContent.trim()")
    say('  计时器可计时', c2, c2 != '00:00')
    pg.click('#t-reset'); pg.wait_for_timeout(150)
    say('  计时器可重置', pg.evaluate("document.getElementById('presclock').textContent.trim()"),
        pg.evaluate("document.getElementById('presclock').textContent.trim()") == '00:00')
    # 翻页时预览跟随
    pg.keyboard.press('ArrowRight'); pg.wait_for_timeout(400)
    pr2 = pg.evaluate("document.querySelector('#pv-cur .pv-inner .slide').getAttribute('data-title')")
    say('  翻页后当前页预览跟随', pr2, pr2 == '目录 · 今天走这五步')
    pg.keyboard.press('s'); pg.wait_for_timeout(300)
    say('S 关闭演讲者视图', pg.evaluate("document.getElementById('pres').classList.contains('on')"), True)
    pg.keyboard.press('Home'); pg.wait_for_timeout(200)

    # ── ④b 观众屏同步 ────────────────────────────────────────
    pg.evaluate("window.__go(3)"); pg.wait_for_timeout(300)
    with pg.context.expect_page(timeout=8000) as pi:
        pg.click('#nav-aud')
    aud = pi.value
    aud.wait_for_load_state('load')
    aud.wait_for_timeout(900)
    isA = aud.evaluate("document.body.classList.contains('audience')")
    say('观众屏打开并进入观众模式', isA, isA)
    pg.evaluate("window.__go(5)"); pg.wait_for_timeout(800)
    aidx = aud.evaluate("""(() => { const t = document.getElementById('track');
        const m = /translateX\\((-?[\\d.]+)px\\)/.exec(t.style.transform || '');
        return m ? Math.round(Math.abs(parseFloat(m[1])) / 1280) : -1; })()""")
    say('观众屏跟随演讲者翻页', aidx, aidx == 5)
    aud.close(); pg.wait_for_timeout(250)
    pg.keyboard.press('Home'); pg.wait_for_timeout(200)

    # ── ⑤ 沙盒三场景 ─────────────────────────────────────────
    pg.evaluate("window.__go(30)")
    pg.wait_for_timeout(400)
    scenes = pg.evaluate("window.__sb.SC.length")
    say('沙盒场景数', scenes, scenes == 3)
    sb_report = []
    for si in range(3):
        pg.evaluate("window.__sb.switchTo(%d)" % si)
        pg.wait_for_timeout(150)
        rounds = pg.evaluate("window.__sb.SC[%d].rounds.length" % si)
        picks = 0
        for r in range(rounds):
            n_opt = pg.evaluate("document.querySelectorAll('#sb-stage .opt').length")
            assert n_opt >= 2, 'scene %d round %d 无选项' % (si, r)
            pg.evaluate("document.querySelectorAll('#sb-stage .opt')[1].click()")
            pg.wait_for_timeout(140)
            picks += 1
        dom = pg.evaluate("""(() => ({
            msgs: document.querySelectorAll('#sb-stage .msg').length,
            fbs: document.querySelectorAll('#sb-stage .fb').length,
            good: document.querySelectorAll('#sb-stage .fb.good').length,
            refs: Array.from(document.querySelectorAll('#sb-stage .ref')).map(e=>e.textContent),
            sum: (document.querySelector('#sb-stage .sumcard')||{textContent:''}).textContent.trim().length,
            cnt: document.getElementById('sb-cnt').textContent.trim(),
            tabs: document.querySelectorAll('#sb-tabs .sb-tab').length
        }))()""")
        sb_report.append(dict(scene=si + 1, rounds=rounds, picks=picks, **dom))
        say('沙盒场景 %d 走完 %d 轮' % (si + 1, rounds),
            'msgs=%d fbs=%d sum=%d cnt=%s' % (dom['msgs'], dom['fbs'], dom['sum'], dom['cnt']),
            dom['fbs'] == rounds and dom['sum'] > 20 and all(
                '看点' in x for x in dom['refs']))
    res['sandbox'] = sb_report
    # 选错也应有反馈
    pg.evaluate("window.__sb.switchTo(0)")
    pg.wait_for_timeout(120)
    pg.evaluate("document.querySelectorAll('#sb-stage .opt')[0].click()")
    pg.wait_for_timeout(150)
    bad = pg.evaluate("""(() => { const e = document.querySelector('#sb-stage .fb.bad');
        return e ? e.textContent.trim().length : 0; })()""")
    say('沙盒选错 → 有反馈', bad, bad > 10)
    # 重置
    pg.click('#sb-reset'); pg.wait_for_timeout(150)
    say('沙盒重来', pg.evaluate("document.getElementById('sb-cnt').textContent.trim()"),
        pg.evaluate("document.querySelectorAll('#sb-stage .msg').length") == 1)
    res['sandbox_bad_fb'] = bad

    # ── ⑥ 零落盘 / 零请求 ────────────────────────────────────
    probe = pg.evaluate("window.__probe")
    st_ls = pg.evaluate("(() => { try { return JSON.stringify(Object.keys(localStorage)); } catch(e){ return 'unavailable'; } })()")
    st_ss = pg.evaluate("(() => { try { return JSON.stringify(Object.keys(sessionStorage)); } catch(e){ return 'unavailable'; } })()")
    ck = pg.evaluate("document.cookie")
    say('沙盒交互后 fetch/XHR/beacon', probe, probe['fetch'] == 0 and probe['xhr'] == 0 and probe['beacon'] == 0)
    say('localStorage 写入', '%s  keys=%s' % (probe['lsSet'], st_ls), probe['lsSet'] == 0 and st_ls in ('[]', 'unavailable'))
    say('sessionStorage 写入', '%s  keys=%s' % (probe['ssSet'], st_ss), probe['ssSet'] == 0 and st_ss in ('[]', 'unavailable'))
    say('cookie 写入', '%s / "%s"' % (probe['cookieSet'], ck), probe['cookieSet'] == 0 and ck == '')
    res['probe'] = probe

    # ── ⑦ 速查卡搜索（含输入法保护）──────────────────────────
    pg.evaluate("window.__go(31)"); pg.wait_for_timeout(300)
    pg.fill('#qk-search', '折中'); pg.wait_for_timeout(200)
    n1 = pg.evaluate("Array.from(document.querySelectorAll('#qk-cards .qc')).filter(e=>e.style.display!=='none').length")
    hint1 = pg.evaluate("document.getElementById('qk-count').textContent.trim()")
    pg.fill('#qk-search', '不存在的词xyz'); pg.wait_for_timeout(200)
    n2 = pg.evaluate("Array.from(document.querySelectorAll('#qk-cards .qc')).filter(e=>e.style.display!=='none').length")
    empty = pg.evaluate("document.getElementById('qk-empty').style.display")
    pg.fill('#qk-search', ''); pg.wait_for_timeout(200)
    n3 = pg.evaluate("Array.from(document.querySelectorAll('#qk-cards .qc')).filter(e=>e.style.display!=='none').length")
    say('速查卡筛选「折中」', '%d 张 (%s)' % (n1, hint1), 1 <= n1 < 8)
    say('速查卡无结果态', '%d 张 empty=%s' % (n2, empty), n2 == 0 and empty != 'none')
    say('清空恢复全部', n3, n3 == 8)
    # 输入法合成期不重渲染
    pg.click('#qk-search')
    pg.evaluate("document.getElementById('qk-search').value='折'")
    pg.evaluate("document.dispatchEvent(new CompositionEvent('compositionstart'))")
    pg.evaluate("document.getElementById('qk-search').dispatchEvent(new Event('input'))")
    pg.wait_for_timeout(150)
    during = pg.evaluate("Array.from(document.querySelectorAll('#qk-cards .qc')).filter(e=>e.style.display!=='none').length")
    pg.evaluate("document.dispatchEvent(new CompositionEvent('compositionend'))")
    pg.evaluate("document.getElementById('qk-search').dispatchEvent(new Event('input'))")
    pg.wait_for_timeout(150)
    after = pg.evaluate("Array.from(document.querySelectorAll('#qk-cards .qc')).filter(e=>e.style.display!=='none').length")
    say('输入法合成期不重渲染', '合成中=%d 结束后=%d' % (during, after), during == 8 and after < 8)
    res['search'] = [n1, n2, n3, during, after]

    # 自检清单打勾
    pg.evaluate("window.__go(32)"); pg.wait_for_timeout(250)
    pg.evaluate("document.querySelectorAll('#chk-list .ci')[0].click(); document.querySelectorAll('#chk-list .ci')[4].click()")
    pg.wait_for_timeout(150)
    onN = pg.evaluate("document.querySelectorAll('#chk-list .ci.on').length")
    say('自检清单可打勾', onN, onN == 2)

    # ── ⑧ SVG 几何 + 文字溢出 ────────────────────────────────
    svg = pg.evaluate("""(() => {
      const out = [];
      document.querySelectorAll('#track svg.sv').forEach((svg, i) => {
        const vb = svg.viewBox.baseVal;
        const rects = Array.from(svg.querySelectorAll('rect')).map(r => ({
          x: +r.x.baseVal.value, y: +r.y.baseVal.value,
          w: +r.width.baseVal.value, h: +r.height.baseVal.value}));
        const texts = Array.from(svg.querySelectorAll('text')).map(t => {
          const b = t.getBBox();
          const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
          let host = null;
          rects.forEach(r => { if (cx >= r.x && cx <= r.x + r.w && cy >= r.y - 8 && cy <= r.y + r.h) host = r; });
          return {t: t.textContent, x: +b.x.toFixed(1), y: +b.y.toFixed(1),
                  w: +b.width.toFixed(1), h: +b.height.toFixed(1),
                  inVb: b.x >= -0.5 && b.y >= -0.5 && b.x + b.width <= vb.width + 0.5 && b.y + b.height <= vb.height + 0.5,
                  inHost: host ? (b.x >= host.x + 2 && b.x + b.width <= host.x + host.w - 2
                                  && b.y >= host.y + 2 && b.y + b.height <= host.y + host.h - 2) : null};
        });
        out.push({i: i, vbw: vb.width, vbh: vb.height, rects: rects, texts: texts});
      });
      return out;
    })()""")
    n_svg = len(svg)
    say('图版 SVG 张数(.sv)', n_svg, n_svg == 6)
    bad_vb = [(s['i'], t['t']) for s in svg for t in s['texts'] if not t['inVb']]
    bad_host = [(s['i'], t['t']) for s in svg for t in s['texts'] if t['inHost'] is False]
    say('SVG 文字溢出错(getBBox 出 viewBox)', bad_vb, not bad_vb)
    say('SVG 文字溢出错(出所属方框)', bad_host, not bad_host)
    geom = []
    for s in svg:
        rows = {}
        for r in s['rects']:
            rows.setdefault(r['y'], []).append(r)
        line = ' 图%d(vb %gx%g): ' % (s['i'] + 1, s['vbw'], s['vbh'])
        for y, rs in sorted(rows.items()):
            rs.sort(key=lambda r: r['x'])
            ws = sorted(set(r['w'] for r in rs))
            gaps = sorted(set(round(rs[i + 1]['x'] - (rs[i]['x'] + rs[i]['w']), 2)
                              for i in range(len(rs) - 1)))
            line += ' 行y=%g 宽%s 间距%s |' % (y, ws, gaps or '-')
        geom.append(line)
        print(line)
    # 等宽等间距断言
    eq_ok = True
    for s in svg:
        rows = {}
        for r in s['rects']:
            rows.setdefault(r['y'], []).append(r)
        for y, rs in rows.items():
            rs.sort(key=lambda r: r['x'])
            if len(rs) > 1:
                if len(set(r['w'] for r in rs)) != 1:
                    eq_ok = False
                gaps = set(round(rs[i + 1]['x'] - (rs[i]['x'] + rs[i]['w']), 1) for i in range(len(rs) - 1))
                if len(gaps) != 1:
                    eq_ok = False
    say('方框等宽等间距（同一行内）', eq_ok, eq_ok)
    res['svg'] = {'n': n_svg, 'bad_vb': bad_vb, 'bad_host': bad_host}

    # ── ⑧b 案例插画：base64 内嵌 / 不压文字 / 未被裁切 ─────
    ill = pg.evaluate("""(() => {
      const out = [];
      document.querySelectorAll('#track figure.illus').forEach((f, i) => {
        const img = f.querySelector('img');
        const slide = f.closest('.slide');
        const ir = img.getBoundingClientRect();
        const hits = [];
        slide.querySelectorAll('.pad h1,.pad h2,.pad p,.pad li,.pad b,.pad blockquote,'
          + '.pad .ct,.pad .cd,.pad .kick,.pad .lead,.pad figcaption,.pad span,.pad button')
          .forEach(el => {
            if (el.contains(img) || img.contains(el)) return;
            const r = el.getBoundingClientRect();
            const ox = Math.min(ir.right, r.right) - Math.max(ir.left, r.left);
            const oy = Math.min(ir.bottom, r.bottom) - Math.max(ir.top, r.top);
            if (ox > 2 && oy > 2) hits.push((el.textContent || '').trim().slice(0, 16));
          });
        out.push({sid: slide.getAttribute('data-slide-id'),
                  data: (img.getAttribute('src') || '').indexOf('data:') === 0,
                  w: Math.round(ir.width), h: Math.round(ir.height), hits: hits});
      });
      return out;
    })()""")
    say('案例插画张数', len(ill), len(ill) == 5)
    say('插画全部 base64 data: 内嵌', [x['sid'] for x in ill], all(x['data'] for x in ill))
    say('插画与文字零重叠', [(x['sid'], x['hits']) for x in ill if x['hits']],
        not any(x['hits'] for x in ill))
    say('插画未被裁切(3:2 无裁切)', [(x['w'], x['h']) for x in ill],
        all(abs(x['h'] - x['w'] / 1.5) < 3 for x in ill))
    deco = pg.evaluate("""(() => {
      const bad = [], ds = document.querySelectorAll('#track svg.deco');
      ds.forEach(d => {
        const z = parseInt(getComputedStyle(d).zIndex, 10);
        const pad = d.parentElement.querySelector('.pad');
        const pz = pad ? parseInt(getComputedStyle(pad).zIndex, 10) : 0;
        if (!(z < pz)) bad.push([z, pz]);
      });
      return {n: ds.length, bad: bad};
    })()""")
    say('程序化装饰 SVG 在文字之下(z-index)', 'n=%d bad=%s' % (deco['n'], deco['bad']),
        deco['n'] >= 6 and not deco['bad'])
    res['illus'] = ill

    # ── ⑨ 每页内容不溢出 ────────────────────────────────────
    ov = pg.evaluate("""(() => {
      const bad = [];
      document.querySelectorAll('#track > .slide').forEach((s, i) => {
        const p = s.querySelector('.pad');
        if (!p) { bad.push([i+1, 'no-pad']); return; }
        const over = p.scrollHeight - p.clientHeight;
        const overX = p.scrollWidth - p.clientWidth;
        if (over > 2 || overX > 2) bad.push([i + 1, over, overX]);
      });
      return bad;
    })()""")
    say('每页内容纵向不溢出(>2px)', ov, not ov)
    res['overflow'] = ov

    # ── ⑩ 敏感数据 ──────────────────────────────────────────
    text = pg.evaluate("document.body.innerText")
    names = set()
    try:
        d = json.load(io.open(os.path.join(W, '数据', 'customer_360.json'), encoding='utf-8'))
        for c in d.get('customers', []):
            for k in ('name', 'customer', '客户名称', 'company'):
                if isinstance(c, dict) and c.get(k):
                    names.add(str(c[k]).strip())
        names |= set(d.get('groups', {}).keys()) if isinstance(d.get('groups'), dict) else set()
    except Exception as e:
        print('   customers 读取:', e)
    try:
        d = json.load(io.open(os.path.join(W, '数据', 'mcp_customers.json'), encoding='utf-8'))
        for c in d.get('customers', []):
            for k, v in c.items():
                if isinstance(v, str) and re.search(r'[\u4e00-\u9fff]', v) and 2 <= len(v) <= 30 \
                        and any(w in v for w in ('公司', '集团', '研究', '大学', '医院', '中心', '院', '厂', '生物', '科技', '医学', '检验')):
                    names.add(v.strip())
    except Exception as e:
        print('   mcp_customers 读取:', e)
    try:
        d = json.load(io.open(os.path.join(W, '数据', 'customer_levels.json'), encoding='utf-8'))
        names |= set(d.keys())
    except Exception as e:
        print('   customer_levels 读取:', e)
    names = {n for n in names if n and len(n) >= 2}
    sales = set()
    try:
        d = json.load(io.open(os.path.join(W, '数据', 'user_roles.json'), encoding='utf-8'))
        sales = {x['name'] for x in d if x.get('name')}
    except Exception as e:
        print('   user_roles 读取:', e)
    hit_c = sorted(n for n in names if n in text)
    hit_s = sorted(n for n in sales if n in text)
    say('客户名逐一扫描命中', '%d 个名字库 / 命中 %s' % (len(names), hit_c or 0), not hit_c)
    say('销售员名逐一扫描命中', '%d 个名字库 / 命中 %s' % (len(sales), hit_s or 0), not hit_s)
    money = re.findall(r'\d+(?:\.\d+)?\s*(?:元|万元|万|折|%)', text)
    say('金额/折扣/百分比数字', money, not money)
    res['names'] = {'customers': len(names), 'sales': len(sales), 'hit_c': hit_c, 'hit_s': hit_s,
                    'money': money}

    # ── ⑪ 手机 414px 无横向溢出 ─────────────────────────────
    pg2 = br.new_page(viewport={'width': 414, 'height': 800})
    pg2.goto(URL); pg2.wait_for_timeout(700)
    m = pg2.evaluate("""(() => ({
        sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth,
        bw: document.body.scrollWidth,
        stageW: document.getElementById('stage').getBoundingClientRect().width,
        navW: document.getElementById('nav').getBoundingClientRect().width}))()""")
    say('414px 无横向溢出', m, m['sw'] <= 415 and m['bw'] <= 415)
    pg2.evaluate("window.__go(30)"); pg2.wait_for_timeout(400)
    m2 = pg2.evaluate("""(() => ({sw: document.documentElement.scrollWidth,
        sbW: document.querySelector('.sb').getBoundingClientRect().width}))()""")
    say('414px 沙盒页无横向溢出', m2, m2['sw'] <= 415)
    res['mobile'] = {'home': m, 'sandbox': m2}
    pg2.close()

    br.close()

srv.terminate()
io.open(OUT, 'w', encoding='utf-8').write(json.dumps(res, ensure_ascii=False, indent=1))
print('\n%s' % ('浏览器验收全部通过 ✅' if ok else '存在失败项 ❌'))
sys.exit(0 if ok else 1)
