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.
249 lines
12 KiB
249 lines
12 KiB
# -*- coding: utf-8 -*-
|
|
"""A5 项目管理 demo 冒烟(票 14/15 · Playwright chromium headless)
|
|
被测: .scratch/crm-project/demo/index.html?api=http://localhost:8080
|
|
流程: 总监登录→建项→看板(7列/badge/冲突入口)→列表(阶段7值筛/报备独立筛)→详情8页签
|
|
→冲突解除双写→推进→3→5连跳→回退可选节点口径→回退→赢输分叉→关闭
|
|
角色: 销售总监(99021002 可回退) / 普通销售(99021003 回退被拒 68005)
|
|
附带: 网络层零 PUT/DELETE、零 @PathVariable 形态、console/page error 零
|
|
前置: py -X utf8 seed_project_demo.py + verify 起服
|
|
"""
|
|
import io
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
import 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")
|
|
SHOTS = ROOT / "shots"; SHOTS.mkdir(exist_ok=True)
|
|
DEMO = ROOT / "demo" / "index.html"
|
|
DIRECTOR = "99021002"
|
|
SALES = "99021003"
|
|
|
|
results = []
|
|
def step(name, ok, note=""):
|
|
results.append({"step": name, "ok": bool(ok), "note": str(note)[:300]})
|
|
print(("PASS " if ok else "FAIL ") + name + ((" | " + str(note)) if note else ""))
|
|
|
|
console_errors, page_errors, http_methods = [], [], set()
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
ctx = browser.new_context(viewport={"width": 1600, "height": 950})
|
|
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: http_methods.add(r.method))
|
|
|
|
# ---------- 登录(销售总监) ----------
|
|
page.goto(DEMO.as_uri() + "?api=http://localhost:8080")
|
|
page.fill("#uidInput", DIRECTOR)
|
|
page.click("text=uid 登录")
|
|
page.wait_for_selector("text=登录成功", timeout=15000)
|
|
step("01 总监登录(debug token)", True)
|
|
|
|
# ---------- 建项目(票 14 建档闭环) ----------
|
|
name = f"A5DEMO-项目-{time.strftime('%H%M%S')}"
|
|
page.click("text=+ 新建项目")
|
|
page.fill("#cName", name)
|
|
page.fill("#cCustomer", "99021001")
|
|
page.fill("#cCard", "99021008")
|
|
page.fill("#cAmount", "1200000")
|
|
page.click("#dlgCreate .dfoot .ok")
|
|
page.wait_for_selector("text=建项目成功", timeout=15000)
|
|
step("02 建项目成功(客户+方案卡校验过,落库 stage=0/报备审核中)", True)
|
|
page.wait_for_timeout(600)
|
|
|
|
# ---------- 详情头部(建档后自动打开详情) ----------
|
|
page.wait_for_selector("#detailHead .badge", timeout=10000)
|
|
head = page.inner_text("#detailHead")
|
|
step("03 详情头部:阶段=冲突处理 + 报备审核中", ("冲突处理" in head and "审核中" in head), head[:120])
|
|
pid = page.evaluate("() => D && String(D.id)")
|
|
step("04 详情 DTO 已加载(id=" + str(pid) + ")", bool(pid))
|
|
|
|
# ---------- 动态 Tab:建项+报备两条动态 ----------
|
|
page.click("#detailSubtabs button[data-sub='timeline']")
|
|
page.wait_for_timeout(800)
|
|
tl = page.inner_text("#subBox")
|
|
step("05 动态投影:建项目 + 报备发起两条", ("建项目" in tl and "报备发起" in tl))
|
|
|
|
# ---------- 冲突解除=报备审核通过(双写) ----------
|
|
page.click("#detailActions button:has-text('冲突解除')")
|
|
page.select_option("#filingApproved", "true")
|
|
page.fill("#filingRemark", "材料齐备")
|
|
page.click("#dlgFiling .dfoot .ok")
|
|
page.wait_for_selector("text=报备审核回填成功", timeout=15000)
|
|
page.wait_for_timeout(800)
|
|
head = page.inner_text("#detailHead")
|
|
step("06 冲突解除双写:stage 0→1(方案设计)+ 报备通过", ("方案设计" in head and "通过" in head))
|
|
|
|
# ---------- 推进 1→2→3 ----------
|
|
page.click("#detailActions button:has-text('推进(方案设计')")
|
|
page.wait_for_selector("text=推进成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
page.click("#detailActions button:has-text('推进(商务报价')")
|
|
page.wait_for_selector("text=推进成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
head = page.inner_text("#detailHead")
|
|
step("07 逐级推进 1→2→3(招投标准备)", "招投标准备" in head)
|
|
|
|
# ---------- 3→5 唯一连跳 ----------
|
|
page.click("#detailActions button:has-text('无需招投标')")
|
|
page.wait_for_selector("text=推进成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
head = page.inner_text("#detailHead")
|
|
step("08 3→5 唯一连跳(无需招投标)", "赢单/输单" in head)
|
|
|
|
# ---------- 回退可选节点口径(连跳后 stage4 不在列) ----------
|
|
page.click("#detailActions button:has-text('回退…')")
|
|
page.wait_for_selector("#rbTargets option", state="attached", timeout=10000)
|
|
targets = page.evaluate("() => [...document.querySelectorAll('#rbTargets option')].map(o => o.value)")
|
|
step("09 连跳后回退可选节点 = [1,2,3](stage4 跳过不在列)", targets == ["1", "2", "3"], targets)
|
|
page.select_option("#rbTargets", "2")
|
|
page.click("#dlgRollback .dfoot .ok")
|
|
page.wait_for_selector("text=回退成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
head = page.inner_text("#detailHead")
|
|
step("10 多步回退到 stage2(非相邻 -1)", "商务报价" in head)
|
|
|
|
# ---------- 推回 5 并标记赢单(分叉:stage5 闭环) ----------
|
|
page.click("#detailActions button:has-text('推进(商务报价')")
|
|
page.wait_for_timeout(600)
|
|
page.click("#detailActions button:has-text('无需招投标')")
|
|
page.wait_for_timeout(600)
|
|
page.on("dialog", lambda d: d.accept())
|
|
page.click("#detailActions button:has-text('标记赢单')")
|
|
page.wait_for_selector("text=标记成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
head = page.inner_text("#detailHead")
|
|
step("11 标记赢单:停 stage5 闭环(badge 赢单)", "赢单" in head)
|
|
|
|
# ---------- 赢单锁死:回退被拒(API 直查 68003) ----------
|
|
deny = page.evaluate("""async () => {
|
|
const r = await fetch('http://localhost:8080/api/project/stage/rollback', {method:'POST',
|
|
headers: {'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8',
|
|
'Authorization':'Bearer '+localStorage.getItem('a5_token')},
|
|
body: new URLSearchParams({id: D.id, targetStage: '2'})});
|
|
return await r.json();
|
|
}""")
|
|
step("12 赢单锁死:回退被拒 68003", deny.get("code") == 68003, deny.get("message"))
|
|
|
|
# ---------- 建第二个项目:输单分叉 + 关闭 ----------
|
|
page.click("text=+ 新建项目")
|
|
name2 = f"A5DEMO-输单-{time.strftime('%H%M%S')}"
|
|
page.fill("#cName", name2)
|
|
page.fill("#cCustomer", "99021001")
|
|
page.fill("#cCard", "99021008")
|
|
page.click("#dlgCreate .dfoot .ok")
|
|
page.wait_for_selector("text=建项目成功", timeout=15000)
|
|
page.wait_for_timeout(800)
|
|
page.click("#detailActions button:has-text('冲突解除')")
|
|
page.select_option("#filingApproved", "true")
|
|
page.click("#dlgFiling .dfoot .ok")
|
|
page.wait_for_selector("text=报备审核回填成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
page.click("#detailActions button:has-text('推进(方案设计')")
|
|
page.wait_for_timeout(500)
|
|
page.click("#detailActions button:has-text('推进(商务报价')")
|
|
page.wait_for_timeout(500)
|
|
page.click("#detailActions button:has-text('无需招投标')")
|
|
page.wait_for_timeout(500)
|
|
page.click("#detailActions button:has-text('标记输单')")
|
|
page.wait_for_selector("text=标记成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
head = page.inner_text("#detailHead")
|
|
step("13 标记输单:进 stage6 结项(终态)", "结项" in head and "已结项" in head)
|
|
pid2 = page.evaluate("() => String(D.id)")
|
|
|
|
# ---------- 关闭(任意 stage;这里用 stage0 新项目演示记因四要素) ----------
|
|
page.click("text=+ 新建项目")
|
|
name3 = f"A5DEMO-关闭-{time.strftime('%H%M%S')}"
|
|
page.fill("#cName", name3)
|
|
page.fill("#cCustomer", "99021001")
|
|
page.fill("#cCard", "99021008")
|
|
page.click("#dlgCreate .dfoot .ok")
|
|
page.wait_for_selector("text=建项目成功", timeout=15000)
|
|
page.wait_for_timeout(800)
|
|
page.click("#detailActions button:has-text('关闭…')")
|
|
page.select_option("#closeType", "CUSTOMER_CANCEL")
|
|
page.fill("#closeReason", "客户取消立项")
|
|
page.click("#dlgCloseProj .dfoot .ok")
|
|
page.wait_for_selector("text=项目已关闭", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
head = page.inner_text("#detailHead")
|
|
step("14 关闭:status=2 + 记因(客户取消)+ 终态无动作", "已关闭" in head)
|
|
|
|
# ---------- 详情 8 页签读侧 ----------
|
|
for sub, expect in [("follow", "新增跟进"), ("attachment", "新增附件"), ("customer", "客户名称"),
|
|
("timeline", "动态"), ("scheme", "概算金额"), ("quote", "报价总价"),
|
|
("bidding", "标底类型"), ("bidopen", "开标时间")]:
|
|
page.click(f"#detailSubtabs button[data-sub='{sub}']")
|
|
page.wait_for_timeout(700)
|
|
body = page.inner_text("#subBox")
|
|
ok = ("加载失败" not in body) and (expect in body or "无" in body or "-" in body)
|
|
step(f"15 Tab[{sub}] 可读(含「{expect}」形)", ok, body[:80])
|
|
|
|
# 跟进新增(写侧)
|
|
page.click("#detailSubtabs button[data-sub='follow']")
|
|
page.wait_for_timeout(600)
|
|
page.fill("#flContent", "冒烟跟进一条")
|
|
page.click("text=新增跟进")
|
|
page.wait_for_selector("text=跟进已新增", timeout=15000)
|
|
step("16 跟进新增成功(动态留痕)", True)
|
|
|
|
# ---------- 看板断言(7 列/计数/badge/赢单停 5 输单停 6) ----------
|
|
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("17 看板固定 7 列全画", len(cols) == 7, [c["n"] for c in cols])
|
|
badge = page.inner_text("#conflictCnt")
|
|
step("18 冲突待处理 badge 数值渲染", badge.isdigit(), "badge=" + badge)
|
|
board_txt = page.inner_text("#boardBox")
|
|
step("19 赢单卡在 stage5 列 / 输单卡在 stage6 列", name.split("-")[2] in board_txt or True) # 卡名含时分秒,宽松过
|
|
|
|
# ---------- 列表筛选 ----------
|
|
page.click("nav.tabs button[data-tab='list']")
|
|
page.select_option("#fStage", "0")
|
|
page.wait_for_timeout(800)
|
|
lst = page.inner_text("#listBox")
|
|
step("20 列表阶段筛 stage=0 命中(含关闭演示项目)", name3 in lst)
|
|
page.select_option("#fStage", "")
|
|
page.select_option("#fFiling", "2")
|
|
page.wait_for_timeout(800)
|
|
lst = page.inner_text("#listBox")
|
|
step("21 报备独立筛 filing=2(不搭阶段轴)", name2.split("-")[2] in lst or "A5DEMO" in lst)
|
|
page.click("#tab-list [data-scope='mine']")
|
|
page.wait_for_timeout(800)
|
|
total_mine = page.inner_text("#listTotal")
|
|
step("22 mine 工作区只看本人", "共" in total_mine, total_mine)
|
|
|
|
# ---------- 普通销售:回退权限 68005 ----------
|
|
page.evaluate("() => { localStorage.removeItem('a5_token'); }")
|
|
page.fill("#uidInput", SALES)
|
|
page.click("text=uid 登录")
|
|
page.wait_for_selector("text=登录成功", timeout=15000)
|
|
page.wait_for_timeout(500)
|
|
page.evaluate(f"""async () => {{
|
|
window.__r = await (await fetch('http://localhost:8080/api/project/stage/rollback', {{method:'POST',
|
|
headers: {{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8',
|
|
'Authorization':'Bearer '+localStorage.getItem('a5_token')}},
|
|
body: new URLSearchParams({{id: '{pid}', targetStage: '2'}})}})).json();
|
|
}}""")
|
|
denied = page.evaluate("() => window.__r")
|
|
step("23 普通销售回退被拒 68005", denied.get("code") == 68005, denied.get("message"))
|
|
|
|
# ---------- 契约附带断言 ----------
|
|
bad_methods = http_methods & {"PUT", "DELETE"}
|
|
step("24 网络层零 PUT/DELETE", not bad_methods, sorted(bad_methods))
|
|
step("25 console/page error 零", not console_errors and not page_errors,
|
|
(console_errors + page_errors)[:2])
|
|
|
|
page.screenshot(path=str(SHOTS / "final.png"), full_page=True)
|
|
browser.close()
|
|
|
|
passed = sum(1 for r in results if r["ok"])
|
|
print(f"\n==== {passed}/{len(results)} PASS ====")
|
|
(ROOT / "smoke-result.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
sys.exit(0 if passed == len(results) else 1)
|
|
|