#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""「客户拉通大表 · 经营仪表盘（报告版 · 可拖拽/缩放/自适应）」渲染验收（playwright + 系统 Chrome）。

断言：
  A. 基础：外部请求 = 0；无 console error；SVG 守卫；11 块指标卡 + 5 张 KPI；每卡带「数据来源」
  B. 报告版结构：结论摘要；风险区单独成块且位于明细之前；KPI 阈值色（卡住>0 红框；待关联率>30% 橙框·独立第5卡）
  C. 复算：≥5 个数字从 脚本/ct_data.py 独立复算并与页面显示逐一对齐
  D. 交互：① 拖拽换位 → DOM 顺序真的变化 ② 拖右下角手柄 → 卡片变宽、SVG 宽度值变化（DOM 读）
           ③ localStorage 写入 + 刷新后保持 ④ 「恢复默认布局」后顺序复原
  E. 自适应：把小卡拉到最小档 → 紧凑模式（环形→堆叠条）+ 内容溢出提示
  F. 打印：print 媒体下无拖拽手柄；preparePrint() → 回到默认顺序
  G. index.html / app.html mtime 未变
输出：≥2 张截图
"""
import os
import sys
import json
import re

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, '脚本'))
import ct_data as CT                                     # noqa
PAGE = os.path.join(ROOT, '工具', '客户拉通大表_仪表盘.html')
INDEX = os.path.join(ROOT, '工具', 'index.html')
APP = os.path.join(ROOT, '工具', 'app.html')
SHOT = os.path.join(ROOT, '工具', '仪表盘', '_shots_thread_dash')
os.makedirs(SHOT, exist_ok=True)
USD = CT.USD_CNY

DEFAULT_CHILD_ORDER = ['sec:risk', 'c5', 'c8', 'c7', 'sec:detail', 'c3', 'c6', 'c9', 'c1', 'c4', 'c11', 'c2', 'c10']


def parse_amt(s):
    s = str(s or '')
    usd = s.startswith('US$')
    v = float(re.sub(r'[^0-9.]', '', s) or 0)
    return v * USD if usd else v


def fmt_y(v):
    return '¥' + '{:,}'.format(round(v))


def main():
    mtime_before = {p: os.path.getmtime(p) for p in (INDEX, APP)}
    res = {}
    ext, console = [], []
    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        b = p.chromium.launch(channel='chrome')
        pg = b.new_page(viewport={'width': 1520, 'height': 1200})
        pg.on('request', lambda r: ext.append(r.url) if r.url.startswith(('http://', 'https://', '//')) else None)
        pg.on('console', lambda m: console.append({'type': m.type, 'text': m.text})
              if m.type in ('error', 'warning') else None)
        pg.goto('file://' + PAGE)
        pg.wait_for_timeout(1000)

        res['dash_ok'] = pg.evaluate('()=>window.__dashOK||null')
        res['dash_report'] = pg.evaluate('()=>window.__dashReport||null')
        res['svg_guard_fn'] = pg.evaluate('()=>window.__svgGuard?window.__svgGuard():null')
        res['svg_bad_inner'] = pg.eval_on_selector_all(
            'svg', "els=>els.map(e=>e.innerHTML).filter(h=>/<title[\\\\s>]|<br[\\\\s>]/i.test(h)).length")
        res['card_count'] = pg.eval_on_selector_all('#grid .card', 'els=>els.length')
        res['kpi_count'] = pg.eval_on_selector_all('#kpiRow .kpi', 'els=>els.length')
        res['src_blocks'] = pg.eval_on_selector_all('#grid .card .src', 'els=>els.length')
        res['concl_text'] = pg.eval_on_selector('#concl', 'e=>e.textContent.trim()')
        res['sec_heads'] = pg.eval_on_selector_all('#grid .sec', 'els=>els.map(e=>e.textContent.trim())')
        res['kpi_flag_classes'] = pg.eval_on_selector_all(
            '#kpiRow .kpi', "els=>els.map(e=>e.className.replace('kpi','').trim())")
        res['kpi_flag_tags'] = pg.eval_on_selector_all('#kpiRow .flagtag', 'els=>els.map(e=>e.textContent.trim())')
        res['dom_kpis'] = pg.eval_on_selector_all('#kpiRow .kpi .num', 'els=>els.map(e=>e.textContent.trim())')
        res['dom_kpi_labels'] = pg.eval_on_selector_all('#kpiRow .kpi .lbl', 'els=>els.map(e=>e.textContent.trim())')
        res['grid_cols'] = pg.evaluate(
            "()=>getComputedStyle(document.getElementById('grid')).gridTemplateColumns.split(' ').length")
        res['wrap_w'] = pg.eval_on_selector('.wrap', 'e=>Math.round(e.getBoundingClientRect().width)')
        res['font_sizes'] = pg.evaluate("""()=>{
            const s=new Set();
            document.querySelectorAll('.wrap *').forEach(function(el){
                const fs=getComputedStyle(el).fontSize; if(fs) s.add(fs); });
            return Array.from(s).sort();
        }""")
        res['risk_row_heights'] = pg.eval_on_selector_all('#grid .card.risk', 'els=>els.map(e=>Math.round(e.getBoundingClientRect().height))')
        res['layout_key'] = pg.evaluate('()=>window.__layoutKey')
        res['child_order_default'] = pg.evaluate('()=>window.__childOrder()')
        res['sec_info_t0'] = pg.evaluate('()=>window.__secInfo()')
        res['sec_of_c5'] = pg.evaluate('()=>window.__secOfCard(5)')
        res['sec_of_c3'] = pg.evaluate('()=>window.__secOfCard(3)')
        res['sec_of_c7'] = pg.evaluate('()=>window.__secOfCard(7)')

        pg.screenshot(path=os.path.join(SHOT, 'dash_上屏.png'))
        pg.screenshot(path=os.path.join(SHOT, 'dash_整页.png'), full_page=True)

        # ── D① 拖拽换位：把 c5 拖到 c7 之后 ──────────────────────
        pg.evaluate("""() => {
            const grid=document.getElementById('grid');
            const src=grid.querySelector('.card[data-card="5"]');
            const tgt=grid.querySelector('.card[data-card="7"]');
            const dt=new DataTransfer();
            const r=tgt.getBoundingClientRect();
            src.dispatchEvent(new DragEvent('dragstart',{bubbles:true,dataTransfer:dt}));
            tgt.dispatchEvent(new DragEvent('dragover',{bubbles:true,clientX:r.right-4,clientY:r.top+r.height/2,dataTransfer:dt}));
            tgt.dispatchEvent(new DragEvent('drop',{bubbles:true,dataTransfer:dt}));
            src.dispatchEvent(new DragEvent('dragend',{bubbles:true,dataTransfer:dt}));
        }""")
        pg.wait_for_timeout(200)
        res['child_order_after_drag'] = pg.evaluate('()=>window.__childOrder()')
        res['drag_changed_dom'] = res['child_order_after_drag'] != res['child_order_default']
        res['c5_after_c7'] = (res['child_order_after_drag'].index('c5') > res['child_order_after_drag'].index('c7'))
        res['sec_info_after_same'] = pg.evaluate('()=>window.__secInfo()')

        # ── D①b 跨区块拖拽被拒：c5（风险区）→ c3（明细区）──────────────
        res['order_before_cross'] = pg.evaluate('()=>window.__childOrder()')
        pg.evaluate("""() => {
            const grid=document.getElementById('grid');
            const src=grid.querySelector('.card[data-card="5"]');
            const tgt=grid.querySelector('.card[data-card="3"]');
            const dt=new DataTransfer();
            const r=tgt.getBoundingClientRect();
            src.dispatchEvent(new DragEvent('dragstart',{bubbles:true,dataTransfer:dt}));
            tgt.dispatchEvent(new DragEvent('dragover',{bubbles:true,clientX:r.right-4,clientY:r.top+r.height/2,dataTransfer:dt}));
            tgt.dispatchEvent(new DragEvent('drop',{bubbles:true,dataTransfer:dt}));
            src.dispatchEvent(new DragEvent('dragend',{bubbles:true,dataTransfer:dt}));
        }""")
        pg.wait_for_timeout(200)
        res['order_after_cross'] = pg.evaluate('()=>window.__childOrder()')
        res['cross_rejected'] = (res['order_after_cross'] == res['order_before_cross'])
        res['cross_last_drop'] = pg.evaluate('()=>window.__lastDrop||null')
        res['cross_hint_text'] = pg.evaluate('()=>window.__dragHintText()')
        res['cross_hint_deny'] = pg.evaluate('()=>window.__dragHintDeny()')
        res['sec_info_after_cross'] = pg.evaluate('()=>window.__secInfo()')
        res['sec_stable'] = (res['sec_info_t0'] == res['sec_info_after_same']
                             == res['sec_info_after_cross'])

        # ── D② 拖右下角手柄把 c1 拉宽 → SVG 宽度变化 ──────────────
        res['c1_svg_before'] = pg.evaluate('()=>window.__svgW(1)')
        res['c1_tier_before'] = pg.evaluate('()=>window.__cardTier(1)')
        res['c1_w_before'] = pg.evaluate('()=>window.__cardW(1)')
        pg.evaluate("""() => {
            const h=document.querySelector('#grid .card[data-card="1"] .handle');
            const r=h.getBoundingClientRect();
            const x0=r.left+r.width/2, y0=r.top+r.height/2;
            h.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:x0,clientY:y0}));
            [80,160,240,320].forEach(function(d){
              document.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:x0+d,clientY:y0})); });
            document.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:x0+320,clientY:y0}));
        }""")
        pg.wait_for_timeout(200)
        res['c1_svg_after'] = pg.evaluate('()=>window.__svgW(1)')
        res['c1_tier_after'] = pg.evaluate('()=>window.__cardTier(1)')
        res['c1_w_after'] = pg.evaluate('()=>window.__cardW(1)')
        res['c1_gridcol'] = pg.eval_on_selector('#grid .card[data-card="1"]', 'e=>getComputedStyle(e).gridColumn')

        # ── D③ localStorage 写入 ─────────────────────────────────
        res['ls_value_nonempty'] = bool(pg.evaluate('()=>window.__lsValue()'))
        res['ls_has_tier'] = pg.evaluate("""()=>{ try{ const o=JSON.parse(window.__lsValue()); return o && o.tier && o.tier['1']; }catch(e){ return null; } }""")

        # ── D③b 刷新后保持 ───────────────────────────────────────
        pg.reload()
        pg.wait_for_timeout(900)
        res['child_order_after_reload'] = pg.evaluate('()=>window.__childOrder()')
        res['c1_svg_after_reload'] = pg.evaluate('()=>window.__svgW(1)')
        res['c1_tier_after_reload'] = pg.evaluate('()=>window.__cardTier(1)')
        res['persist_reload_ok'] = (res['child_order_after_reload'] == res['child_order_after_drag']
                                    and res['c1_tier_after_reload'] == res['c1_tier_after'])
        pg.screenshot(path=os.path.join(SHOT, 'dash_拖拽后.png'), full_page=True)

        # ── D④ 恢复默认布局 ─────────────────────────────────────
        pg.click('#btnResetLayout')
        pg.wait_for_timeout(250)
        res['child_order_after_reset'] = pg.evaluate('()=>window.__childOrder()')
        res['reset_ok'] = res['child_order_after_reset'] == DEFAULT_CHILD_ORDER
        res['ls_cleared_after_reset'] = pg.evaluate('()=>window.__lsValue()') is None

        # ── E 自适应：把 c2 拉到最小档 → 紧凑堆叠条；把 c5 拉到最小档 → 溢出提示 ──
        def shrink_to_min(idx):
            pg.evaluate("""(idx) => {
                const h=document.querySelector('#grid .card[data-card="'+idx+'"] .handle');
                const r=h.getBoundingClientRect();
                const x0=r.left+r.width/2, y0=r.top+r.height/2;
                h.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:x0,clientY:y0}));
                document.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:x0-900,clientY:y0}));
                document.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:x0-900,clientY:y0}));
            }""", idx)
        shrink_to_min(2)
        shrink_to_min(5)
        pg.wait_for_timeout(250)
        res['c2_tier'] = pg.evaluate('()=>window.__cardTier(2)')
        res['c2_compact_has_sbar'] = pg.eval_on_selector_all('#grid .card[data-card="2"] .sbar', 'els=>els.length') == 1
        res['c2_compact_no_ring'] = pg.eval_on_selector_all('#grid .card[data-card="2"] .ring', 'els=>els.length') == 0
        res['c2_has_compact_class'] = pg.eval_on_selector('#grid .card[data-card="2"]', "e=>e.classList.contains('compact')")
        res['c5_overflow_flag'] = pg.eval_on_selector('#grid .card[data-card="5"]', "e=>e.classList.contains('has-overflow')")
        el = pg.query_selector('#grid'); el.scroll_into_view_if_needed()
        pg.screenshot(path=os.path.join(SHOT, 'dash_紧凑与溢出.png'), full_page=True)
        pg.click('#btnResetLayout')
        pg.wait_for_timeout(200)

        # ── F 打印：无手柄 + 回默认顺序 ──────────────────────────
        pg.emulate_media(media='print')
        pg.wait_for_timeout(150)
        res['print_handle_display'] = pg.evaluate('()=>window.__handleDisplay()')
        pg.emulate_media(media='screen')
        pg.wait_for_timeout(100)
        res['child_order_prepare_print'] = pg.evaluate('()=>window.__preparePrint()')
        res['print_order_ok'] = res['child_order_prepare_print'] == DEFAULT_CHILD_ORDER
        pg.evaluate('()=>window.__endPrint()')
        pg.wait_for_timeout(120)

        # 原生 beforeprint / afterprint 钩子：真拖拽换位 → beforeprint 回默认 → afterprint 还原
        pg.evaluate("""() => {
            const grid=document.getElementById('grid');
            const src=grid.querySelector('.card[data-card="5"]');
            const tgt=grid.querySelector('.card[data-card="7"]');
            const dt=new DataTransfer();
            const r=tgt.getBoundingClientRect();
            src.dispatchEvent(new DragEvent('dragstart',{bubbles:true,dataTransfer:dt}));
            tgt.dispatchEvent(new DragEvent('dragover',{bubbles:true,clientX:r.right-4,clientY:r.top+r.height/2,dataTransfer:dt}));
            tgt.dispatchEvent(new DragEvent('drop',{bubbles:true,dataTransfer:dt}));
            src.dispatchEvent(new DragEvent('dragend',{bubbles:true,dataTransfer:dt}));
        }""")
        pg.wait_for_timeout(150)
        res['order_before_native'] = pg.evaluate('()=>window.__childOrder()')
        pg.evaluate("()=>window.dispatchEvent(new Event('beforeprint'))")
        pg.wait_for_timeout(150)
        res['order_after_beforeprint'] = pg.evaluate('()=>window.__childOrder()')
        pg.evaluate("()=>window.dispatchEvent(new Event('afterprint'))")
        pg.wait_for_timeout(150)
        res['order_after_afterprint'] = pg.evaluate('()=>window.__childOrder()')

        b.close()

    # ── Python 独立复算（来自 脚本/ct_data.py，与页面同源）────────────
    rows = CT.flat_rows()
    nodes = CT.NODE_COLS

    def node_state(o, n):
        return (o.get('nodes') or {}).get(n, {}).get('state', '未开始')

    def cur(o):
        idx = -1
        for i, n in enumerate(nodes):
            if node_state(o, n) in ('进行中', '卡住'):
                idx = i
        if idx < 0:
            for i, n in enumerate(nodes):
                if node_state(o, n) == '已完成':
                    idx = i
        return idx

    total_opps = len(rows)
    customers = len({r['cust'] for r in rows})
    amount = sum(parse_amt(r['scale']) for r in rows)
    stuck = sum(1 for r in rows if cur(r) >= 0 and node_state(r, nodes[cur(r)]) == '卡住')
    unlinked = sum(1 for r in rows if r.get('unlinked'))
    unlinked_ratio = round(unlinked / total_opps * 1000) / 10.0

    py = {'商机总数': total_opps, '客户数': customers, '商机总额': fmt_y(amount), '卡住商机数': stuck,
          '数据待关联率': str(unlinked_ratio) + '%'}
    dom = res['dom_kpis']
    order = ['商机总数', '客户数', '商机总额', '卡住商机数', '数据待关联率']
    res['recompute_python_ct_data'] = py

    res['external_requests'] = ext
    res['console_errors'] = [c for c in console if c['type'] == 'error']
    mtime_after = {p: os.path.getmtime(p) for p in (INDEX, APP)}
    res['mtime_unchanged'] = {os.path.basename(p): (mtime_before[p] == mtime_after[p]) for p in mtime_before}

    keys_show = ['card_count', 'kpi_count', 'src_blocks', 'grid_cols', 'wrap_w', 'font_sizes',
                 'sec_heads', 'sec_info_t0', 'sec_of_c5', 'sec_of_c3', 'sec_of_c7',
                 'kpi_flag_classes', 'kpi_flag_tags', 'dom_kpi_labels', 'dom_kpis',
                 'child_order_default', 'child_order_after_drag', 'c1_svg_before', 'c1_svg_after',
                 'c1_w_before', 'c1_w_after', 'c1_tier_before', 'c1_tier_after', 'c1_gridcol',
                 'order_before_cross', 'order_after_cross', 'cross_rejected', 'cross_last_drop',
                 'cross_hint_text', 'cross_hint_deny', 'sec_info_after_cross', 'sec_stable',
                 'ls_value_nonempty', 'ls_has_tier', 'child_order_after_reload', 'persist_reload_ok',
                 'child_order_after_reset', 'reset_ok', 'ls_cleared_after_reset', 'c2_compact_has_sbar',
                 'c2_compact_no_ring', 'c5_overflow_flag', 'print_handle_display', 'print_order_ok',
                 'order_before_native', 'order_after_beforeprint', 'order_after_afterprint']
    print(json.dumps({k: res.get(k) for k in keys_show}, ensure_ascii=False, indent=2))
    print('\n== 复算（脚本/ct_data.py 独立复算） vs 页面显示（5 个 KPI）==')
    for k in order:
        dv = dom[order.index(k)]
        print('  %-10s ct_data 复算=%-14s 页面显示=%-14s %s' % (k, py[k], dv, '✅' if str(py[k]) == str(dv) else '❌'))
    print('\n  结论摘要（页面自动生成）:', res['concl_text'])
    print('  KPI 阈值标记:', res['kpi_flag_classes'], res['kpi_flag_tags'])

    dr = res['dash_report'] or {}
    fs = res['font_sizes']
    rh = res['risk_row_heights']
    ok = []
    ok.append(('外部请求 = 0', len(ext) == 0))
    ok.append(('无 console error', len(res['console_errors']) == 0))
    ok.append(('SVG 守卫通过', res['svg_guard_fn'] is True and res['svg_bad_inner'] == 0))
    ok.append(('11 块指标卡均渲染', res['card_count'] == 11))
    ok.append(('5 张 KPI 大卡', res['kpi_count'] == 5))
    ok.append(('每块卡标注「数据来源」', res['src_blocks'] == 11))
    ok.append(('结论摘要非空且含关键数', len(res['concl_text']) > 10 and '卡住' in res['concl_text'] and '闭环率' in res['concl_text']))
    ok.append(('风险区单独成块且 2 个区块标题', len(res['sec_heads']) == 2 and '风险' in res['sec_heads'][0]))
    ok.append(('风险卡共 3 张（卡点/资源/沉默）', dr.get('riskCards') == 3))
    ok.append(('风险区位于明细之前', dr.get('riskCardsBeforeDetail') is True))
    ok.append(('KPI 阈值色：卡住>0 红框', 'flag-red' in ' '.join(res['kpi_flag_classes'])))
    ok.append(('KPI 阈值色：待关联率>30% 橙框（独立第5卡）', 'flag-amber' in ' '.join(res['kpi_flag_classes'])))
    ok.append(('第5张 KPI =「数据待关联率」橙框', res['dom_kpi_labels'][-1] == '数据待关联率'
               and 'flag-amber' in res['kpi_flag_classes'][-1]))
    ok.append(('原「客户数」卡不再挂待关联', 'flag-amber' not in res['kpi_flag_classes'][1]))
    ok.append(('复算·商机总数一致', str(py['商机总数']) == str(dom[0])))
    ok.append(('复算·客户数一致', str(py['客户数']) == str(dom[1])))
    ok.append(('复算·商机总额一致', str(py['商机总额']) == str(dom[2])))
    ok.append(('复算·卡住商机数一致', str(py['卡住商机数']) == str(dom[3])))
    ok.append(('复算·数据待关联率一致(30.8%)', py['数据待关联率'] == dom[4]))
    ok.append(('栅格 3 列 / 单页 ≤1440px', res['grid_cols'] == 3 and res['wrap_w'] <= 1440))
    ok.append(('字号级别 ≤ 4 级', len(fs) <= 4))
    ok.append(('同行卡片等高（风险区三卡高度差 ≤ 6px）', len(rh) == 3 and (max(rh) - min(rh)) <= 6))
    # 交互
    ok.append(('默认顺序 = 风险区前置（c5/c8/c7 在前）', res['child_order_default'] == DEFAULT_CHILD_ORDER))
    ok.append(('① 拖拽换位 → DOM 顺序真的变化', res['drag_changed_dom'] is True))
    ok.append(('① c5 被拖到 c7 之后', res['c5_after_c7'] is True))
    ok.append(('①b 同区块拖拽：c5 与 c7 同属风险区', bool(res['sec_of_c5']) and '风险' in res['sec_of_c5']
               and res['sec_of_c5'] == res['sec_of_c7']))
    ok.append(('①b 跨区块目标 c3 属明细区（与 c5 不同区）',
               bool(res['sec_of_c5']) and bool(res['sec_of_c3']) and res['sec_of_c5'] != res['sec_of_c3']))
    ok.append(('② 跨区块拖拽被拒 → DOM 顺序不变（回弹）', res['cross_rejected'] is True))
    ok.append(('② 跨区落点被判为 rejected', res['cross_last_drop'] == 'rejected'))
    ok.append(('② 出现禁止/回弹提示（deny 样式 + 文案含「拒绝」）',
               res['cross_hint_deny'] is True and '拒绝' in str(res['cross_hint_text'])))
    ok.append(('③ 区块标题文本与位置：两次操作前后完全不变', res['sec_stable'] is True))
    ok.append(('② 拖手柄 → 卡片变宽（DOM 读宽度变大）',
               res['c1_w_after'] > res['c1_w_before']))
    ok.append(('② 拖手柄 → SVG 宽度值变化（DOM 读）',
               res['c1_svg_after'] != res['c1_svg_before'] and res['c1_svg_after'] > res['c1_svg_before']))
    ok.append(('② 卡片跨列（grid-column 含 span 2/3）', 'span 2' in str(res['c1_gridcol']) or 'span 3' in str(res['c1_gridcol'])))
    ok.append(('③ localStorage 已写入且含 tier', res['ls_value_nonempty'] and res['ls_has_tier'] is not None))
    ok.append(('③ 刷新后布局保持（顺序+档位）', res['persist_reload_ok'] is True))
    ok.append(('④「恢复默认布局」后顺序复原', res['reset_ok'] is True and res['ls_cleared_after_reset'] is True))
    # 自适应
    ok.append(('⑤ 最小档 → 紧凑模式（环形→堆叠条，无 ring）',
               res['c2_tier'] == 0 and res['c2_compact_has_sbar'] and res['c2_compact_no_ring']))
    ok.append(('⑤ 内容溢出提示（最小档卡片）', res['c5_overflow_flag'] is True))
    # 打印
    ok.append(('⑥ 打印样式下无拖拽手柄', res['print_handle_display'] == 'none'))
    ok.append(('⑥ 打印回默认顺序（preparePrint）', res['print_order_ok'] is True))
    ok.append(('⑥ 原生 beforeprint → 回默认顺序', res['order_before_native'] != DEFAULT_CHILD_ORDER
               and res['order_after_beforeprint'] == DEFAULT_CHILD_ORDER))
    ok.append(('⑥ 原生 afterprint → 还原用户顺序', res['order_after_afterprint'] == res['order_before_native']))
    ok.append(('index/app mtime 未变', all(res['mtime_unchanged'].values())))
    print('\n== 断言 ==')
    for n, v in ok:
        print(('  ✅ ' if v else '  ❌ ') + n)
    print('\n截图目录:', SHOT)
    sys.exit(0 if all(v for _, v in ok) else 1)


if __name__ == '__main__':
    main()
