#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""客户拉通大表 定稿基线 DEMO（轨道优先版 · 第二层精简录入）· 渲染验收（playwright + 系统 Chrome）。

断言分四组：
  A. 第一层：默认轨道视图（圆点串/13 行可点/4 部门）；点行 → 资源泳道矩阵；表格视图三层表头；
     加列加行；可切回。
  B. 第二层（精简版）：录入区只剩 4 块（① 活动类型 / ② 活动描述 / ③ 关联订单·测试单 / ④ 待办事项）+ 周会决议；
     已删除的旧栏目（竞品信息 / 货号 / 关联订单独立列 / 6 维结构化 / 商机规模·部门·客户编码录入位）不存在；
     只读继承（部门/客户编码/商机规模/销售员/技术支持）；按活动类型切换描述模板；段级校验（缺段被拦并提示缺哪段）。
  C. 三轨落盘：资源协作多选 → 第一层加列；CloudBase 载荷含 desc_template/desc_content 且不含已删字段。
  D. 周会决议三岗各填一栏 → 落盘待办库 +3；备份 v1–v7 均存在；index/app/仪表盘/天隆看板 mtime 未变。
  E. §13：商机性质 chip（机会性/持续性/待判）≥4 且可筛选；风险区两块（机会性商机流失预警 / 客诉→替换→采购恢复周期）
     含天隆 M5244-ZJ4 → MD2104-003006 真实案例（周期 60 天）与可点击展开的依据字段。
