Commit 57cf814d authored by wenwen.tang's avatar wenwen.tang 😕

test(ui): ui_verify1-7 加 RENTOS_BASE 闸门并适配账期折叠,新增 ui_verify11

parent 9326844a
......@@ -4,9 +4,9 @@
====================================
台账(房租车位费)→ 改单元格 → 增行 → 删行 → 搜索 → 电费手工录入 → 还原现场。
运行:
python tools/ui_verify.py
RENTOS_BASE=http://127.0.0.1:8901 python tools/ui_verify.py
运行(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱):
python tools/ui_verify_runner.py tools/ui_verify.py
RENTOS_ALLOW_REAL=1 python tools/ui_verify.py # 确需直连真实服务才这样用
覆盖的改造点:
* 只有一张台账「房租车位费」(旧的两张已退休)
......@@ -21,7 +21,18 @@ warnings.filterwarnings("ignore")
from playwright.sync_api import sync_playwright
BASE = os.environ.get("RENTOS_BASE", "http://127.0.0.1:8899")
BASE = os.environ.get("RENTOS_BASE", "")
if not BASE:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if os.environ.get("RENTOS_ALLOW_REAL") == "1":
BASE = "http://127.0.0.1:8899"
else:
_me = os.path.basename(__file__)
sys.exit("已阻止:%s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。\n"
" 沙箱运行: python tools/ui_verify_runner.py tools/%s\n"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/%s" % (_me, _me, _me))
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
os.makedirs(OUT, exist_ok=True)
......
# -*- coding: utf-8 -*-
"""
「电费批次重编 + 逐室核对 + 电费手动发送 + 3F/6F/7F 总表」端到端验证(2026-10-13)
=================================================================================
对应需求(巴特菲):
C. 批次台账里的数据要能「重新编辑」→ 回录入页改完覆盖原批次;批次台账加「核对」
按钮逐室 ✔;批次详情显示核对状态;**未确认核对不能发送**;电费**全部手动发送**
(设置里的电费自动发送去掉)。
D. 新增 3F / 6F / 7F 总表,在录入本期电费时一并录入(明细类目 / 上期 / 本期 /
用量 / 电价 / 系数),**不发账单,只支持导出**。
做法:**自带沙箱**(复制 data/ + 改写路径常量 + 另起端口),真浏览器跑一遍。
真实 data/ 零改动;沙箱内的 config.json 是副本,企微发送不会被触发
(发送只验闸门,不真的点发送)。
运行:python tools/ui_verify11.py
"""
import io
import os
import shutil
import sys
import tempfile
import threading
import urllib.request
from http.server import ThreadingHTTPServer
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402
from rentos import tables # noqa: E402
PORT = int(os.environ.get("RENTOS_VERIFY_PORT", "8907"))
BASE = "http://127.0.0.1:%d" % PORT
OUT = os.path.join(ROOT, "output", "ui_shots")
OK, FAIL = [], []
def ck(name, cond, extra=""):
(OK if cond else FAIL).append(name)
print(" [%s] %s%s" % ("ok" if cond else "FAIL", name,
(" → %s" % extra) if extra != "" else ""))
def start_sandbox():
tmp = tempfile.mkdtemp(prefix="rentos-verify11-")
shutil.copytree(os.path.join(ROOT, "data"), os.path.join(tmp, "data"))
data = os.path.join(tmp, "data")
for name, val in {
"DATA_DIR": data,
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
"UNIT_MAP_FILE": os.path.join(data, "unit_map.json"),
"UPLOAD_DIR": os.path.join(tmp, "uploads"),
"OUTPUT_DIR": os.path.join(tmp, "output", "elec"),
"OUTPUT_RENT_DIR": os.path.join(tmp, "output", "rent"),
"TABLE_EXPORT_DIR": os.path.join(tmp, "output", "tables"),
"BATCH_EXPORT_DIR": os.path.join(tmp, "output", "exports"),
"LOG_DIR": os.path.join(tmp, "logs"),
}.items():
setattr(C, name, val)
C.ensure_dirs()
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, tmp
def pick_billable_unit(want=3):
"""沙箱台账里挑几个「房间 + 有租客 + 有出租方」的行。"""
out = []
t = tables.load_table(tables.RENT_KEY)
for r in (t or {}).get("rows") or []:
unit = str(r.get(tables.BILL_COLS["unit"]) or "").strip()
if not unit or "车位" in unit:
continue
if not str(r.get(tables.BILL_COLS["tenant"]) or "").strip():
continue
if "空置" in str(r.get(tables.BILL_COLS["tenant"]) or ""):
continue
if not str(r.get(tables.BILL_COLS["owner"]) or "").strip():
continue
if unit not in out:
out.append(unit)
if len(out) >= want:
break
return out
def main():
os.makedirs(OUT, exist_ok=True)
srv, tmp = start_sandbox()
print("沙箱数据目录:%s" % tmp)
print("沙箱服务:%s(真实 data/ 未被触碰)" % BASE)
units = pick_billable_unit(3)
print("沙箱造数:台账房间 %s" % "、".join(units))
assert units, "沙箱台账里找不到可出单的房间"
U1, U2 = units[0], (units[1] if len(units) > 1 else units[0])
PERIOD_U = "2099年01月" # 专属账期:核对分组里只会有本次这张批次
from playwright.sync_api import sync_playwright
errors = []
try:
with sync_playwright() as pw:
b = pw.chromium.launch()
p = b.new_page(viewport={"width": 1680, "height": 1000})
p.on("console", lambda m: errors.append("console:" + m.text)
if (m.type == "error" and "favicon" not in m.text.lower()
and "Failed to load resource" not in m.text) else None)
p.on("pageerror", lambda e: errors.append("pageerror:" + str(e)))
p.on("dialog", lambda d: d.accept()) # 原生 confirm 一律确认
def js(fn, *a):
return p.evaluate(fn, *a) if a else p.evaluate(fn)
def api_call(method, path, body=None):
return js("""async ([m, path, body]) => {
const t = localStorage.getItem('rentos_token');
const r = await fetch(path, {method: m,
headers: Object.assign({'Content-Type': 'application/json'},
t ? {Authorization: 'Bearer ' + t} : {}),
body: body === null ? undefined : JSON.stringify(body)});
let d = {}; try { d = await r.json(); } catch (e) {}
return [r.status, d];
}""", [method, path, body])
def goto(h):
p.evaluate("(h) => { location.hash = '#/' + h; }", h)
p.wait_for_timeout(1200)
def login():
p.goto(BASE + "/login.html")
p.fill("#u", "admin")
p.fill("#p", "1989@rent05")
p.click("button[type=submit]")
p.wait_for_url("**/admin.html")
p.wait_for_function("() => location.hash.length > 2", timeout=20000)
p.wait_for_timeout(800)
# ==================================================== 1 总表渲染
print("\n1. 总表(3F/6F/7F)录入表格")
login()
goto("elec")
p.wait_for_timeout(800)
nrows = js("() => document.querySelectorAll('#mBody tr').length")
ck("总表共 9 行(3 层 × 3 类目)", nrows == 9, str(nrows))
floors = js("() => [...document.querySelectorAll('#mBody td[rowspan]')]"
".map(td => td.textContent.trim())")
ck("楼层按 3F / 6F / 7F 分组(合并单元格)", floors == ["3F", "6F", "7F"],
str(floors))
labels = js("""() => [...document.querySelectorAll('#mBody tr')].map(tr => {
const tds = [...tr.children];
const lab = tds.find(td => !td.hasAttribute('rowspan')
&& !td.querySelector('input') && !td.hasAttribute('data-mqty')
&& !td.hasAttribute('data-mamt'));
return lab ? lab.textContent.trim() : '';
})""")
ck("类目依次是 公共 / 套内 / 空调总",
labels == ["公共", "套内", "空调总"] * 3, str(labels))
spec = api_call("GET", "/api/masters")[1]
ck("接口 /api/masters 下发规格(3 层 × 3 类目 + 默认系数)",
spec.get("floors") == ["3F", "6F", "7F"]
and [i["factor"] for i in spec.get("items", [])] == [12.25, 1.0, 12.25],
str(spec.get("items")))
# 3F 公共:12000 → 12500 = 500 度 × 1.2 × 12.25
p.fill('#mBody tr[data-mi="0"] [data-mk="prev"]', "12000")
p.fill('#mBody tr[data-mi="0"] [data-mk="cur"]', "12500")
p.wait_for_timeout(400)
q = p.eval_on_selector('#mBody tr[data-mi="0"] [data-mqty]',
"e => e.innerText.trim()")
amt = p.eval_on_selector('#mBody tr[data-mi="0"] [data-mamt]',
"e => e.innerText.trim()")
ck("3F 公共:用量 = 本期 − 上期 = 500", q == "500", q)
ck("3F 公共:金额 = 500 × 1.2 × 12.25 = ¥7,350.00", "7,350" in amt, amt)
# 7F 套内(系数 1.0):0 → 100
p.fill('#mBody tr[data-mi="7"] [data-mk="prev"]', "0")
p.fill('#mBody tr[data-mi="7"] [data-mk="cur"]', "100")
p.wait_for_timeout(400)
amt2 = p.eval_on_selector('#mBody tr[data-mi="7"] [data-mamt]',
"e => e.innerText.trim()")
ck("7F 套内:系数 1.0 → 金额 ¥120.00", "120" in amt2, amt2)
# 6F 公共:只填上期 → 未填本期
p.fill('#mBody tr[data-mi="3"] [data-mk="prev"]', "999")
p.wait_for_timeout(400)
q3 = p.eval_on_selector('#mBody tr[data-mi="3"] [data-mqty]',
"e => e.innerText.trim()")
ck("只填上期 → 标「未填本期」", "未填本期" in q3, q3)
s = p.inner_text("#mSum")
ck("总表小计:3 行、¥7,470.00",
"3" in s and "7,470" in s, s.replace("\n", " "))
p.screenshot(path=os.path.join(OUT, "V11-1-masters.png"), full_page=True)
p.click("#btn-m-clear")
p.wait_for_timeout(400)
left = js("() => [...document.querySelectorAll('#mBody [data-mk=\"cur\"]')]"
".filter(i => i.value !== '').length")
ck("「清空总表」把已填行清空", left == 0, str(left))
# ==================================================== 2 一并录入 → 批次
print("\n2. 与本期电费一并录入 → 出单(总表不出单)")
goto("elec")
p.click("#btn-e-clear")
p.wait_for_timeout(300)
# 用一个专属账期,保证该账期的核对分组里只有本次这一张批次(沙箱里还留着真实台账的历史批次)
p.fill("#in-period", PERIOD_U)
p.click("#btn-e-row")
p.click("#btn-e-row") # 两行:同一房间的「照明 + 空调」→ 可按行分别核对
p.wait_for_timeout(300)
row = '#eBody tr[data-i="0"] '
p.fill(row + '[data-k="unit"]', U1)
p.fill(row + '[data-k="meter"]', "照明")
p.fill(row + '[data-k="prev"]', "100")
p.fill(row + '[data-k="cur"]', "180")
row2 = '#eBody tr[data-i="1"] '
p.fill(row2 + '[data-k="unit"]', U1)
p.fill(row2 + '[data-k="meter"]', "空调")
p.fill(row2 + '[data-k="prev"]', "100")
p.fill(row2 + '[data-k="cur"]', "200")
p.wait_for_timeout(300)
# 总表:3F 公共 0 → 500、7F 空调总 0 → 10
p.fill('#mBody tr[data-mi="0"] [data-mk="prev"]', "0")
p.fill('#mBody tr[data-mi="0"] [data-mk="cur"]', "500")
p.fill('#mBody tr[data-mi="8"] [data-mk="prev"]', "0")
p.fill('#mBody tr[data-mi="8"] [data-mk="cur"]', "10")
p.wait_for_timeout(300)
before = api_call("GET", "/api/batches?kind=elec")[1].get("batches") or []
p.click("#btn-e-submit")
p.wait_for_timeout(3000)
res = p.inner_text("#uploadResult") if p.query_selector("#uploadResult") else ""
ck("生成成功(提示里带总表信息)",
"已生成" in res and "楼层总表已存" in res, res.replace("\n", " | ")[:160])
after = api_call("GET", "/api/batches?kind=elec")[1].get("batches") or []
ck("批次数量 +1", len(after) == len(before) + 1,
"%d → %d" % (len(before), len(after)))
bid = after[0]["batch_id"]
me = after[0]
ck("列表里标出「总表×2」", me.get("master_rows") == 2, str(me.get("master_rows")))
st, det = api_call("GET", "/api/batches/" + bid)
bt = det.get("batch") or {}
docs = bt.get("documents") or []
ck("总表 2 行随批次留档", len(bt.get("masters") or []) == 2,
str(len(bt.get("masters") or [])))
ck("总表不产生通知书(只有房间那份)", len(docs) == 1, "%d 份" % len(docs))
exp_master = round(500 * 1.2 * 12.25 + 10 * 1.2 * 12.25, 2)
ck("汇总:总表金额单列、不并入出单金额",
abs(float(bt["totals"].get("master_amount") or 0) - exp_master) < 0.01
and abs(float(bt["totals"]["document_amount"])
- float(bt["totals"]["total_amount"])) < 0.01,
"master=%s doc=%s" % (bt["totals"].get("master_amount"),
bt["totals"].get("document_amount")))
ck("通知书里没有总表类目",
all("公共" not in "".join(d.get("units") or []) for d in docs), str(docs[:1]))
# 批次详情展示
goto("elec")
p.wait_for_timeout(1200)
bodytxt = p.inner_text("#detailBody")
alltxt = js("() => (document.querySelector('#detailBody') || {}).textContent || ''")
ck("批次详情能看到「查看楼层总表」", "查看楼层总表" in bodytxt,
bodytxt[:80].replace("\n", " "))
ck("详情里能看到 3F / 公共 行(展开后可见)",
"3F" in alltxt and "公共" in alltxt,
[s for s in ("3F", "公共", "空调总") if s in alltxt])
p.click("#detailBody details summary")
p.wait_for_timeout(600)
p.screenshot(path=os.path.join(OUT, "V11-2-detail.png"), full_page=True)
# 导出 xlsx:应有「总表」Sheet
import openpyxl
tok = js("() => localStorage.getItem('rentos_token')")
req = urllib.request.Request(BASE + "/api/batches/%s/export" % bid,
headers={"Authorization": "Bearer " + tok})
xp = os.path.join(tmp, "export.xlsx")
with open(xp, "wb") as f:
f.write(urllib.request.urlopen(req).read())
wb = openpyxl.load_workbook(xp)
sheets = list(wb.sheetnames)
ws = wb["总表"] if "总表" in sheets else None
ck("导出的 xlsx 多一个「总表」Sheet",
sheets == ["单据", "明细", "总表", "汇总"], str(sheets))
ok_sheet = False
if ws:
head = [ws.cell(3, i).value for i in range(1, 11)]
ok_sheet = (head[0] == "序号" and head[1] == "楼层" and head[2] == "明细类目"
and abs(float(ws.cell(4, 9).value or 0)
- round(500 * 1.2 * 12.25, 2)) < 0.01)
ck("「总表」Sheet 表头与金额正确", ok_sheet, str(head) if ws else "缺 Sheet")
wb.close()
# ==================================================== 3 批次台账汇总 + 重新编辑
print("\n3. 批次台账按账期(月)汇总;重新编辑挪到批次详情")
goto("elec")
p.wait_for_timeout(1500)
grp = js("""() => {
const tbs = document.querySelectorAll('#batchList tbody.pgrp');
return {n: tbs.length,
collapsed: document.querySelectorAll('#batchList tbody.pgrp.collapsed').length,
first: tbs[0] ? (tbs[0].dataset.pgrp || '') : '',
rows: tbs[0] ? tbs[0].querySelectorAll('tr.prow').length : 0};
}""")
ck("批次台账按账期汇总成分组(与「按账期月份留档统计」一致)",
grp["n"] >= 1, str(grp))
ck("同一个账期只汇总成一行(不再一行行摊开)",
grp["n"] == js("() => document.querySelectorAll('#batchList tr.grp').length"),
"分组 %s / 汇总头 %s" % (grp["n"], js("() => document.querySelectorAll('#batchList tr.grp').length")))
ck("默认折叠:只显示账期汇总行", grp["collapsed"] == grp["n"], str(grp))
ck("折叠时批次行不渲染出来",
js("""() => { const t = document.querySelector('#batchList tbody.pgrp.collapsed tr.prow');
return !t || getComputedStyle(t).display === 'none'; }""") is True)
ck("汇总头写明账期 / 批次数 / 核对进度",
js("""() => { const g = document.querySelector('#batchList tr.grp');
return g && /账期/.test(g.textContent) && /个批次/.test(g.textContent)
&& /核对 \\d+\\/\\d+ 行/.test(g.textContent); }""") is True,
js("() => (document.querySelector('#batchList tr.grp') || {}).textContent || ''")
.replace("\n", " ")[:120])
ck("列表里不再有「重新编辑」按钮",
js("() => document.querySelectorAll('#batchList [data-reingest]').length") == 0)
ck("列表里不再有「核对」按钮",
js("() => document.querySelectorAll('#batchList [data-verify-period]').length") == 0)
ck("批次行仍有「详情」入口",
js("() => document.querySelectorAll('#batchList tr.prow [data-open]').length") >= 1)
p.screenshot(path=os.path.join(OUT, "V11-6-period-summary.png"), full_page=True)
# 展开账期 → 打开批次详情 → 在详情里重新编辑
p.click('#batchList tbody.pgrp tr.grp')
p.wait_for_timeout(500)
ck("点汇总头可展开该账期的批次",
js("() => document.querySelectorAll('#batchList tbody.pgrp.collapsed').length")
< grp["n"])
p.click('#batchList tr.prow[data-open="%s"]' % bid)
p.wait_for_timeout(2200)
ck("批次详情已打开(详情卡可见)",
js("() => document.querySelector('#detailCard').style.display !== 'none'") is True)
ck("详情页有「核对」按钮",
js("() => !!document.querySelector('#btn-detail-verify')") is True)
ck("详情页有「重新编辑」按钮",
js("() => !!document.querySelector('#btn-detail-reingest')") is True)
p.click("#btn-detail-reingest")
p.wait_for_timeout(2200)
bar = p.inner_text("#eModeBar")
ck("详情里点「重新编辑」→ 进入「重新编辑中」模式条",
"重新编辑中" in bar, bar.replace("\n", " ")[:100])
n_room = js("() => document.querySelectorAll('#eBody tr[data-i]').length")
ck("录入表格载入了原批次明细(2 行)", n_room == 2, str(n_room))
mv = js("() => document.querySelector('#mBody tr[data-mi=\"0\"] [data-mk=\"cur\"]').value")
ck("总表也被载回(3F 公共 本期 = 500)", str(mv) == "500", str(mv))
ck("提交按钮文案变「保存重编」",
p.inner_text("#btn-e-submit").strip() == "保存重编",
p.inner_text("#btn-e-submit"))
p.fill('#eBody tr[data-i="0"] [data-k="cur"]', "200")
p.wait_for_timeout(300)
p.click("#btn-e-submit")
p.wait_for_timeout(3000)
res2 = p.inner_text("#uploadResult")
ck("重编成功(提示带「重编」)", "重编" in res2, res2.replace("\n", " | ")[:120])
st, d2 = api_call("GET", "/api/batches/" + bid)
b2 = d2.get("batch") or {}
ck("批次号不变(覆盖式重编)", b2.get("batch_id") == bid, str(b2.get("batch_id")))
ck("重编次数记 1", int(b2.get("reingest_count") or 0) == 1,
str(b2.get("reingest_count")))
ck("总表随重编保留(没传就沿用)", len(b2.get("masters") or []) == 2,
str(len(b2.get("masters") or [])))
ck("重编后核对状态被重置",
(d2.get("verify") or {}).get("done") is False,
str(d2.get("verify")))
# ==================================================== 4 账期核对(入口在批次详情)
print("\n4. 账期级核对:入口在批次详情,未确认不允许发送,支持部分核对")
st, snd = api_call("POST", "/api/batches/%s/send" % bid, {})
ck("未确认核对就发送 → 409", st == 409,
"HTTP %s %s" % (st, snd.get("error", "")))
goto("elec")
p.wait_for_timeout(1500)
p.click('#batchList tbody.pgrp tr.grp') # 展开账期
p.wait_for_timeout(400)
p.click('#batchList tr.prow[data-open="%s"]' % bid)
p.wait_for_timeout(2200)
bodytxt = p.inner_text("#detailBody")
ck("批次详情显示核对状态条(未完成)", "核对状态" in bodytxt,
bodytxt[:90].replace("\n", " "))
ck("详情提示去「核对」", "去核对" in bodytxt or "核对" in bodytxt,
bodytxt[:120].replace("\n", " "))
p.click("#btn-detail-verify")
p.wait_for_timeout(2400)
bar2 = p.inner_text("#eModeBar")
ck("进入「账期核对中」模式条", "账期核对中" in bar2, bar2.replace("\n", " ")[:110])
ck("模式条写明账期与批次数", PERIOD_U in bar2, bar2.replace("\n", " ")[:110])
ck("模式条说明按行核对、可部分核对",
"按行勾选" in bar2 and "只核对照明或空调" in bar2,
bar2.replace("\n", " ")[:160])
ck("总表区块在核对模式下隐藏",
js("""() => { const b = document.querySelector('#masterBlock');
return !b || b.style.display === 'none'; }""") is True)
ck("核对模式下列是只读的(没有删除按钮)",
js("() => !!document.querySelector('#eBody [data-delrow]')") is False)
ck("有「列显示」按钮(可只展示想核对的列)",
js("() => !!document.querySelector('#btn-vcols')") is True)
ck("一行都没勾时「确认核对」禁用",
p.is_disabled("#e-vf-done") is True)
# ---- 列筛选:只留 房间号 / 类目 / 上期 / 本期,其余隐藏
p.click("#btn-vcols")
p.wait_for_timeout(300)
ck("列显示菜单弹出", js("() => !document.querySelector('#vcolsMenu').hidden") is True)
for k in ("qty", "price", "factor", "company", "owner", "note", "amt", "act"):
p.click('[data-vcol="%s"]' % k)
p.wait_for_timeout(300)
vis = js("""() => {
const shown = [...document.querySelectorAll('#eGrid thead th[data-col]')]
.filter(th => th.style.display !== 'none').map(th => th.dataset.col);
return shown;
}""")
ck("只展示勾选的列(房间号 / 类目 / 上期 / 本期 / 核对)",
sorted(vis) == sorted(["unit", "meter", "prev", "cur", "vcol"]), str(vis))
ck("隐藏的列在表体里也不渲染",
js("""() => { const td = document.querySelector('#eBody td[data-col="price"]');
return !td || td.style.display === 'none'; }""") is True)
ck("列选择已存 localStorage(重渲染后仍在)",
js("() => (localStorage.getItem('rentos_vcols') || '').indexOf('unit') >= 0") is True)
p.screenshot(path=os.path.join(OUT, "V11-7-vcols.png"), full_page=True)
p.click("#btn-vcols") # 收起菜单
p.wait_for_timeout(200)
# ---- 逐行核对:只勾第一行(部分核对 / 只核对某一类目)
nrow = js("() => document.querySelectorAll('#eBody [data-vrow]').length")
ck("核对列按行给复选框(不是按房间)", nrow >= 1, str(nrow))
rk = js("""() => { const c = document.querySelector('#eBody [data-vrow]');
return c ? c.dataset.vrow : ''; }""")
ck("行键是「单元|类目」", "|" in rk, rk)
p.click('#eBody [data-vrow="%s"]' % rk)
p.wait_for_timeout(1600)
sm = p.inner_text("#eSum")
ck("只勾一行 → 提示部分核对", "部分核对" in sm, sm.replace("\n", " "))
ck("部分核对时「确认核对」可点", p.is_disabled("#e-vf-done") is False)
st, d2b = api_call("GET", "/api/batches/" + bid)
ck("进度已落到服务端(1 行)",
len((d2b.get("verify") or {}).get("rows") or []) == 1,
str((d2b.get("verify") or {}).get("rows")))
p.click("#e-vf-done")
p.wait_for_timeout(900)
ck("弹出确认框(.modal-mask.show)",
js("() => !!document.querySelector('#cfm.show')") is True)
cfm = js("() => (document.querySelector('#cfm') || {}).textContent || ''")
ck("确认框标明「部分核对」并列出未核对行数",
"部分核对" in cfm and "未核对" in cfm, cfm.replace("\n", " ")[:140])
p.screenshot(path=os.path.join(OUT, "V11-8-partial-confirm.png"), full_page=True)
p.click("#cfmOk")
p.wait_for_timeout(2800)
st, d3 = api_call("GET", "/api/batches/" + bid)
vs = d3.get("verify") or {}
ck("部分核对也确认成功(ok = True)", vs.get("ok") is True, str(vs))
ck("标记为部分核对(partial = True)", vs.get("partial") is True, str(vs))
ck("核对人 / 时间已记录", bool(vs.get("by")) and bool(vs.get("at")),
"%s @ %s" % (vs.get("by"), vs.get("at")))
st, ps = api_call("GET", "/api/periods?kind=elec")
mine = next((x for x in (ps.get("periods") or []) if x["period"] == PERIOD_U), {})
ck("接口 /api/periods 该账期 ok=True 且 partial=True",
mine.get("ok") is True and mine.get("partial") is True,
"ok=%s partial=%s" % (mine.get("ok"), mine.get("partial")))
bodytxt2 = p.inner_text("#detailBody")
ck("批次详情显示「已核对」并注明部分核对",
"已核对" in bodytxt2 and "部分核对" in bodytxt2,
bodytxt2[:110].replace("\n", " "))
ck("详情状态条注明「按账期统一核对」", "按账期统一核对" in bodytxt2,
bodytxt2[:110].replace("\n", " "))
ck("发送按钮已解禁", p.is_disabled("#btn-send-all") is False)
st2, snd2 = api_call("POST", "/api/batches/%s/send" % bid, {})
ck("部分核对后发送闸门也放行(不再 409)", st2 != 409,
"HTTP %s %s" % (st2, snd2.get("error", "")))
# ---- 部分核对发送:前端再提醒一次
p.click("#btn-send-all")
p.wait_for_timeout(900)
ck("部分核对时发送前弹出二次提醒",
js("() => !!document.querySelector('#cfm.show')") is True)
cfm2 = js("() => (document.querySelector('#cfm') || {}).textContent || ''")
ck("提醒里写明还有几行未核对", "部分核对" in cfm2 and "未核对" in cfm2,
cfm2.replace("\n", " ")[:140])
p.click("#cfmCancel")
p.wait_for_timeout(600)
p.screenshot(path=os.path.join(OUT, "V11-9-verified.png"), full_page=True)
# ---- 核对回退:取消勾选 → 确认自动撤销
p.click("#btn-detail-verify")
p.wait_for_timeout(2200)
ck("重新进入核对时保留已勾选的行",
js("""() => { const cs = [...document.querySelectorAll('#eBody [data-vrow]')];
return cs.length > 0 && cs[0].checked; }""") is True)
p.click("#e-vf-none")
p.wait_for_timeout(1600)
st, d4 = api_call("GET", "/api/batches/" + bid)
ck("取消勾选后确认自动撤销", (d4.get("verify") or {}).get("ok") is False,
str(d4.get("verify")))
st, ps2 = api_call("GET", "/api/periods?kind=elec")
mine2 = next((x for x in (ps2.get("periods") or []) if x["period"] == PERIOD_U), {})
ck("接口 /api/periods 同步回落到未确认",
mine2.get("ok") is False and mine2.get("verified") == 0,
"%s/%s" % (mine2.get("verified"), mine2.get("total")))
p.click("#e-mode-cancel")
p.wait_for_timeout(1200)
# ==================================================== 5 定时发送设置不再出现电费
print("\n5. 设置页:电费已从「定时发送设置」里删掉,无冗余展示")
goto("settings")
p.wait_for_timeout(1800)
keys = js("""() => [...document.querySelectorAll('#planRows tr[data-plan]')]
.map(tr => tr.dataset.plan)""")
ck("计划表里只剩「房租车位费」一行", keys == ["rent_parking"], str(keys))
ck("电费行已彻底删除(不是灰掉占位)",
js("() => !document.querySelector('#planRows tr[data-plan=\"elec\"]')") is True)
setxt = p.inner_text("#page-settings")
ck("设置页不再出现「出租方 · 汇款公司」卡片", "汇款公司" not in setxt,
"仍出现" if "汇款公司" in setxt else "已移除")
ck("定时发送说明里也不再提电费配置", "电费通知单" not in setxt
or "不在此配置" in setxt)
st, sc = api_call("GET", "/api/scheduler")
sched = sc.get("scheduler") or {}
by = {x["key"]: x for x in (sched.get("plans") or [])}
ck("接口:电费 auto=False / state=manual(后端仍是手动口径)",
by.get("elec", {}).get("auto") is False
and by.get("elec", {}).get("state") == "manual", str(by.get("elec"))[:120])
ck("接口:房租车位费仍是自动排期", by.get("rent_parking", {}).get("auto") is True,
str(by.get("rent_parking", {}).get("auto")))
ck("manual_count = 1", sched.get("manual_count") == 1, str(sched.get("manual_count")))
p.screenshot(path=os.path.join(OUT, "V11-5-settings.png"), full_page=True)
goto("companies")
p.wait_for_timeout(1600)
cotxt = p.inner_text("#page-companies")
ck("「公司管理」页仍保留出租方 · 汇款公司(唯一入口)",
"汇款公司" in cotxt and "出租方" in cotxt, cotxt[:60].replace("\n", " "))
# ==================================================== 6 无 JS 报错
print("\n6. 前端无 JS 报错")
ck("浏览器控制台无 error / pageerror", not errors,
" | ".join(errors[:3]) if errors else "0 条")
b.close()
finally:
srv.shutdown()
shutil.rmtree(tmp, ignore_errors=True)
print("\n" + "=" * 72)
print("通过 %d 项,失败 %d 项" % (len(OK), len(FAIL)))
for f in FAIL:
print(" FAIL: %s" % f)
print("截图:%s" % OUT)
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
......@@ -2,19 +2,31 @@
"""
界面验证:两张账单发送计划 / 免租期 / 台账锁定与二次确认 / 撤销 / 无「序号」列
运行:
python tools/ui_verify2.py
RENTOS_BASE=http://127.0.0.1:8901 python tools/ui_verify2.py
运行(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱):
python tools/ui_verify_runner.py tools/ui_verify2.py
RENTOS_ALLOW_REAL=1 python tools/ui_verify2.py # 确需直连真实服务才这样用
"""
import json
import os
import re
import sys
import warnings
warnings.filterwarnings("ignore")
from playwright.sync_api import sync_playwright # noqa: E402
BASE = os.environ.get("RENTOS_BASE", "http://127.0.0.1:8899")
BASE = os.environ.get("RENTOS_BASE", "")
if not BASE:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if os.environ.get("RENTOS_ALLOW_REAL") == "1":
BASE = "http://127.0.0.1:8899"
else:
_me = os.path.basename(__file__)
sys.exit("已阻止:%s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。\n"
" 沙箱运行: python tools/ui_verify_runner.py tools/%s\n"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/%s" % (_me, _me, _me))
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots2")
os.makedirs(OUT, exist_ok=True)
......@@ -49,13 +61,14 @@ def main():
page.wait_for_url("**/admin.html")
page.wait_for_timeout(1200)
# ============================================ 1. 设置页:两张账单
print("\n== 1. 设置页:两张账单发送计划 ==")
# ============================================ 1. 设置页:只剩房租车位费
print("\n== 1. 设置页:只配置「房租车位费」(电费已删) ==")
page.goto(f"{BASE}/admin.html#/settings")
page.wait_for_selector("#planRows tr[data-plan]", timeout=20000)
page.wait_for_timeout(800)
rows = page.eval_on_selector_all("#planRows tr[data-plan]", "els=>els.map(e=>e.dataset.plan)")
ck("两张账单计划各一行", rows == ["elec", "rent_parking"], str(rows))
ck("电费通知单已从定时发送设置里删掉,只剩房租车位费",
rows == ["rent_parking"], str(rows))
ck("退休的旧账单键已消失",
"rent_summary_367" not in rows and "rent_contract_8f11f" not in rows, str(rows))
for k in rows:
......@@ -71,24 +84,30 @@ def main():
ck(f"{k} 四个控件齐全", got["en"] and got["adv"] and got["tm"] and got["rf"],
json.dumps(got, ensure_ascii=False))
names = page.eval_on_selector_all("#planRows .bill-name", "e=>e.map(x=>x.innerText)")
ck("账单名称 = 电费 / 房租车位费",
names == ["电费收费通知书", "房租车位费收费通知书"], str(names))
ck("账单名称只剩「房租车位费收费通知书」",
names == ["房租车位费收费通知书"], str(names))
ck("页面不再出现「三张账单」",
"三张账单" not in page.eval_on_selector("#page-settings", "e=>e.innerText"))
ck("设置页不再重复展示「出租方 · 汇款公司」",
"汇款公司" not in page.eval_on_selector("#page-settings", "e=>e.innerText"))
page.screenshot(path=os.path.join(OUT, "S1-plans.png"), full_page=True)
print("\n== 2. 免租期卡片 ==")
rf = page.eval_on_selector("#rentfreeBox", "e=>e.innerText")
ck("免租期卡片列出两种免租类型", "首N月免租" in rf and "每年8-9月免租" in rf)
ck("免租期列出 3 段(315室 / 302室 / 303室)", "共 3 段" in rf, rf.split("\n")[0][:80])
# 段数跟着台账走(不写死):801/802 登记「每年8-9月免租」后会按年展开
ck("免租期卡片给出「共 N 段」汇总",
re.search(r"共 \d+ 段", rf) is not None, rf.split("\n")[0][:80])
ck("免租期卡片列出真实台账里的免租房间",
all(u in rf for u in ("315室", "302室", "303室")), rf[:120])
ck("免租期表格有 302室", "302室" in rf and "2026-08-16" in rf)
ck("免租期来源标注为房租车位费台账", "房租车位费" in rf or "台账" in rf)
page.screenshot(path=os.path.join(OUT, "S2-rentfree.png"), full_page=True)
# 记下初始计划,最后原样还原(脚本不该改变现场配置)
adv = '#planRows tr[data-plan="elec"] input[data-f="advance_days"]'
tm = '#planRows tr[data-plan="elec"] input[data-f="send_time"]'
en = '#planRows tr[data-plan="elec"] input[data-f="enabled"]'
adv = '#planRows tr[data-plan="rent_parking"] input[data-f="advance_days"]'
tm = '#planRows tr[data-plan="rent_parking"] input[data-f="send_time"]'
en = '#planRows tr[data-plan="rent_parking"] input[data-f="enabled"]'
init = {"master": page.input_value("#st-enabled"), "adv": page.input_value(adv),
"tm": page.input_value(tm), "en": page.is_checked(en)}
......@@ -128,10 +147,10 @@ def main():
ck("确认后提前天数=7 已落盘", page.input_value(adv) == "7", page.input_value(adv))
ck("确认后发送时点=07:30 已落盘", page.input_value(tm) == "07:30", page.input_value(tm))
st_txt = page.eval_on_selector(
'#planRows tr[data-plan="elec"] .plan-state', "e=>e.innerText")
ck("状态列给出运行状态(排期说明 / 暂无通知书 / 未启用)",
("缴费期限" in st_txt) or ("还没有可发送的通知书" in st_txt) or ("未启用" in st_txt),
st_txt.replace("\n", " | ")[:110])
'#planRows tr[data-plan="rent_parking"] .plan-state', "e=>e.innerText")
ck("状态列给出运行状态(排期说明 / 未启用 / 无待发)",
("推送日" in st_txt) or ("待出单" in st_txt) or ("未启用" in st_txt)
or ("无待发" in st_txt), st_txt.replace("\n", " | ")[:110])
page.screenshot(path=os.path.join(OUT, "S4-saved.png"), full_page=True)
print("\n== 4b. 关闭总开关时不再排期 ==")
......@@ -143,10 +162,10 @@ def main():
page.reload(wait_until="networkidle")
page.wait_for_selector("#planRows tr[data-plan]", timeout=20000)
page.wait_for_timeout(600)
ck("总开关关闭 → 两张账单状态均为 off",
ck("总开关关闭 → 计划状态为「总开关关闭」",
page.eval_on_selector_all("#planRows tr[data-plan] .plan-state .l1",
"e=>e.map(x=>x.innerText)")
== ["总开关关闭", "总开关关闭"],
== ["总开关关闭"],
str(page.eval_on_selector_all("#planRows tr[data-plan] .plan-state .l1",
"e=>e.map(x=>x.innerText)")))
......
......@@ -10,9 +10,9 @@
5. 「房租车位费」台账:8F/11F 付款周期 = 3 个月
6. 全程页面无 JS 报错
运行:
python tools/ui_verify3.py # 默认 http://127.0.0.1:8899
RENTOS_BASE=http://127.0.0.1:8901 python tools/ui_verify3.py
运行(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱):
python tools/ui_verify_runner.py tools/ui_verify3.py
RENTOS_ALLOW_REAL=1 python tools/ui_verify3.py # 确需直连真实服务才这样用
"""
import base64
import io
......@@ -24,7 +24,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
from playwright.sync_api import sync_playwright # noqa: E402
BASE = os.environ.get("RENTOS_BASE", "http://127.0.0.1:8899")
BASE = os.environ.get("RENTOS_BASE", "")
if not BASE:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if os.environ.get("RENTOS_ALLOW_REAL") == "1":
BASE = "http://127.0.0.1:8899"
else:
_me = os.path.basename(__file__)
sys.exit("已阻止:%s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。\n"
" 沙箱运行: python tools/ui_verify_runner.py tools/%s\n"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/%s" % (_me, _me, _me))
USER, PWD = "admin", "1989@rent05"
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots3")
PNG_B64 = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGA"
......
......@@ -6,14 +6,14 @@
1. 导航出现「公司管理」,深链 #/companies 可直达,共 5 项
2. 租客 / 往来单位名册:加载、类型标签、搜索过滤、录入、行内编辑改名、删除
3. 出租方 · 汇款公司:加载、引用单元数、录入、改名(提示会同步台账)、删除
4. 设置页不再有旧的「汇款公司」编辑卡片(只剩指引)
4. 设置页不再有旧的「汇款公司」编辑卡片,也不再重复展示(名单只在「公司管理」维护)
5. 选择场景接入:电费录入「单位名称 / 出租方」有候选并在填房间号时自动带出
6. 台账「现租客」列编辑时挂上名册候选
7. 全程页面无 JS 报错,且测试用完即清理(不留脏数据)
运行:
python tools/ui_verify4.py # 默认 8899
RENTOS_BASE=http://127.0.0.1:8901 python tools/ui_verify4.py
运行(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱):
python tools/ui_verify_runner.py tools/ui_verify4.py
RENTOS_ALLOW_REAL=1 python tools/ui_verify4.py # 确需直连真实服务才这样用
"""
import io
import os
......@@ -24,7 +24,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
from playwright.sync_api import sync_playwright # noqa: E402
BASE = os.environ.get("RENTOS_BASE", "http://127.0.0.1:8899")
BASE = os.environ.get("RENTOS_BASE", "")
if not BASE:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if os.environ.get("RENTOS_ALLOW_REAL") == "1":
BASE = "http://127.0.0.1:8899"
else:
_me = os.path.basename(__file__)
sys.exit("已阻止:%s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。\n"
" 沙箱运行: python tools/ui_verify_runner.py tools/%s\n"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/%s" % (_me, _me, _me))
USER, PWD = "admin", "1989@rent05"
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots4")
......@@ -278,7 +289,8 @@ def main():
ck("旧汇款公司编辑区已移除",
page.eval_on_selector_all("#ownersBox, #btn-save-owners, #btn-reset-owners", "e => e.length") == 0)
txt = page.inner_text("#page-settings")
ck("留有指向公司管理的指引", "公司管理" in txt)
ck("设置页不再重复展示「出租方 · 汇款公司」(已迁走,不留冗余指引)",
"汇款公司" not in txt, txt[:80].replace("\n", " "))
page.screenshot(path=os.path.join(OUT, "C4-settings.png"), full_page=True)
# ============================================================ 5
......
......@@ -23,9 +23,9 @@
* 结束时逐格核对台账「账单起始日」与跑前一致,不一致就报 FAIL;
* 本次生成的产物文件会被清理(批次 JSON 由删除步骤清掉)。
运行:
python tools/ui_verify5.py # 默认 8899
RENTOS_BASE=http://127.0.0.1:8901 python tools/ui_verify5.py
运行(该脚本会写数据,默认目标 8899 是【真实台账】,必须走沙箱):
python tools/ui_verify_runner.py tools/ui_verify5.py
RENTOS_ALLOW_REAL=1 python tools/ui_verify5.py # 确需直连真实服务才这样用
"""
import io
import os
......@@ -37,7 +37,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from playwright.sync_api import sync_playwright # noqa: E402
BASE = os.environ.get("RENTOS_BASE", "http://127.0.0.1:8899")
BASE = os.environ.get("RENTOS_BASE", "")
if not BASE:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if os.environ.get("RENTOS_ALLOW_REAL") == "1":
BASE = "http://127.0.0.1:8899"
else:
_me = os.path.basename(__file__)
sys.exit("已阻止:%s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。\n"
" 沙箱运行: python tools/ui_verify_runner.py tools/%s\n"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/%s" % (_me, _me, _me))
OUT = os.environ.get("RENTOS_SHOTS", os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "output", "ui_shots"))
USER, PWD = "admin", "1989@rent05"
......@@ -275,14 +286,21 @@ def main():
# ============================================ 7. 批次台账 / 类别隔离
print("\n7. 批次台账 · 类别隔离")
kinds = p.eval_on_selector_all("#rpBatchList tbody tr td:first-child",
# 批次行默认按账期折叠;只展开仍折叠的分组(hash 导航不刷新页面,状态会延续)
js("""() => { document.querySelectorAll('#rpBatchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }""")
p.wait_for_timeout(500)
kinds = p.eval_on_selector_all("#rpBatchList tbody tr.prow td:first-child",
"e=>e.map(x=>x.innerText.trim())")
ck("房租批次表的「账单」列标出「房租车位费」",
kinds and all(k == "房租车位费" for k in kinds), str(kinds[:3]))
p.goto(BASE + "/admin.html#/elec")
p.wait_for_selector("#eGrid", timeout=15000)
p.wait_for_timeout(1800)
elec_ids = p.eval_on_selector_all("#batchList tbody tr td:nth-child(2)",
js("""() => { document.querySelectorAll('#batchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }""")
p.wait_for_timeout(400)
elec_ids = p.eval_on_selector_all("#batchList tbody tr.prow td:nth-child(2)",
"e=>e.map(x=>x.innerText.trim())")
ck("电费页的批次列表不含刚生成的房租批次",
(created_batch or "x") not in elec_ids, str(elec_ids))
......@@ -292,6 +310,9 @@ def main():
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#rpWrap table", timeout=20000)
p.wait_for_timeout(1200)
js("""() => { document.querySelectorAll('#rpBatchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }""")
p.wait_for_timeout(400)
if created_batch:
p.click('#rpBatchList button[data-open="%s"]' % created_batch)
p.wait_for_timeout(1500)
......
......@@ -15,7 +15,7 @@
* 只出 1 行单,跑完**必定删除该批次并回滚账期**(try/finally 兜底);
* 结束时核对台账 AC/AJ 与跑前一致。
运行:python tools/ui_verify6.py (需先起服务)
运行:python tools/ui_verify_runner.py tools/ui_verify6.py (该脚本会写数据,沙箱隔离)
"""
import io
import os
......@@ -25,7 +25,18 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from playwright.sync_api import sync_playwright # noqa: E402
BASE = os.environ.get("RENTOS_BASE", "http://127.0.0.1:8899")
BASE = os.environ.get("RENTOS_BASE", "")
if not BASE:
# 该脚本会写数据;未注入 RENTOS_BASE 时默认目标 8899 是【真实台账】,
# 直连会污染权威数据(2026-10-13 曾误写入 17 个测试批次)。
# 正常路径:tools/ui_verify_runner.py(沙箱 + 注入 RENTOS_BASE)。
if os.environ.get("RENTOS_ALLOW_REAL") == "1":
BASE = "http://127.0.0.1:8899"
else:
_me = os.path.basename(__file__)
sys.exit("已阻止:%s 未设置 RENTOS_BASE(默认 8899 是【真实台账】)。\n"
" 沙箱运行: python tools/ui_verify_runner.py tools/%s\n"
" 确需打真实服务:RENTOS_ALLOW_REAL=1 python tools/%s" % (_me, _me, _me))
OUT = os.environ.get("RENTOS_SHOTS", os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "output", "ui_shots"))
USER, PWD = "admin", "1989@rent05"
......
......@@ -247,6 +247,10 @@ def main():
_st, _bl = api_call("GET", "/api/batches?kind=elec")
_elec = (_bl.get("batches") or [])
if _elec:
# 批次行默认按账期折叠;只展开仍折叠的分组
js("""() => { document.querySelectorAll('#batchList tbody.pgrp.collapsed tr.grp')
.forEach(tr => tr.click()); }""")
p.wait_for_timeout(500)
ck("guest 看得到电费批次的「删除」按钮",
p.eval_on_selector_all("#batchList tbody tr button[data-del]",
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment