#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""P0 待办分层 —— 浏览器渲染实测 v3（修正探针结构）。

v2 的两处失败经核实都是**探针写错**，不是产品问题：
 ① 段标题 div 内含计数的 <span>，我用 `children.length===0` 过滤 → 全被滤掉；
 ② 「旧格式兼容」断言用的是整行 innerText，会命中行内 <select> 的 option 文字，不构成证明。
v3 改为：按 inline style 定位段标题；按 `.todo-meta span` 读该行**自身**的类型标签；并新增分段计数断言。
"""
import io
import json
import os
import sys
from playwright.sync_api import sync_playwright

W = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
BASE = 'http://127.0.0.1:8765/'
PAGE = '工具/index.html'
USER = '刘新元'
KEY = 'bior_sw_todos_v2_' + USER
LEGACY_ID = 'legacyX'
REAL = json.load(io.open(os.path.join(W, '数据', 'todos.json'), encoding='utf-8'))['users'][USER]
seed = list(REAL)
seed.append({'id': LEGACY_ID, 'text': '旧格式兼容测试：cat=meeting', 'cat': 'meeting',
             'done': False, 'due': '2026-10-20', 'client': '', 'created': '2026-10-07'})

SCOPE = {'internal_project':'internal','internal_complaint':'internal','internal_test_plan':'internal','internal_other':'internal',
         'external_visit':'external','external_test_req':'external','external_test_result':'external',
         'external_project':'external','external_appointment':'external'}
LEG = {'prep':'external_visit','post':'external_project','complaint':'internal_complaint','custom':'internal_other',
       'meeting':'external_appointment','travel':'external_appointment','other':'internal_other'}
def mk(t):
    k = t.get('type') or t.get('cat')
    k = k if k in SCOPE else LEG.get(k, 'internal_other')
    return k, SCOPE[k]
open_items = [t for t in seed if not t.get('done')]
exp = {'external': 0, 'internal': 0}
for t in open_items:
    exp[mk(t)[1]] += 1

ok = True
def chk(label, cond, detail=''):
    global ok
    print('  %s %-30s %s' % ('✅' if cond else '❌', label, detail))
    if not cond:
        ok = False

JS_PROBE = r"""(() => {
  const T = e => (e ? e.innerText.replace(/\s+/g,' ').trim() : '');
  const rows = [...document.querySelectorAll('.todo-item')];
  const heads = [...document.querySelectorAll('#main div[style*="font-weight:800"]')].map(T);
  const per = {external:0, internal:0};
  rows.forEach(r => { const s = r.querySelector('select'); const v = s ? s.value : '';
                      (TYPE_SCOPE[v] === 'external' ? per.external++ : per.internal++); });
  const lr = rows.filter(r => T(r).indexOf('旧格式兼容测试') >= 0)[0];
  const lsel = lr ? lr.querySelector('.todo-meta span') : null;
  return {
    rows: rows.length, heads: [...new Set(heads)], per,
    legacyLabel: lsel ? T(lsel) : '', legacyInExt: !!(lr && (lr.querySelector('select') || {}).value === 'external_appointment'),
    opts: (() => { const s = document.getElementById('todoCat'); return s ? [...s.querySelectorAll('option')].map(o => o.value) : []; })(),
    groups: (() => { const s = document.getElementById('todoCat'); return s ? [...s.querySelectorAll('optgroup')].map(g => g.label) : []; })(),
    form: ['todoText','todoOwner','todoCat','todoClient','todoDue','todoTech'].filter(i => !!document.getElementById(i)),
    inline: document.querySelectorAll('.todo-item select').length,
    owner: document.querySelectorAll('.todo-item .tag-blue').length,
    filters: [...document.querySelectorAll('.todo-filters button')].map(T)
  };
})()"""

with sync_playwright() as p:
    br = p.chromium.launch(channel='chrome', headless=True)
    pg = br.new_page(viewport={'width': 1500, 'height': 1000})
    errs = []
    pg.on('pageerror', lambda e: errs.append(str(e)))
    pg.on('dialog', lambda d: d.accept())

    pg.goto(BASE, wait_until='domcontentloaded')
    pg.evaluate("""([u, k]) => {
        localStorage.setItem('bior_auth_token', 'preview-only-token');
        localStorage.setItem('bior_sw_user_v2', JSON.stringify({name:u, role:'admin', dept:'营销中心', line:'diag', open_id:'ou_preview_only'}));
        localStorage.removeItem(k);
    }""", [USER, KEY])
    pg.goto(BASE + PAGE, wait_until='load')
    pg.wait_for_timeout(2500)

    d0 = pg.evaluate("() => ({title:document.title, fn:typeof window.switchTab})")
    chk('外壳已进入（标题正确、函数在位）', '销售工作台' in d0['title'] and d0['fn'] == 'function', d0['title'])

    pg.evaluate("""([u]) => {
        window.state = window.state || {};
        window.state.user = {name:u, role:'admin', dept:'营销中心', line:'diag', open_id:'ou_preview_only'};
        window.state.tab = 'todos';
        if (typeof showApp === 'function') showApp();
    }""", [USER])
    pg.wait_for_timeout(1200)
    pg.evaluate("""([k, seed]) => {
        window.__push = 0;
        pushTodos = function() { window.__push++; };
        window.state.tab = 'todos';
        localStorage.setItem(k, JSON.stringify(seed));
        render();
    }""", [KEY, seed])
    pg.wait_for_timeout(900)

    m = pg.evaluate(JS_PROBE)
    print('\n待办 Tab 实测（seed %d，未关闭 对外 %d / 对内 %d）：' % (len(seed), exp['external'], exp['internal']))
    chk('渲染行数=未关闭条数', m['rows'] == len(open_items), '%d / 期望 %d' % (m['rows'], len(open_items)))
    chk('分段标题=2 段', len(m['heads']) == 2, str(m['heads']))
    chk('分段计数正确', m['per'] == exp, '实测 %s / 期望 %s' % (m['per'], exp))
    chk('表单字段齐全', len(m['form']) == 6, str(m['form']))
    chk('类型下拉 9 项', len(m['opts']) == 9, '%d 项' % len(m['opts']))
    chk('下拉分两组', len(m['groups']) == 2, str(m['groups']))
    chk('下拉不含旧 7 类', not (set(m['opts']) & {'prep','post','complaint','custom','meeting','travel','other'}))
    chk('旧格式→邀约及计划(行自身标签)', m['legacyLabel'].endswith('邀约及计划'), m['legacyLabel'])
    chk('旧格式归入对外段', m['legacyInExt'], '')
    chk('行内可改类型', m['inline'] == m['rows'], '%d 个' % m['inline'])
    chk('负责人标签', m['owner'] >= 1, '%d 个' % m['owner'])
    chk('范围筛选按钮', any('对外' in f for f in m['filters']) and any('对内' in f for f in m['filters']), str(m['filters']))

    tid = seed[0]['id']
    r = pg.evaluate("""(id) => {
      const b = window.__push;
      changeTodoType(id, 'internal_test_plan');
      const s = JSON.parse(localStorage.getItem('%s')||'[]').find(t=>t.id===id);
      return {pushed: window.__push-b, type:s&&s.type, cat:s&&s.cat, scope:s&&s.scope};
    }""" % KEY, tid)
    chk('改类型：三字段同步+触发同步', r['type'] == 'internal_test_plan' and r['cat'] == 'internal_test_plan'
        and r['scope'] == 'internal' and r['pushed'] == 1, json.dumps(r, ensure_ascii=False))

    b4 = pg.evaluate("() => loadTodos().length")
    pg.evaluate("""() => { document.getElementById('todoText').value='P0 测试条目';
                           document.getElementById('todoOwner').value='刘新元';
                           document.getElementById('todoDue').value=''; }""")
    pg.evaluate("addTodo()"); pg.wait_for_timeout(300)
    chk('新增：无截止被拦', pg.evaluate("() => loadTodos().length") == b4, '%d → 未变' % b4)
    pg.evaluate("() => { document.getElementById('todoDue').value='2026-11-30'; }")
    pg.evaluate("addTodo()"); pg.wait_for_timeout(400)
    top = pg.evaluate("() => { const l=loadTodos(); const t=l[0]; return {n:l.length, due:t.due, owner:t.owner, scope:t.scope, type:t.type}; }")
    chk('新增：落库带新字段', top['n'] == b4 + 1 and top['due'] == '2026-11-30' and top['owner'] == '刘新元'
        and top['type'] == 'external_visit' and top['scope'] == 'external', json.dumps(top, ensure_ascii=False))

    pg.evaluate("setTodoScope('external')"); pg.wait_for_timeout(500)
    m2 = pg.evaluate(JS_PROBE)
    chk('筛选=对外：只剩对外段', len(m2['heads']) == 1 and '对外' in m2['heads'][0] and m2['per']['internal'] == 0, str(m2['heads']) + str(m2['per']))
    pg.evaluate("setTodoScope('internal')"); pg.wait_for_timeout(500)
    m3 = pg.evaluate(JS_PROBE)
    chk('筛选=对内：只剩对内段', len(m3['heads']) == 1 and '对内' in m3['heads'][0] and m3['per']['external'] == 0, str(m3['heads']) + str(m3['per']))
    pg.evaluate("setTodoScope('all')"); pg.wait_for_timeout(400)

    chk('无 JS 错误', not errs, str(errs[:2]))
    pg.screenshot(path='/tmp/p0_todos.png')
    pg.evaluate("""([k]) => { localStorage.removeItem(k); localStorage.removeItem('bior_auth_token'); localStorage.removeItem('bior_sw_user_v2'); }""", [KEY])
    chk('预览数据已清', pg.evaluate("() => Object.keys(localStorage).filter(k => k.indexOf('bior_sw_todos') === 0).length") == 0)
    br.close()

print('\n%s' % ('✅ P0 待办实测全部通过' if ok else '❌ 存在失败项'))
sys.exit(0 if ok else 1)
