You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
254 lines
17 KiB
254 lines
17 KiB
|
1 day ago
|
# -*- coding: utf-8 -*-
|
||
|
|
"""票16 黑盒审计 · UI 层(Playwright chromium headless)对照原型逐页操作 + 截图
|
||
|
|
产出: ui-result.json + screenshots/*.png
|
||
|
|
"""
|
||
|
|
import io, json, pathlib, sys, time
|
||
|
|
|
||
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||
|
|
from playwright.sync_api import sync_playwright
|
||
|
|
|
||
|
|
ROOT = pathlib.Path(r"E:\code\crm-backend-matt\.scratch\crm-project")
|
||
|
|
AUD = ROOT / "audit"
|
||
|
|
SHOTS = AUD / "screenshots"; SHOTS.mkdir(exist_ok=True)
|
||
|
|
DEMO = ROOT / "demo" / "index.html"
|
||
|
|
DIRECTOR, SALES = "99021002", "99021003"
|
||
|
|
CUSTOMER, CARD = "99021001", "99021008"
|
||
|
|
BASE = "http://localhost:8080"
|
||
|
|
|
||
|
|
results = []
|
||
|
|
def step(name, ok, note=""):
|
||
|
|
results.append({"step": name, "ok": bool(ok), "note": str(note)[:400]})
|
||
|
|
print(("PASS " if ok else "!! ") + name + ((" | " + str(note)) if note else ""))
|
||
|
|
|
||
|
|
def shot(page, name):
|
||
|
|
page.screenshot(path=str(SHOTS / (name + ".png")), full_page=True)
|
||
|
|
|
||
|
|
console_errors, page_errors, methods = [], [], set()
|
||
|
|
|
||
|
|
with sync_playwright() as p:
|
||
|
|
browser = p.chromium.launch(headless=True)
|
||
|
|
ctx = browser.new_context(viewport={"width": 1680, "height": 1000})
|
||
|
|
page = ctx.new_page()
|
||
|
|
page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
|
||
|
|
page.on("pageerror", lambda e: page_errors.append(str(e)))
|
||
|
|
page.on("request", lambda r: methods.add(r.method))
|
||
|
|
page.set_default_timeout(15000)
|
||
|
|
|
||
|
|
page.goto(DEMO.as_uri() + "?api=" + BASE)
|
||
|
|
page.fill("#uidInput", DIRECTOR); page.click("text=uid 登录")
|
||
|
|
page.wait_for_selector("text=登录成功"); page.wait_for_timeout(900)
|
||
|
|
|
||
|
|
# ============ 审计1 建档表单 ============
|
||
|
|
page.click("text=+ 新建项目"); shot(page, "01-create-dialog")
|
||
|
|
# 缺名称
|
||
|
|
page.fill("#cCustomer", CUSTOMER); page.fill("#cCard", CARD)
|
||
|
|
page.click("#dlgCreate .dfoot .ok"); page.wait_for_timeout(700)
|
||
|
|
t = page.inner_text("#toast")
|
||
|
|
step("U01 缺名称被拒(68001)", "68001" in t or "必填" in t, t)
|
||
|
|
shot(page, "02-create-missing-name")
|
||
|
|
# 缺客户
|
||
|
|
page.fill("#cName", "A5UI-" + time.strftime("%H%M%S")); page.fill("#cCustomer", "")
|
||
|
|
page.click("#dlgCreate .dfoot .ok"); page.wait_for_timeout(700)
|
||
|
|
t = page.inner_text("#toast")
|
||
|
|
step("U02 缺客户被拒", "必填" in t, t)
|
||
|
|
# 卡客户不匹配:换一个真实存在的其他客户(来自 DB 已知 A5DEMO-C001=99021001;用不存在客户看语义)
|
||
|
|
page.fill("#cCustomer", "99999001"); page.fill("#cCard", CARD)
|
||
|
|
page.click("#dlgCreate .dfoot .ok"); page.wait_for_timeout(700)
|
||
|
|
t = page.inner_text("#toast")
|
||
|
|
step("U03 卡客户不匹配/客户不存在被拒", "不可用" in t or "不存在" in t, t)
|
||
|
|
shot(page, "03-create-bad-customer")
|
||
|
|
# 成功建档
|
||
|
|
nm = "A5UI-主-" + time.strftime("%H%M%S")
|
||
|
|
page.fill("#cName", nm); page.fill("#cCustomer", CUSTOMER); page.fill("#cCard", CARD)
|
||
|
|
page.fill("#cAmount", "1200000"); page.fill("#cBrand", "itc")
|
||
|
|
page.click("#dlgCreate .dfoot .ok"); page.wait_for_selector("text=建项目成功")
|
||
|
|
page.wait_for_timeout(900)
|
||
|
|
step("U04 合法建档成功并自动进详情", True)
|
||
|
|
shot(page, "04-detail-stage0")
|
||
|
|
head = page.inner_text("#detailHead")
|
||
|
|
step("U05 头部: 冲突处理+审核中+进行中", ("冲突处理" in head and "审核中" in head and "进行中" in head), head[:150])
|
||
|
|
step("U06 头部含 客户/负责人/金额/方案卡/品牌/地区", all(k in head for k in ["客户", "负责人", "项目金额", "方案卡", "品牌", "地区"]))
|
||
|
|
miss = [k for k in ["来源商机", "项目编号", "甲方单位", "客户联系人", "项目类型"] if k not in page.inner_text("#tab-detail")]
|
||
|
|
step("U07 头部对照原型缺: " + ",".join(miss), not miss, "原型页首区字段(编号/甲方单位/客户联系人/来源商机/项目类型)未展示" if miss else "")
|
||
|
|
pid = page.evaluate("() => String(D.id)")
|
||
|
|
|
||
|
|
# ============ 审计4 状态机动作 ============
|
||
|
|
acts = page.inner_text("#detailActions")
|
||
|
|
step("U08 stage0 动作区只有 冲突解除+关闭(无推进/回退)", ("冲突解除" in acts and "回退" not in acts and "推进" not in acts), acts)
|
||
|
|
page.click("#detailSubtabs button[data-sub='timeline']"); page.wait_for_timeout(800)
|
||
|
|
tl = page.inner_text("#subBox")
|
||
|
|
step("U09 动态: 建项目+报备发起两条", ("建项目" in tl and "报备发起" in tl), tl[:120])
|
||
|
|
shot(page, "05-timeline-create")
|
||
|
|
# 冲突解除弹窗
|
||
|
|
page.click("#detailActions button:has-text('冲突解除')")
|
||
|
|
page.wait_for_selector("#dlgFiling[open]"); shot(page, "06-filing-dialog")
|
||
|
|
page.select_option("#filingApproved", "true"); page.fill("#filingRemark", "审计通过")
|
||
|
|
page.click("#dlgFiling .dfoot .ok"); page.wait_for_selector("text=报备审核回填成功"); page.wait_for_timeout(900)
|
||
|
|
head = page.inner_text("#detailHead")
|
||
|
|
step("U10 冲突解除双写: 方案设计+报备通过", ("方案设计" in head and "通过" in head), head[:120])
|
||
|
|
shot(page, "07-detail-stage1")
|
||
|
|
acts = page.inner_text("#detailActions")
|
||
|
|
step("U11 stage1 动作区: 推进+关闭(回退未出现,stage<2)", ("推进(方案设计" in acts and "回退" not in acts), acts)
|
||
|
|
# 1→2→3
|
||
|
|
page.click("#detailActions button:has-text('推进(方案设计')"); page.wait_for_selector("text=推进成功"); page.wait_for_timeout(500)
|
||
|
|
page.click("#detailActions button:has-text('推进(商务报价')"); page.wait_for_selector("text=推进成功"); page.wait_for_timeout(500)
|
||
|
|
acts = page.inner_text("#detailActions")
|
||
|
|
step("U12 stage3 出现两颗按钮: 推进+无需招投标+回退", ("推进(招投标准备" in acts and "无需招投标" in acts and "回退" in acts), acts)
|
||
|
|
shot(page, "08-detail-stage3")
|
||
|
|
# 回退弹窗(此时可选 1,2)
|
||
|
|
page.click("#detailActions button:has-text('回退…')"); page.wait_for_selector("#rbTargets option", state="attached")
|
||
|
|
tg = page.evaluate("() => [...document.querySelectorAll('#rbTargets option')].map(o => o.value)")
|
||
|
|
step("U13 stage3 回退可选={1,2}", tg == ["1", "2"], str(tg))
|
||
|
|
shot(page, "09-rollback-dialog")
|
||
|
|
step("U14 回退弹窗仅一个「回退到」下拉(原型另有*原因类型+*说明)",
|
||
|
|
page.evaluate("() => document.querySelectorAll('#dlgRollback label').length") == 1)
|
||
|
|
page.click("#dlgRollback .dfoot button:not(.ok)"); page.wait_for_timeout(300)
|
||
|
|
# 连跳
|
||
|
|
page.click("#detailActions button:has-text('无需招投标')"); page.wait_for_selector("text=推进成功"); page.wait_for_timeout(600)
|
||
|
|
head = page.inner_text("#detailHead")
|
||
|
|
step("U15 3→5 连跳到 赢单/输单", "赢单/输单" in head)
|
||
|
|
shot(page, "10-detail-stage5")
|
||
|
|
# 赢单
|
||
|
|
page.on("dialog", lambda d: d.accept())
|
||
|
|
page.click("#detailActions button:has-text('标记赢单')"); page.wait_for_selector("text=标记成功"); page.wait_for_timeout(700)
|
||
|
|
head = page.inner_text("#detailHead")
|
||
|
|
acts = page.inner_text("#detailActions")
|
||
|
|
step("U16 赢单 stage5 闭环: 无任何动作(除关闭)", ("赢单" in head and "标记赢单" not in acts and "回退" not in acts), acts or "(空)")
|
||
|
|
shot(page, "11-win-locked")
|
||
|
|
page.click("#detailSubtabs button[data-sub='bidopen']"); page.wait_for_timeout(700)
|
||
|
|
bo = page.inner_text("#subBox")
|
||
|
|
step("U17 开标页签: 赢单后快照字段全为'-'(标记时不采集原型必填的中标金额/单位/方式)", bo.count("-") >= 4, bo[:150])
|
||
|
|
shot(page, "12-bidopen-win")
|
||
|
|
# 关闭赢单项目
|
||
|
|
page.click("#detailActions button:has-text('关闭…')"); page.wait_for_selector("#dlgCloseProj[open]")
|
||
|
|
page.select_option("#closeType", "MANUAL"); page.fill("#closeReason", "审计收尾")
|
||
|
|
page.click("#dlgCloseProj .dfoot .ok"); page.wait_for_selector("text=项目已关闭"); page.wait_for_timeout(700)
|
||
|
|
head = page.inner_text("#detailHead"); acts = page.inner_text("#detailActions")
|
||
|
|
step("U18 关闭后: 已关闭+终态无动作", ("已关闭" in head and "锁死" in acts), acts[:100])
|
||
|
|
step("U19 关闭行显示原始枚举 closeType", "MANUAL" in head, head[-120:])
|
||
|
|
shot(page, "13-closed-terminal")
|
||
|
|
|
||
|
|
# ============ 第二个项目: 看板/列表 + 输单 ============
|
||
|
|
nm2 = "A5UI-输单-" + time.strftime("%H%M%S")
|
||
|
|
page.click("text=+ 新建项目"); page.fill("#cName", nm2)
|
||
|
|
page.fill("#cCustomer", CUSTOMER); page.fill("#cCard", CARD)
|
||
|
|
page.click("#dlgCreate .dfoot .ok"); page.wait_for_selector("text=建项目成功"); page.wait_for_timeout(800)
|
||
|
|
pid2 = page.evaluate("() => String(D.id)")
|
||
|
|
# 看板: 7列/badge/stage0卡冲突入口/manage-mine
|
||
|
|
page.click("nav.tabs button[data-tab='board']"); page.wait_for_timeout(1000)
|
||
|
|
cols = page.evaluate("() => [...document.querySelectorAll('#boardBox .bcol')].map(c => ({n: c.querySelector('.colhead span').textContent, cnt: c.querySelector('.cnt').textContent}))")
|
||
|
|
step("U20 看板 7 列全画且列名=7阶段", len(cols) == 7 and [c["n"] for c in cols] ==
|
||
|
|
["冲突处理", "方案设计", "商务报价", "招投标准备", "商务跟进", "赢单/输单", "结项"], str([c["n"] for c in cols]))
|
||
|
|
badge = page.inner_text("#conflictCnt")
|
||
|
|
step("U21 冲突待处理 badge=数字", badge.isdigit(), "badge=" + badge)
|
||
|
|
card0 = page.inner_text("#boardBox .bcol:first-child")
|
||
|
|
step("U22 stage0 卡带「冲突解除」入口", "冲突解除" in card0, card0[:100])
|
||
|
|
step("U23 看板卡字段: 名称/客户/负责人/金额(原型另有 最近跟进时间/中标时间/超期状态)",
|
||
|
|
page.evaluate("() => { const m = document.querySelector('#boardBox .bcard .bmeta'); return m ? m.children.length >= 2 : false; }"))
|
||
|
|
step("U24 看板顶部无「全部项目/各阶段计数」统计卡(原型+spec §4 顶部统计卡)",
|
||
|
|
page.evaluate("() => document.querySelectorAll('.sumcards .card').length") == 1)
|
||
|
|
shot(page, "14-board-director")
|
||
|
|
page.click("#tab-board [data-scope='mine']"); page.wait_for_timeout(900)
|
||
|
|
mine_names = page.inner_text("#boardBox")
|
||
|
|
step("U25 mine 看板含本人新建项目", "A5UI-" in mine_names)
|
||
|
|
shot(page, "15-board-mine")
|
||
|
|
page.click("#tab-board [data-scope='manage']"); page.wait_for_timeout(800)
|
||
|
|
# 列表
|
||
|
|
page.click("nav.tabs button[data-tab='list']"); page.wait_for_timeout(800)
|
||
|
|
hdrs = page.evaluate("() => [...document.querySelectorAll('#tab-list thead th')].map(t => t.textContent)")
|
||
|
|
step("U26 列表列头=名称/阶段/状态/报备/赢输/客户/负责人/金额/创建时间", hdrs ==
|
||
|
|
["项目名称", "阶段", "状态", "报备", "赢/输", "客户", "负责人", "项目金额", "创建时间"], str(hdrs))
|
||
|
|
step("U27 列表对照原型缺列: NO./地区/销售经理/客户信息/来源商机/品牌/中标时间/操作",
|
||
|
|
not set(["NO.", "地区", "销售经理", "客户信息", "来源商机", "品牌", "中标时间", "操作"]) & set(hdrs))
|
||
|
|
stage_opts = page.evaluate("() => [...document.querySelectorAll('#fStage option')].map(o => o.value)")
|
||
|
|
step("U28 阶段筛=7值不含「报备中」", stage_opts == ["", "0", "1", "2", "3", "4", "5", "6"], str(stage_opts))
|
||
|
|
shot(page, "16-list-manage")
|
||
|
|
page.select_option("#fStage", "0"); page.wait_for_timeout(800)
|
||
|
|
lst = page.inner_text("#listBox")
|
||
|
|
step("U29 阶段筛 stage=0 命中新建冲突项目", nm2 in lst)
|
||
|
|
page.select_option("#fStage", ""); page.select_option("#fFiling", "1"); page.wait_for_timeout(800)
|
||
|
|
lst = page.inner_text("#listBox")
|
||
|
|
step("U30 报备独立筛 filing=1 命中", "审核中" in lst)
|
||
|
|
shot(page, "17-list-filtered")
|
||
|
|
# 分页(size=10,造 12+ 条后翻页)——直接用已存在的 A5AUD/A5DEMO 数据
|
||
|
|
page.select_option("#fFiling", ""); page.wait_for_timeout(600)
|
||
|
|
total1 = page.inner_text("#listTotal")
|
||
|
|
page.click(".pager button:has-text('下一页')"); page.wait_for_timeout(700)
|
||
|
|
pn = page.inner_text("#pageNo")
|
||
|
|
step("U31 分页可翻页", pn == "2", f"total={total1} page={pn}")
|
||
|
|
page.click(".pager button:has-text('上一页')"); page.wait_for_timeout(500)
|
||
|
|
|
||
|
|
# ============ 输单流程 ============
|
||
|
|
page.click(f"#listBox tr:has-text('{nm2}')"); page.wait_for_timeout(900)
|
||
|
|
page.click("#detailActions button:has-text('冲突解除')"); page.select_option("#filingApproved", "true")
|
||
|
|
page.click("#dlgFiling .dfoot .ok"); page.wait_for_selector("text=报备审核回填成功"); page.wait_for_timeout(500)
|
||
|
|
for b in ["推进(方案设计", "推进(商务报价"]:
|
||
|
|
page.click(f"#detailActions button:has-text('{b}')"); page.wait_for_selector("text=推进成功"); page.wait_for_timeout(400)
|
||
|
|
page.click("#detailActions button:has-text('无需招投标')"); page.wait_for_selector("text=推进成功"); page.wait_for_timeout(500)
|
||
|
|
page.click("#detailActions button:has-text('标记输单')"); page.wait_for_selector("text=标记成功"); page.wait_for_timeout(700)
|
||
|
|
head = page.inner_text("#detailHead")
|
||
|
|
step("U32 输单→结项(已结项)", ("结项" in head and "已结项" in head), head[:120])
|
||
|
|
shot(page, "18-detail-stage6-lose")
|
||
|
|
page.click("#detailSubtabs button[data-sub='timeline']"); page.wait_for_timeout(800)
|
||
|
|
tl = page.inner_text("#subBox")
|
||
|
|
step("U33 动态留痕: 输单", "标记输单" in tl, tl[:100])
|
||
|
|
# ============ 8 页签轮询 ============
|
||
|
|
for sub, expect in [("follow", "跟进内容"), ("attachment", "文件ID"), ("customer", "客户名称"),
|
||
|
|
("timeline", "操作人"), ("scheme", "概算金额"), ("quote", "报价总价"),
|
||
|
|
("bidding", "标底类型"), ("bidopen", "开标时间")]:
|
||
|
|
page.click(f"#detailSubtabs button[data-sub='{sub}']"); page.wait_for_timeout(700)
|
||
|
|
body = page.inner_text("#subBox")
|
||
|
|
step("U34 Tab[" + sub + "] 渲染含「" + expect + "」", ("加载失败" not in body and expect in body), body[:80])
|
||
|
|
page.click("#detailSubtabs button[data-sub='follow']"); page.wait_for_timeout(600)
|
||
|
|
page.fill("#flContent", "UI审计跟进"); page.click("text=新增跟进"); page.wait_for_selector("text=跟进已新增"); page.wait_for_timeout(700)
|
||
|
|
step("U35 跟进新增成功", True); shot(page, "19-follow-added")
|
||
|
|
# 客户页签字段
|
||
|
|
page.click("#detailSubtabs button[data-sub='customer']"); page.wait_for_timeout(700)
|
||
|
|
cu = page.inner_text("#subBox")
|
||
|
|
step("U36 客户页签缺 联系人/联系电话/客户等级(原型关联客户页签)",
|
||
|
|
all(k not in cu for k in ["客户联系人", "联系电话", "客户等级"]), cu[:120])
|
||
|
|
|
||
|
|
# ============ 普通销售视角 ============
|
||
|
|
page.evaluate("() => localStorage.removeItem('a5_token')")
|
||
|
|
page.fill("#uidInput", SALES); page.click("text=uid 登录"); page.wait_for_selector("text=登录成功")
|
||
|
|
page.wait_for_timeout(2500) # 等登录引发的 loadBoard/loadList 全部落定(demo 无请求去重,早读会被旧响应覆盖)
|
||
|
|
page.click("nav.tabs button[data-tab='list']"); page.wait_for_timeout(300)
|
||
|
|
page.click("#tab-list [data-scope='mine']"); page.wait_for_timeout(2500)
|
||
|
|
lst = page.inner_text("#listBox"); tot = page.inner_text("#listTotal")
|
||
|
|
step("U37 销售 mine 列表为空(无本人项目)", "无数据" in lst and "共 0 条" in tot, f"total={tot} lst={lst[:60]!r}")
|
||
|
|
page.click("#tab-list [data-scope='manage']"); page.wait_for_timeout(2000)
|
||
|
|
lst = page.inner_text("#listBox")
|
||
|
|
step("U38 销售 manage 列表可见本部门(含总监)项目(种子用户同部门,DataScope 合法)", "A5UI-" in lst or "A5AUD" in lst)
|
||
|
|
# 打开进行中的他人项目(PID_RB, stage2)→ 回退按钮可见
|
||
|
|
pid_rb = (AUD / "rbpid.txt").read_text().strip()
|
||
|
|
page.evaluate(f"() => openProject('{pid_rb}')"); page.wait_for_timeout(1200)
|
||
|
|
acts = page.inner_text("#detailActions")
|
||
|
|
step("U39 普通销售可见「回退…」按钮(原型: 仅管理员+销售总监可见)", "回退…" in acts, acts[:120])
|
||
|
|
page.click("#detailActions button:has-text('回退…')"); page.wait_for_timeout(900)
|
||
|
|
t = page.inner_text("#toast")
|
||
|
|
step("U40 销售提交回退被服务端拒 68005", "68005" in t, t)
|
||
|
|
shot(page, "20-sales-rollback-denied")
|
||
|
|
page.click("#dlgRollback .dfoot button:not(.ok)"); page.wait_for_timeout(300) # 失败后弹窗不自动关,手关
|
||
|
|
# 销售在他人 stage0 项目上可见冲突解除按钮并可操作(原型: 仅报备专员+管理员)
|
||
|
|
page.click("nav.tabs button[data-tab='list']"); page.wait_for_timeout(500)
|
||
|
|
page.select_option("#fStage", "0"); page.wait_for_timeout(1500)
|
||
|
|
row = page.query_selector("#listBox tr.row")
|
||
|
|
if row:
|
||
|
|
row.click(); page.wait_for_timeout(1200)
|
||
|
|
acts = page.inner_text("#detailActions")
|
||
|
|
step("U41 普通销售在他人 stage0 项目可见「冲突解除」并提交成功(原型: 仅报备专员+管理员)",
|
||
|
|
("冲突解除" in acts), acts[:120])
|
||
|
|
shot(page, "21-sales-filing-visible")
|
||
|
|
else:
|
||
|
|
step("U41 普通销售在他人 stage0 项目可见「冲突解除」", False, "无 stage0 行可验")
|
||
|
|
|
||
|
|
# ---------- 契约附带 ----------
|
||
|
|
step("U42 零 PUT/DELETE", not (methods & {"PUT", "DELETE"}), sorted(methods & {"PUT", "DELETE"}))
|
||
|
|
step("U43 console/page error 零", not console_errors and not page_errors, (console_errors + page_errors)[:2])
|
||
|
|
shot(page, "22-final")
|
||
|
|
browser.close()
|
||
|
|
|
||
|
|
passed = sum(1 for r in results if r["ok"])
|
||
|
|
print(f"\n==== UI {passed}/{len(results)} PASS ====")
|
||
|
|
(AUD / "ui-result.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")
|