"""
import os
import sys
import json

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PAGE = os.path.join(ROOT, '工具', '客户拉通大表_demo.html')
BACKUPS = ['客户拉通大表_demo_v%d%s.html' % (n, t) for n, t in
           [(1, '_时间线版'), (2, '_单层宽表'), (3, '_自动映射版'), (4, '_表格版'), (5, ''), (6, ''), (7, '')]]
INDEX = os.path.join(ROOT, '工具', 'index.html')
APP = os.path.join(ROOT, '工具', 'app.html')
DASH = os.path.join(ROOT, '工具', '客户拉通大表_仪表盘.html')
TIANLONG = os.path.join(ROOT, '工具', '天隆_2025vs2026_趋势看板.html')
PROTECTED = [INDEX, APP, DASH, TIANLONG]
SHOT = os.path.join(ROOT, '工具', '仪表盘', '_shots_thread')
os.makedirs(SHOT, exist_ok=True)

TARGET_OPP = '多重呼吸道病原体冻干体系开发'
TARGET_CUST = '圣湘生物科技股份有限公司'
BAND_TITLES = ['项目基本信息', '项目关键节点（可向右延展）', '资源协助（可向右延展）']
RES_OPTIONS = ['技术支持测试项目单', '研发项目单', '售后', '其他']
WR_ROLES = ['sales', 'tech', 'rnd']
WR_LABELS = ['销售', '技术支持', '研发']
WR_LINES = {
    'sales': '补做C组配方并复测 ｜ 王工 ｜ 2026-10-20',
    'tech':  '安排加速箱位接样 ｜ 李工 ｜ 2026-10-18',
    'rnd':   '重做保护剂配比 ｜ 赵工 ｜ 2026-10-22',
}
DELETED_IDS = ['f_comp', 'f_sku', 'f_dept', 'f_sales', 'f_todo', 'f_owner', 'f_due',
               'st1', 'st2', 'st3', 'st4', 'st5', 'st6', 'sixdim']
VALID_DESC = ("【客户需求】\n· 应用场景：冻干放大  · 技术平台：冻干\n"
              "【宝锐方案】\n· 货号+名称：MD2104 冻干保护剂\n"
              "【测试结果】\n· 1. Ct 漂移 0.6 → 通过\n· 结论：通过\n"
              "【问题现象】客户原话“C 组第 6 天曲线抬不起来”\n"
              "【客户反馈 / 下一步诉求】认可 A/B 组，补做 C 组\n"
              "【待办事项】研发补做 C 组配方 ｜ 王工 ｜ 2026-10-20")
BROKEN_DESC = ("【客户需求】\n· 应用场景：冻干放大  · 技术平台：冻干\n"
               "【宝锐方案】\n· 货号+名称：MD2104 冻干保护剂\n"
               "【测试结果】\n\n"
               "【问题现象】客户原话“C 组第 6 天曲线抬不起来”\n"
               "【客户反馈 / 下一步诉求】认可 A/B 组，补做 C 组\n"
               "【待办事项】研发补做 C 组配方 ｜ 王工 ｜ 2026-10-20")

from playwright.sync_api import sync_playwright


def ncols(pg):
    return pg.eval_on_selector_all('#l1Table colgroup col', 'els=>els.length')


def headers(pg):
    return pg.eval_on_selector_all('#l1Table thead tr.h3 th', 'els=>els.map(e=>e.textContent.trim())')


def main():
    mtime_before = {p: os.path.getmtime(p) for p in PROTECTED}
    res = {}
    ext, console = [], []
    with sync_playwright() as p:
        b = p.chromium.launch(channel='chrome')
        pg = b.new_page(viewport={'width': 1560, 'height': 1100})
        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(800)

        res['demo_ok'] = pg.evaluate('()=>window.__demoOK||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")

        # ── A. 第一层：轨道视图 ─────────────────────────────────
        res['default_view_rail'] = pg.eval_on_selector('#viewSwitch .vb.on', 'e=>e.getAttribute("data-v")')
        res['rail_visible'] = pg.eval_on_selector('#railWrap', "e=>getComputedStyle(e).display!=='none'")
        res['table_hidden'] = pg.eval_on_selector('#l1Wrap', "e=>getComputedStyle(e).display==='none'")
        res['rail_info'] = pg.evaluate('()=>window.__railInfo||null')
        res['rail_rows'] = pg.eval_on_selector_all('#railBox rect.rowhit', 'els=>els.length')
        res['rail_curlab'] = pg.eval_on_selector_all('#railBox svg .curlab', 'els=>els.length')
        res['rail_deptnames'] = pg.eval_on_selector_all('#railBox svg text.deptname', 'els=>els.map(e=>e.textContent)')
        pg.evaluate("""() => {
            const rs=document.querySelectorAll('#railBox rect.rowhit');
            rs[0].dispatchEvent(new MouseEvent('click',{bubbles:true}));
        }""")
        pg.wait_for_timeout(400)
        res['expand_title'] = pg.eval_on_selector('.ex-title', 'e=>e.textContent')
        res['row_highlighted'] = pg.eval_on_selector_all('#railBox rect.rowhit.sel', 'els=>els.length')
        res['mx_lanes'] = pg.eval_on_selector_all('#expandBox .mx-lane', 'els=>els.length')
        res['mx_chips'] = pg.eval_on_selector_all('#expandBox .mx-chip', 'els=>els.length')
        res['mx_empty'] = pg.eval_on_selector_all('#expandBox .mx-empty', 'els=>els.map(e=>e.textContent)')
        res['mx_heads'] = pg.eval_on_selector_all('#expandBox .mx-head', 'els=>els.map(e=>e.textContent)')
        res['expand_h'] = pg.eval_on_selector('#expandBox', 'e=>Math.round(e.getBoundingClientRect().height)')
        el = pg.query_selector('#expandBox'); el.scroll_into_view_if_needed(); pg.wait_for_timeout(150)
        pg.screenshot(path=os.path.join(SHOT, 'rail_里程碑轨道.png'), full_page=True)

        pg.click('#viewSwitch .vb[data-v="table"]'); pg.wait_for_timeout(300)
        res['l1_thead_rows'] = pg.eval_on_selector_all('#l1Table thead tr', 'els=>els.length')
        res['l1_band_titles'] = pg.eval_on_selector_all('#l1Table thead tr.h2 th', 'els=>els.map(e=>e.textContent.trim())')
        res['scroll'] = pg.evaluate("""() => {
            const w=document.getElementById('l1Wrap');
            const th=document.querySelector('#l1Table thead th.stick');
            return {hasHScroll:w.scrollWidth>w.clientWidth, stickyLeft: th? getComputedStyle(th).position : null};
        }""")
        c0 = ncols(pg)
        pg.click('#btnAddNode'); pg.wait_for_timeout(150); res['cols_after_node'] = ncols(pg)
        pg.click('#btnAddRes'); pg.wait_for_timeout(150); res['cols_after_res'] = ncols(pg)
        r0 = pg.eval_on_selector_all('#l1Table tbody tr', 'els=>els.length')
        pg.click('#btnAddOpp'); pg.wait_for_timeout(200)
        res['rows_after_opp'] = pg.eval_on_selector_all('#l1Table tbody tr', 'els=>els.length')
        res['c0'] = c0; res['r0'] = r0
        el = pg.query_selector('#l1Wrap'); el.scroll_into_view_if_needed()
        pg.screenshot(path=os.path.join(SHOT, 'table_三层表头宽表.png'))
        pg.click('#viewSwitch .vb[data-v="rail"]'); pg.wait_for_timeout(250)
        res['back_to_rail'] = pg.eval_on_selector('#railWrap', "e=>getComputedStyle(e).display!=='none'")

        # ── B. 第二层：精简录入 ────────────────────────────────
        pg.click('.tab[data-t="l2"]'); pg.wait_for_timeout(300)
        res['l2_visible'] = pg.eval_on_selector('#panel-l2', "e=>getComputedStyle(e).display!=='none'")
        res['l2_rows'] = pg.eval_on_selector_all('#l2Table tbody tr', 'els=>els.length')
        res['l2_thead_rows'] = pg.eval_on_selector_all('#l2Table thead tr', 'els=>els.length')
        res['l2_cols'] = pg.eval_on_selector_all('#l2Table thead tr.h3 th', 'els=>els.map(e=>e.textContent.trim())')
        # 旧栏目已删除
        res['deleted_ids_present'] = pg.evaluate(
            "(ids)=>ids.filter(function(i){ return !!document.getElementById(i); })", DELETED_IDS)
        res['six_class_present'] = pg.eval_on_selector_all('.six', 'els=>els.length')
        # 4 块 + 继承字段
        res['grp_titles'] = pg.eval_on_selector_all('.grp-title', 'els=>els.map(e=>e.textContent.trim())')
        res['has_f_desc'] = pg.eval_on_selector_all('#f_desc', 'els=>els.length') == 1
        res['has_btnTpl'] = pg.eval_on_selector_all('#btnTpl', 'els=>els.length') == 1
        res['has_todoRows'] = pg.eval_on_selector_all('#todoRows', 'els=>els.length') == 1
        res['has_btnAddTodo'] = pg.eval_on_selector_all('#btnAddTodo', 'els=>els.length') == 1
        res['has_f_order'] = pg.eval_on_selector_all('#f_order', 'els=>els.length') == 1
        res['ro_fields'] = pg.evaluate("""()=>['f_dept_ro','f_cid_ro','f_scale_ro','f_sales_ro','f_tech_ro']
            .map(function(i){ const e=document.getElementById(i); return e? e.readOnly : null; })""")
        res['res_options_in_dom'] = pg.eval_on_selector_all('#resPick input[data-res]', 'els=>els.map(e=>e.getAttribute("value"))')
        res['demo_blocks'] = (res['demo_ok'] or {}).get('descBlocks')

        # 只读继承：选客户 → 带出部门/编码/规模/销售/技术
        pg.select_option('#f_cust', TARGET_CUST); pg.wait_for_timeout(200)
        res['inherit'] = pg.evaluate("""()=>({dept:document.getElementById('f_dept_ro').value,
            cid:document.getElementById('f_cid_ro').value, scale:document.getElementById('f_scale_ro').value,
            sales:document.getElementById('f_sales_ro').value, tech:document.getElementById('f_tech_ro').value})""")
        res['todo_default_owner'] = pg.eval_on_selector('#todoRows .td-row .td-owner', 'e=>e.value')

        # 模板：切类型 → 自动填入（逐字含模板头）
        res['tpl_test_default'] = pg.eval_on_selector('#f_desc', 'e=>e.value').count('【客户需求】') >= 1
        pg.select_option('#f_type', '采购跟进'); pg.wait_for_timeout(150)
        d2 = pg.eval_on_selector('#f_desc', 'e=>e.value')
        res['tpl_purchase'] = ('【采购阶段】' in d2 and '【决策链与关键人】' in d2)
        pg.select_option('#f_type', '客户档案完善'); pg.wait_for_timeout(150)
        d3 = pg.eval_on_selector('#f_desc', 'e=>e.value')
        res['tpl_dossier'] = '【本次补齐的字段】' in d3
        pg.select_option('#f_type', '测试结果跟进'); pg.wait_for_timeout(150)
        d4 = pg.eval_on_selector('#f_desc', 'e=>e.value')
        res['tpl_test_full'] = all(x in d4 for x in ['【客户需求】', '【宝锐方案】', '【测试结果】', '【问题现象】', '【客户反馈', '【待办事项】'])
        res['tpl_verbatim'] = ('· 参数要求：（量化，如 CV≤5%、Ct 漂移≤0.5）' in d4
                               and '· 结论：通过 / 超阈（超阈必写偏差方向与幅度）' in d4)
        pg.screenshot(path=os.path.join(SHOT, 'l2_精简录入_模板.png'), full_page=True)

        # 段级校验：清空【测试结果】段 → 提交被拦并提示缺哪段
        pg.fill('#f_desc', BROKEN_DESC); pg.wait_for_timeout(120)
        res['seg_missing_before'] = pg.evaluate('()=>window.__segMissing()')
        pg.click('#btnSubmit'); pg.wait_for_timeout(250)
        res['seg_blocked_on'] = pg.eval_on_selector('#formErr', "e=>e.classList.contains('on')")
        res['seg_blocked_text'] = pg.eval_on_selector('#formErr', 'e=>e.textContent.trim()')
        res['seg_blocked_bad'] = pg.eval_on_selector('#f_desc', "e=>e.classList.contains('bad')")
        res['seg_blocked_modal'] = pg.eval_on_selector('#trackModal', "e=>e.classList.contains('open')")
        pg.screenshot(path=os.path.join(SHOT, 'l2_段级校验拦截.png'), full_page=True)

        # 补全 → 提交通过；同时多选资源协作 + 多条待办
        pg.fill('#f_desc', VALID_DESC); pg.wait_for_timeout(120)
        res['seg_missing_after'] = pg.evaluate('()=>window.__segMissing()')
        pg.fill('#todoRows .td-row:nth-child(1) .td-item', '研发补做 C 组配方并附加速数据')
        pg.fill('#todoRows .td-row:nth-child(1) .td-owner', '王工')
        pg.fill('#todoRows .td-row:nth-child(1) .td-due', '2026-10-20')
        pg.click('#btnAddTodo'); pg.wait_for_timeout(120)
        res['todo_rows_after_add'] = pg.evaluate('()=>window.__todoCount()')
        pg.fill('#todoRows .td-row:nth-child(2) .td-item', '技术支持安排加速箱位接样')
        pg.fill('#todoRows .td-row:nth-child(2) .td-owner', '李工')
        pg.fill('#todoRows .td-row:nth-child(2) .td-due', '2026-10-18')
        pg.eval_on_selector_all('#resPick input[data-res]', 'els=>els.forEach(e=>{e.checked=false;})')
        c_before = ncols(pg)
        pg.check('#resPick input[data-res][value="研发项目单"]')
        res['todos_before_submit'] = pg.evaluate('()=>window.__todoCount()')
        pg.click('#btnSubmit'); pg.wait_for_timeout(400)
        res['submit_modal_open'] = pg.eval_on_selector('#trackModal', "e=>e.classList.contains('open')")
        res['last_submit'] = pg.evaluate('()=>window.__lastSubmit||null')
        res['n2_cols_delta'] = ncols(pg) - c_before
        res['n2_headers'] = headers(pg)
        res['cb_text'] = pg.eval_on_selector('#trackCB', 'e=>e.textContent')
        res['feishu_head'] = pg.eval_on_selector('#trackFeishu', 'e=>e.textContent').split('\n')[0]
        pg.screenshot(path=os.path.join(SHOT, 'tracks_三轨落盘.png'), full_page=True)
        pg.click('#trackX'); pg.wait_for_timeout(150)
        res['warn_badges'] = pg.evaluate("()=>document.querySelectorAll('.warn').length")
        pg.click('#btnQC'); pg.wait_for_timeout(180)
        res['qc_open'] = pg.eval_on_selector('#qcModal', "e=>e.classList.contains('open')")
        pg.click('#qcX'); pg.wait_for_timeout(120)

        # ── D. 周会决议（三岗各填一栏 → 落盘待办库）────────────
        res['wr_group_present'] = pg.eval_on_selector_all(
            '#l2Table thead tr.h2 th.band-wr', 'els=>els.map(e=>e.textContent.trim())')
        res['wr_cols'] = pg.eval_on_selector_all('#wrPanel .wr-col', 'els=>els.map(e=>e.getAttribute("data-role"))')
        res['wr_role_labels'] = pg.eval_on_selector_all('#wrPanel .wr-role', 'els=>els.map(e=>e.textContent.trim())')
        res['wr_ta_disabled_init'] = pg.eval_on_selector_all('#wrPanel .wr-ta', 'els=>els.map(e=>e.disabled)')
        res['wr_state_init'] = pg.eval_on_selector_all('#wrPanel .wr-state', 'els=>els.map(e=>e.textContent.trim())')
        res['wr_table_未提交_cells'] = pg.evaluate(
            "()=>Array.from(document.querySelectorAll('#l2Table tbody td')).filter(t=>t.textContent.indexOf('本岗未提交')>=0).length")
        el = pg.query_selector('#wrPanel'); el.scroll_into_view_if_needed(); pg.wait_for_timeout(150)
        pg.screenshot(path=os.path.join(SHOT, 'weekly_周会决议栏.png'))

        pg.click('#wrPanel .wr-act[data-act="sales"]'); pg.wait_for_timeout(120)
        res['wr_after_open_sales'] = pg.eval_on_selector_all('#wrPanel .wr-ta', 'els=>els.map(e=>e.disabled)')
        n_before = pg.evaluate('()=>window.__supportTodos.length')
        for role in WR_ROLES:
            pg.click('#wrPanel .wr-act[data-act="%s"]' % role); pg.wait_for_timeout(80)
            pg.fill('[data-ta="%s"]' % role, WR_LINES[role])
            pg.select_option('[data-opp="%s"]' % role, TARGET_OPP)
        pg.click('#btnWrSubmit'); pg.wait_for_timeout(450)
        n_after = pg.evaluate('()=>window.__supportTodos.length')
        res['wr_todo_delta'] = n_after - n_before
        res['wr_modal_open'] = pg.eval_on_selector('#wrModal', "e=>e.classList.contains('open')")
        res['wr_count_text'] = pg.eval_on_selector('#wrCount', 'e=>e.textContent.trim()')
        res['wr_feishu'] = pg.eval_on_selector('#wrFeishu', 'e=>e.textContent')
        res['wr_new_todos'] = pg.evaluate('()=>window.__supportTodos.slice(-3)')
        res['wr_state_after'] = pg.eval_on_selector_all('#wrPanel .wr-state', 'els=>els.map(e=>e.textContent.trim())')
        res['wr_table_has_text'] = pg.evaluate(
            "(t)=>Array.from(document.querySelectorAll('#l2Table tbody td')).filter(c=>c.textContent.indexOf(t)>=0).length",
            WR_LINES['sales'].split(' ｜ ')[0])
        pg.screenshot(path=os.path.join(SHOT, 'weekly_落盘面板.png'), full_page=True)
        pg.click('#wrX'); pg.wait_for_timeout(120)

        # ── E. §13 商机性质 chip / 筛选 / 风险预警区 ────────────
        pg.click('.tab[data-t="l1"]'); pg.wait_for_timeout(280)
        res['natf_btns'] = pg.eval_on_selector_all('#natfBar .natf', 'els=>els.map(e=>e.getAttribute("data-nat"))')
        res['rail_chips'] = pg.eval_on_selector_all('#railBox .natchip-svg', 'els=>els.map(e=>e.getAttribute("data-nat"))')
        res['table_chips'] = pg.eval_on_selector_all('#l1Table .natchip', 'els=>els.map(e=>e.getAttribute("data-nat"))')
        _all_chips = res['rail_chips'] + res['table_chips']
        res['chip_total'] = len(_all_chips)
        res['chip_natures'] = sorted(set(_all_chips))
        _el = pg.query_selector('#natfBar'); _el.scroll_into_view_if_needed(); pg.wait_for_timeout(150)
        pg.screenshot(path=os.path.join(SHOT, '13_chip_商机性质筛选.png'))
        # 筛选生效：点「机会性」（基准 = 加行后的当前行数）
        res['rail_rows_pre'] = pg.eval_on_selector_all('#railBox rect.rowhit', 'els=>els.length')
        pg.click('#natfBar .natf[data-nat="opportunistic"]'); pg.wait_for_timeout(300)
        res['natF_after'] = pg.evaluate('()=>window.__natFilter')
        res['rail_rows_op'] = pg.eval_on_selector_all('#railBox rect.rowhit', 'els=>els.length')
        res['rail_chips_op'] = sorted(set(pg.eval_on_selector_all('#railBox .natchip-svg', 'els=>els.map(e=>e.getAttribute("data-nat"))')))
        pg.click('#natfBar .natf[data-nat="all"]'); pg.wait_for_timeout(250)
        res['rail_rows_back'] = pg.eval_on_selector_all('#railBox rect.rowhit', 'els=>els.length')
        # 风险预警区两块
        res['risk_cards'] = pg.eval_on_selector_all('#cardOppLoss,#cardReplace', 'els=>els.map(e=>e.id)')
        res['opp_levels'] = pg.eval_on_selector_all('#oppLossList .risk-item', 'els=>els.map(e=>e.getAttribute("data-lv"))')
        res['repl_items'] = pg.eval_on_selector_all('#replList .risk-item',
                                                    'els=>els.map(e=>e.getAttribute("data-lv")+":"+e.getAttribute("data-case"))')
        res['repl_segs'] = pg.eval_on_selector_all('#replList .rp-seg', 'els=>els.length')
        res['tianlong_text'] = pg.eval_on_selector('#replList .risk-item[data-case="RC-202607-001"]', 'e=>e.innerText')
        _el = pg.query_selector('#riskArea'); _el.scroll_into_view_if_needed(); pg.wait_for_timeout(220)
        pg.screenshot(path=os.path.join(SHOT, '13_风险预警区_两块.png'))
        # 点击展开依据字段
        pg.click('#replList .risk-item[data-case="RC-202607-001"]'); pg.wait_for_timeout(220)
        res['basis_open'] = pg.eval_on_selector_all('#replList .risk-item.open .risk-basis', 'els=>els.length')
        res['basis_disp'] = pg.eval_on_selector('#replList .risk-item.open .risk-basis', 'e=>getComputedStyle(e).display')
        _el = pg.query_selector('#cardReplace'); _el.scroll_into_view_if_needed(); pg.wait_for_timeout(200)
        pg.screenshot(path=os.path.join(SHOT, '13_风险预警区_展开依据.png'))
        b.close()

    res['external_requests'] = ext
    res['console_errors'] = [c for c in console if c['type'] == 'error']
    res['backup_exists'] = {fn: os.path.exists(os.path.join(ROOT, '工具', fn)) for fn in BACKUPS}
    mtime_after = {p: os.path.getmtime(p) for p in PROTECTED}
    res['mtime_unchanged'] = {os.path.basename(p): (mtime_before[p] == mtime_after[p]) for p in mtime_before}

    show = ['l2_cols', 'grp_titles', 'deleted_ids_present', 'six_class_present', 'ro_fields', 'inherit',
            'todo_default_owner', 'tpl_test_full', 'tpl_verbatim', 'tpl_purchase', 'tpl_dossier',
            'seg_missing_before', 'seg_blocked_on', 'seg_blocked_text', 'seg_blocked_bad', 'seg_blocked_modal',
            'seg_missing_after', 'todo_rows_after_add', 'todos_before_submit', 'last_submit',
            'n2_cols_delta', 'n2_headers', 'feishu_head', 'demo_blocks', 'rail_info']
    print(json.dumps({k: res.get(k) for k in show}, ensure_ascii=False, indent=2))
    print('\n== 周会决议 · 新落盘待办（3 条）==')
    for t in res['wr_new_todos']:
        print('  %s ｜ 岗位=%s ｜ 负责人=%s ｜ 时间节点=%s ｜ 关联商机=%s ｜ 来源=%s' %
              (t.get('todo_id'), t.get('role'), t.get('owner'), t.get('due'), t.get('opportunity'), t.get('source')))

    ri = res['rail_info'] or {}
    cb = res['cb_text']
    ls = res['last_submit'] or {}
    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))
    # A 第一层
    ok.append(('默认视图 = 轨道视图', res['default_view_rail'] == 'rail' and res['rail_visible'] and res['table_hidden']))
    ok.append(('轨道 13 行可点 + 当前节点标签', res['rail_rows'] == 13 and res['rail_curlab'] == 13 and ri.get('rows') == 13))
    ok.append(('4 个部门分组就位', sum(1 for t in res['rail_deptnames'] if '个商机' in t) == 4))
    ok.append(('点行 → 高亮 + 展开资源泳道矩阵（5 泳道 / chip>0）',
               res['row_highlighted'] == 1 and res['mx_lanes'] == 5 and res['mx_chips'] > 0))
    ok.append(('矩阵空泳道显示「未触发」+ 月份列头', any('未触发' in t for t in res['mx_empty'])
               and len([h for h in res['mx_heads'] if '/' in h]) >= 2))
    ok.append(('切表格视图 → 原三层表头宽表 + 横向滚动冻结',
               res['l1_thead_rows'] == 3 and res['l1_band_titles'] == BAND_TITLES
               and res['scroll']['hasHScroll'] and res['scroll']['stickyLeft'] == 'sticky'))
    ok.append(('＋新增节点列 / 资源协助列 / 商机 → 列行各 +1',
               res['cols_after_node'] == res['c0'] + 1 and res['cols_after_res'] == res['c0'] + 2
               and res['rows_after_opp'] == res['r0'] + 1))
    ok.append(('可切回轨道视图', res['back_to_rail'] is True))
    ok.append(('第二层 Tab 渲染（表格 3 行表头 + 行>0）', res['l2_visible'] and res['l2_thead_rows'] == 3 and res['l2_rows'] > 0))
    # B 第二层精简
    ok.append(('录入区 = 4 块（①②③④）', all(any(x in t for t in res['grp_titles']) for x in
               ['① 活动类型', '② 活动描述', '③ 关联订单', '④ 待办事项'])))
    ok.append(('删除的旧栏目全部不存在（含 6 维/.six）',
               res['deleted_ids_present'] == [] and res['six_class_present'] == 0))
    ok.append(('保留控件：活动描述/插入模板/待办多行/关联订单',
               res['has_f_desc'] and res['has_btnTpl'] and res['has_todoRows'] and res['has_btnAddTodo'] and res['has_f_order']))
    ok.append(('只读继承字段（部门/编码/规模/销售/技术）全 readonly', res['ro_fields'] == [True, True, True, True, True]))
    ok.append(('只读继承已带出（圣湘：大客户部/编码/规模/李工）',
               res['inherit']['dept'] == '大客户部' and res['inherit']['cid'] == '6878118191'
               and res['inherit']['sales'] == '赵云浩' and res['inherit']['tech'] == '李工'))
    ok.append(('待办默认协同人 = 技术支持', res['todo_default_owner'] == '李工'))
    ok.append(('L2 表格列已剔除竞品信息/货号', '竞品信息' not in res['l2_cols'] and '货号' not in res['l2_cols']))
    ok.append(('L2 表格含「活动描述（模板化）」「关联订单/测试单」',
               '活动描述（模板化）' in res['l2_cols'] and '关联订单/测试单' in res['l2_cols']))
    ok.append(('切「测试结果跟进」→ 模板自动填入（6 段头齐全）', res['tpl_test_full'] is True))
    ok.append(('模板逐字照用（参数要求/结论行一致）', res['tpl_verbatim'] is True))
    ok.append(('切「采购跟进」→ 模板自动切换', res['tpl_purchase'] is True))
    ok.append(('切「客户档案完善」→ 模板自动切换', res['tpl_dossier'] is True))
    ok.append(('段级校验：清空【测试结果】段 → 命中缺段', res['seg_missing_before'] == ['测试结果']))
    ok.append(('段级校验：提交被拦 + 高亮 + 不落盘',
               res['seg_blocked_on'] and res['seg_blocked_bad'] and not res['seg_blocked_modal']))
    ok.append(('段级校验：提示缺哪段（【测试结果】）', '测试结果' in res['seg_blocked_text']))
    ok.append(('补全必填段后可提交', res['seg_missing_after'] == [] and res['submit_modal_open'] is True))
    ok.append(('待办可多条（新增一行 → 2 行，提交含 2 条）',
               res['todo_rows_after_add'] == 2 and len(ls.get('todos') or []) == 2))
    ok.append(('资源协作多选 → 第一层列 +1（研发项目单1）',
               res['n2_cols_delta'] == 1 and '研发项目单1' in res['n2_headers']))
    ok.append(('CloudBase 载荷含 desc_template/desc_content', 'desc_template' in cb and 'desc_content' in cb))
    ok.append(('CloudBase 载荷不含已删字段（sku/competitor）', 'sku' not in cb and 'competitor' not in cb))
    ok.append(('「待关联」黄标存在', res['warn_badges'] >= 1))
    ok.append(('质检检查单可开', res['qc_open'] is True))
    # D 周会决议
    ok.append(('周会决议列组就位（表头「周会决议（三岗各填一栏）」）', res['wr_group_present'] == ['周会决议（三岗各填一栏）']))
    ok.append(('三岗填写位就位（顺序固定 sales/tech/rnd）', res['wr_cols'] == WR_ROLES))
    ok.append(('每栏带岗位标识', res['wr_role_labels'] == WR_LABELS))
    ok.append(('权限演示：默认三栏锁定', res['wr_ta_disabled_init'] == [True, True, True]))
    ok.append(('未填栏显示「本岗未提交」（表单 3 处）', res['wr_state_init'] == ['本岗未提交', '本岗未提交', '本岗未提交']))
    ok.append(('表格空白列格显示「本岗未提交」', res['wr_table_未提交_cells'] >= 1))
    ok.append(('权限演示：以本岗身份填写 → 仅本栏解锁', res['wr_after_open_sales'] == [False, True, True]))
    ok.append(('提交周会决议 → 待办库 +3', res['wr_todo_delta'] == 3))
    ok.append(('结果面板展示「本次落盘 3 条待办」', res['wr_modal_open'] and res['wr_count_text'] == '3'))
    ok.append(('三岗各 1 条（销售/技术支持/研发）', [t.get('role') for t in res['wr_new_todos']] == WR_LABELS))
    ok.append(('提交后三栏状态 = 已提交 1 条', res['wr_state_after'] == ['已提交 1 条', '已提交 1 条', '已提交 1 条']))
    ok.append(('周会决议落盘后表格同步显示决议文本', res['wr_table_has_text'] >= 1))
    ok.append(('飞书描述含「本次落盘 3 条待办」', '本次落盘 3 条待办' in res['wr_feishu']))
    # E §13 商机性质 chip / 筛选 / 风险预警区
    ok.append(('§13 筛选按钮四个（全部/机会性/持续性/待判）',
               res['natf_btns'] == ['all', 'opportunistic', 'continuous', 'unknown']))
    ok.append(('§13 商机性质 chip ≥4 且含机会性与持续性两类',
               res['chip_total'] >= 4 and 'opportunistic' in res['chip_natures']
               and 'continuous' in res['chip_natures']))
    ok.append(('§13 筛选生效（机会性 → 轨道 4 行且全为机会性）',
               res['natF_after'] == 'opportunistic' and res['rail_rows_op'] == 4
               and res['rail_chips_op'] == ['opportunistic']))
    ok.append(('§13 筛选可复位（全部 → 恢复筛选前 %d 行）' % res['rail_rows_pre'],
               res['rail_rows_back'] == res['rail_rows_pre'] and res['rail_rows_pre'] > res['rail_rows_op']))
    ok.append(('§13 风险区两块（机会性流失 + 客诉替换周期）', res['risk_cards'] == ['cardOppLoss', 'cardReplace']))
    ok.append(('§13 机会性商机流失预警含红/黄等级', 'red' in res['opp_levels'] and 'yellow' in res['opp_levels']))
    ok.append(('§13 替换卡 3 案 · 每案 6 阶段进度条（共 18 段）',
               len(res['repl_items']) == 3 and res['repl_segs'] == 18))
    ok.append(('§13 天隆案例卡含 M5244-ZJ4 / MD2104-003006 / 60',
               all(k in res['tianlong_text'] for k in ['M5244-ZJ4', 'MD2104-003006', '60'])))
    ok.append(('§13 预警项可点击展开依据字段', res['basis_open'] >= 1 and res['basis_disp'] == 'block'))
    ok.append(('v1–v7 备份均存在', all(res['backup_exists'].values())))
    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()
