diff --git a/AGENTS.md b/AGENTS.md index 6287270..8b5e369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -318,6 +318,11 @@ python test/schema_alignment_e2e.py --env dev --jwt-secret python test/schema_alignment_e2e.py --env prod --jwt-secret --yes-prod # 5) TCP 协议端到端(模拟柜控板,打测试环境 device-server :3001) +# 含 ⑨ 步「平台侧积分电量」断言:读测试库核对 charge_orders.energy 是否等于 +# 由负载常量推导的期望值(⇒ **需要 SSH 隧道**,连不上会 exit 2「未验证」而非通过) +# ⚠️ 前置:先 python test/reset_fixture.py;且 auth_str 有「同一 IMEI 5 次/5 分钟」限流, +# 连续跑第 6 次会撞限流 —— 脚本会明确报"被频率限制/exit 2",此时 +# `systemctl restart pms-api-test` 清零进程内计数器即可(**不是功能缺陷**) python test/tcp_e2e_smoke.py # 6) 状态位解析两端一致性(逐位遍历,断言 PC 与小程序口径一致) diff --git a/test/tcp_e2e_smoke.py b/test/tcp_e2e_smoke.py index 4aec4d6..02526dc 100644 --- a/test/tcp_e2e_smoke.py +++ b/test/tcp_e2e_smoke.py @@ -8,6 +8,7 @@ import sys import hashlib import json +import os import socket import time @@ -15,6 +16,23 @@ HOST, PORT = "10.8.0.252", 3001 IMEI = "860000000000001" BATTERY_ID = "SN-LEGACY-001" +# ⚠️ **平台侧积分电量的期望值推导**(与 status_post 的负载常量绑定): +# CHARGING = 54.00V / 8.00A ⇒ 电压 5400×10 = 54000 mV、电流 800×10 = 8000 mA +# 积分口径(software/api-server/src/workers/energy.rs): +# mWh = mV × mA × Δt / 3_600_000,Δt 固定 60 秒 +# 本脚本第 ⑥ 步发 3 次 status_post ⇒ 期望 Wh = (3×7200 + 500) / 1000 = 22(mWh→Wh 四舍五入) +ENERGY_SAMPLES = 3 +ENERGY_VOLTAGE_MV = 5400 * 10 +ENERGY_CURRENT_MA = 800 * 10 +ENERGY_DT_SECS = 60 + + +def expected_energy_wh(): + """按积分公式算出本脚本应落库的订单电量(Wh)。""" + per_sample_mwh = ENERGY_VOLTAGE_MV * ENERGY_CURRENT_MA * ENERGY_DT_SECS // 3_600_000 + total_mwh = ENERGY_SAMPLES * per_sample_mwh + return (total_mwh + 500) // 1000 + def hex_channel(batt: int, power: int, soc_pct: int, voltage_001v: int, current_001a: int, temp_k: int) -> str: @@ -90,8 +108,18 @@ ok = [] step("① auth_str(未登录可发)") cab.send({"act": "auth_str", "msg_id": cab.next_id(), "dev_id": IMEI}) -resp = cab.recv() -auth_str = resp.get("auth_str") if resp else None +resp = cab.recv() or {} +# ⚠️ **限流要单独报**:协议 §5.1 规定同一 IMEI「5 次 / 5 分钟」。 +# 连续多跑几次本脚本就会撞上 —— 此时后续每一步都会级联失败, +# 输出看起来像"登录/建单/结算全坏了"。必须与真正的功能缺陷区分开: +# 明确说清原因并以**退出码 2(环境/夹具问题)**结束,别让人去查不存在的 bug。 +if resp.get("err") == "rate limit exceeded": + print(" ⚠️ 未验证:auth_str 被频率限制(协议 §5.1:同一 IMEI 5 次/5 分钟)") + print(" 这是**重复运行本脚本**导致的,不是功能缺陷。") + print(" 处理:等 5 分钟再跑;或重启测试环境 api-server 清零进程内计数器") + print(" (systemctl restart pms-api-test);再跑前先 python test/reset_fixture.py") + sys.exit(2) +auth_str = resp.get("auth_str") ok.append(("auth_str 返回 8 位安全码", bool(auth_str) and len(auth_str) == 8)) print(f" 安全码 = {auth_str}") @@ -151,6 +179,82 @@ except Exception as e: # noqa: BLE001 cab.sock.close() +# --------------------------------------------------------------------------- +# ⑨ 平台侧积分电量:**端到端断言**(读测试库,而不是只看 suc=1) +# --------------------------------------------------------------------------- +unverified = [] +step("⑨ 平台侧积分电量(读测试库:charge_orders.energy 应等于积分期望值)") + + +def fetch_settled_order(timeout_secs=15): + """轮询测试库,返回 `(order_id, order_status, order_energy, compartment_id, record)`。 + + `record` = 该仓体最新 `charge_records` 行 `(energy, max_temperature)`,无则 None。 + + ⚠️ 列名以 information_schema 实测为准:**`max_temperature` 只在 `charge_records` 上**, + `charge_orders` 没有该列(早期版本这里写错,被"未验证"机制挡下而非静默通过)。 + ⚠️ 结算在 report_worker 里是异步的(`off` 的 `suc` 可能在写回电量之前返回), + 所以这里**轮询**而不是读一次。连不上库时抛异常,由调用方判为"未验证"。 + """ + import pymysql + + host = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1") + port = int(os.environ.get("PMS_MYSQL_PORT", "13306")) + db = os.environ.get("PMS_MYSQL_DB", "pms_dev") + conn = pymysql.connect(host=host, port=port, user="root", + password="Hbhyg731024@", database=db, autocommit=True) + try: + cur = conn.cursor() + deadline = time.time() + timeout_secs + last = None + while time.time() < deadline: + cur.execute( + "SELECT co.id, co.status, co.energy, co.compartment_id " + " FROM charge_orders co JOIN cabinets c ON c.id = co.cabinet_id " + " WHERE c.imei = %s ORDER BY co.id DESC LIMIT 1", + (IMEI,), + ) + last = cur.fetchone() + if last and last[1] != 0: # status <> 0 ⇒ 已结算 + break + time.sleep(0.5) + if last is None: + return None + cur.execute( + "SELECT energy, max_temperature FROM charge_records " + " WHERE compartment_id = %s ORDER BY id DESC LIMIT 1", + (last[3],), + ) + return last + (cur.fetchone(),) + finally: + conn.close() + + +try: + row = fetch_settled_order() + if row is None: + ok.append(("积分电量:存在订单", False)) + else: + order_id, order_status, energy_wh, _comp_id, rec = row + want = expected_energy_wh() + rec_energy, rec_temp = rec if rec else (None, None) + print(f" 订单 id={order_id} status={order_status} energy={energy_wh}Wh | " + f"记录 energy={rec_energy}Wh 最高温={rec_temp}K×100 | 期望={want}Wh") + ok.append(("积分电量:订单已结算", order_status != 0)) + ok.append((f"积分电量:charge_orders.energy == 积分期望值 {want}Wh(实测 {energy_wh})", + energy_wh == want)) + ok.append(("积分电量:已生成 charge_records", rec is not None)) + # 同一积分值写两处,必须一致(两处不一致说明写回路径有分叉) + ok.append((f"积分电量:charge_records.energy 与订单一致(记录 {rec_energy})", + rec_energy == want)) + # 最高温由积分路径一并跟踪(CHARGING 的 temp = 298K ⇒ K×100 = 29800) + ok.append((f"积分路径:最高温已跟踪(29800 K×100,实测 {rec_temp})", rec_temp == 29800)) +except Exception as e: # noqa: BLE001 + print(f" ⚠️ 未验证:连不上测试库({e})") + print(" 需要先起 SSH 隧道:pwsh tools/dev-tunnel.ps1" + "(或用 PMS_MYSQL_HOST/PMS_MYSQL_PORT/PMS_MYSQL_DB 指向别处)") + unverified.append("平台侧积分电量(连不上测试库 —— 见上方 ⚠️,通常是没有 SSH 隧道)") + print("\n========== 联调结果 ==========") for name, passed in ok: print(f" {'✅' if passed else '❌'} {name}") @@ -163,4 +267,9 @@ if failed: for n in failed: print(f" ❌ {n}", file=sys.stderr) print(f"合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过,{len(failed)} 项失败", file=sys.stderr) -sys.exit(0 if not failed else 1) +if unverified: + # ⛔ 未验证 ≠ 通过:环境问题按仓库退出码约定走 **2** + print("未验证项(stderr,**不是通过**):", file=sys.stderr) + for n in unverified: + print(f" ⚠️ {n}", file=sys.stderr) +sys.exit(1 if failed else (2 if unverified else 0))