#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""《客户拉通大表 · 销售员使用说明》验收（playwright + 系统 Chrome）。

断言分组：
  1 渲染：外部请求 = 0 · 无 console error · SVG 节点 > 0 · 七块均渲染。
  2 匿名：真实客户名（取自 脚本/ct_data.py，≥5 个）与真实货号（M5244-ZJ4 / MD2104-003006 / M4211-3 等）均不出现。
  3 关键词：周会决议 / 三轨落盘 / 资源协作 / 插入模板 / 待关联 逐字存在；负面断言不含「已上线」。
  4 沙盒：真点一遍（合格 / 反例 / 重试 / 重置），读 DOM 状态取证；localStorage 无新增 key。
  5 截图：≥3 张（步骤图 / 逻辑图 / 填写质量）+ 全页。
  6 保护文件 mtime 未变（对照开工前快照）。
"""
import os
import re
import sys
import json

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, '脚本'))
PAGE = os.path.join(ROOT, '知识沉淀', '客户拉通大表_销售员使用说明_20261011.html')
SHOT = os.path.join(ROOT, '知识沉淀', '_shots_customer_thread_training')
os.makedirs(SHOT, exist_ok=True)

BASELINE = os.path.join(ROOT, '.hermes', 'ct_training_mtime_baseline.json')
PROTECTED = ['工具/index.html', '工具/app.html', '工具/客户拉通大表_demo.html',
             '工具/客户拉通大表_仪表盘.html', '工具/天隆_2025vs2026_趋势看板.html']

KEYWORDS = ['周会决议', '三轨落盘', '资源协作', '插入模板', '待关联']
BLOCK_IDS = ['b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7']
BLOCK_TITLES = ['为什么要用', '你要做的事', '一步步怎么做', '背后的逻辑', '谁能看到什么', '填写质量要求', '你从中能得到什么']

import ct_data  # noqa: E402

# ── 真实客户名黑名单（从 ct_data.py 取）──────────────────────────────
REAL_CUST = set()
for r in ct_data.RAW_ROWS:
    REAL_CUST.add(r.get('cust', ''))
for c in ct_data.REPLACE_CASES:
    REAL_CUST.add(c.get('customer', ''))
for h in ct_data.OPP_LOSS_HISTORY:
    REAL_CUST.add(h.get('customer', ''))
REAL_CUST = sorted(n for n in REAL_CUST if n and len(n) >= 4)

# ── 真实货号黑名单（任务点名 + ct_data 内出现过的）────────────────────
REAL_SKU = ['M5244-ZJ4', 'MD2104-003006', 'M4211-3', 'MD2104', 'MD2201', '1E102', '1D303', 'MD3100-ZJ2', '1D305-000801']

from playwright.sync_api import sync_playwright  # noqa: E402

FAILS = []


def chk(name, cond, detail=''):
    print('%s %s %s' % ('✅' if cond else '❌', name, detail))
    if not cond:
        FAILS.append(name)


def sel_shot(pg, sel, name):
    el = pg.query_selector(sel)
    el.scroll_into_view_if_needed()
    pg.wait_for_timeout(200)
    p = os.path.join(SHOT, name)
    el.screenshot(path=p)
    return p


def main():
    print('=' * 64)
    print('渲染 + 断言：%s' % os.path.basename(PAGE))
    print('=' * 64)
    ext, console_err = [], []
    with sync_playwright() as p:
        b = p.chromium.launch(channel='chrome')
        pg = b.new_page(viewport={'width': 1280, 'height': 1000})
        pg.on('request', lambda r: ext.append(r.url) if r.url.startswith(('http://', 'https://', '//')) else None)
        pg.on('console', lambda m: console_err.append(m.text) if m.type == 'error' else None)
        pg.goto('file://' + PAGE)
        pg.wait_for_timeout(700)

        # ── 1 渲染 ──────────────────────────────────────────────
        chk('外部请求 = 0', len(ext) == 0, '（%d）' % len(ext))
        chk('无 console error', len(console_err) == 0, str(console_err[:2]))
        chk('SVG 节点 > 0', pg.evaluate('()=>document.querySelectorAll("svg").length') > 0,
            'svg=%d / 子节点=%d' % (pg.evaluate('()=>document.querySelectorAll("svg").length'),
                                    pg.evaluate('()=>document.querySelectorAll("svg *").length')))
        blk = {}
        for i, sid in enumerate(BLOCK_IDS):
            t = pg.eval_on_selector('#' + sid + ' h2', 'e=>e.textContent.replace(/\\s+/g,"")')
            h = pg.eval_on_selector('#' + sid, 'e=>Math.round(e.getBoundingClientRect().height)')
            vis = pg.eval_on_selector('#' + sid, "e=>getComputedStyle(e).display!=='none'")
            blk[sid] = {'h': h, 'vis': vis, 'title_ok': BLOCK_TITLES[i] in t}
        chk('七块均渲染', all(v['h'] > 40 and v['vis'] and v['title_ok'] for v in blk.values()),
            str({k: v['h'] for k, v in blk.items()}))
        chk('TOC 7 条锚点', pg.evaluate('()=>document.querySelectorAll("nav.toc a").length') == 7)
        chk('页首/页脚均标 DEMO 原型', pg.evaluate('()=>document.body.innerText.split("DEMO 原型").length-1') >= 2)

        # ── 4 沙盒真交互 ────────────────────────────────────────
        miss0 = pg.evaluate('()=>document.querySelectorAll("#sbSeg .it.miss").length')
        chk('沙盒初始六段全缺', miss0 == 6, '（%d）' % miss0)
        pg.click('#sbFillOk'); pg.wait_for_timeout(120)
        fill1 = pg.evaluate('()=>document.querySelectorAll("#sbSeg .it.fill").length')
        ok1 = pg.eval_on_selector('#sbMsg', 'e=>e.className')
        chk('一键填合格 → 六段齐', fill1 == 6 and 'ok' in ok1, '（fill=%d / %s）' % (fill1, ok1))
        pg.click('#sbFillBad'); pg.wait_for_timeout(120)
        miss1 = pg.evaluate('()=>document.querySelectorAll("#sbSeg .it.miss").length')
        badcls = pg.eval_on_selector('#sbDesc', "e=>e.classList.contains('bad')")
        chk('填反例 → 缺段上升且输入框标红', miss1 == 1 and badcls, '（miss=%d / bad=%s）' % (miss1, badcls))
        pg.click('#sbSubmit'); pg.wait_for_timeout(120)
        msg = pg.eval_on_selector('#sbMsg', 'e=>e.textContent')
        chk('缺段提交被拦（提示缺哪段）', ('提交被拦截' in msg) and ('测试结果' in msg), msg[:40])
        pg.click('#sbFillOk'); pg.wait_for_timeout(100)
        pg.click('#sbSubmit'); pg.wait_for_timeout(100)
        msg2 = pg.eval_on_selector('#sbMsg', 'e=>e.textContent')
        chk('补全后提交通过（三轨落盘提示）', ('校验通过' in msg2) and ('三轨落盘' in msg2), msg2[:36])
        pg.click('#sbReset'); pg.wait_for_timeout(100)
        miss2 = pg.evaluate('()=>document.querySelectorAll("#sbSeg .it.miss").length')
        chk('重置 → 回到六段全缺', miss2 == 6, '（%d）' % miss2)
        chk('沙盒不落盘（localStorage.length=0）', pg.evaluate('()=>localStorage.length') == 0)

        # ── 5 截图 ──────────────────────────────────────────────
        shots = [sel_shot(pg, '#b3', '01_步骤图.png'),
                 sel_shot(pg, '#b4', '02_逻辑图.png'),
                 sel_shot(pg, '#b6', '03_填写质量.png'),
                 sel_shot(pg, '#b5', '04_权限.png')]
        pg.screenshot(path=os.path.join(SHOT, '00_全页.png'), full_page=True)
        chk('截图 ≥3 张', len(shots) >= 3, '（%d）' % len(shots))
        for s in shots:
            print('   · %s (%d bytes)' % (os.path.relpath(s, ROOT), os.path.getsize(s)))
        b.close()

    # ── 2 匿名断言 ──────────────────────────────────────────────
    raw = open(PAGE, encoding='utf-8').read()
    scan = re.sub(r'data:image/[^"\')]{200,}', '', raw)   # 剔 base64 再扫
    hit_cust = [n for n in REAL_CUST if n in scan]
    hit_sku = [s for s in REAL_SKU if s in scan]
    chk('真实客户名 0 残留', not hit_cust, '黑名单 %d 个 / 命中: %s' % (len(REAL_CUST), hit_cust[:5]))
    chk('真实货号 0 残留', not hit_sku, '黑名单 %d 个 / 命中: %s' % (len(REAL_SKU), hit_sku))
    print('   黑名单示例（客户名）: %s' % ' / '.join(REAL_CUST[:5]))

    # ── 3 关键词 ────────────────────────────────────────────────
    for kw in KEYWORDS:
        chk('关键词逐字存在：%s' % kw, kw in raw, '（%d 次）' % raw.count(kw))
    chk('负面：不含「已上线」', '已上线' not in raw)

    # ── 6 保护文件 mtime ────────────────────────────────────────
    base = json.load(open(BASELINE, encoding='utf-8'))
    changed = [p for p in PROTECTED
               if os.path.getmtime(os.path.join(ROOT, p)) != base.get(p)]
    chk('保护文件 mtime 未变', not changed, '变化: %s' % changed)

    print('=' * 64)
    if FAILS:
        print('❌ 失败 %d 项：%s' % (len(FAILS), FAILS))
        sys.exit(1)
    print('✅ 全部断言通过')


if __name__ == '__main__':
    main()
