文件
phonerail/rack/backend/tests/test_api.py
T
zhou yang和Claude Opus 5.5 9858b6c98e rack:界面改版(仪器前面板风格 + 暗色模式)+ 底板温度曲线
- 总览:每块板一块面板、五个刀片槽(状态灯 + DIN 读数 + 最近两分钟电流小曲线,平线 = 疑似卡死);
  只有异常 / 故障 / 执行中上色;顶上一句话概况,有问题直接列出是哪几路
- 单板页:新增「底板温度」(两路 NTC + 风扇占空比,上下两块共用时间轴,10 分钟 ~ 7 天);
  每路电流 / 电压去掉双 Y 轴,改上下两块,电压固定 3.5~4.1 V;名称 / 位置就地编辑
- 主题:theme.css 亮 / 暗两套变量(跟随系统,Element Plus 暗色变量同挂 html.dark),图表切换时重画;
  读数用系统自带 DIN 系字体(不加载网络字体);温度两色按本页亮暗底色跑过 dataviz validate_palette.js 全通过
- 后端:board_tele_min 分钟聚合(温度带 min/max,留 90 天)+ GET /api/boards/{id}/history(Db.board_history)
- 修一个偶发失败的测试(test_ota_events_are_named_by_arg:只推一次遥测,赶上 Hub 正在同步时会漏触发);
  后端 42 例本机 + 服务器全绿,已部署;线上已取到板 B 温度 24.4 / 26.0 °C

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 15:03:24 +08:00

156 行
6.4 KiB
Python

"""HTTP / WebSocket 接口:用 TestClient 起完整的应用,Hub 经进程内假 broker 连模拟板子。"""
import time
import pytest
from fastapi.testclient import TestClient
from prrack.api import create_app
from prrack.bus import MemBroker, MemBus
from prrack.db import Db, now_ms
from prrack.hub import Hub
from prrack.recovery import RecoveryManager
from prrack.simboard import SimBoard
B = "b81f3fc9c8bc"
@pytest.fixture
def env(tmp_path):
broker = MemBroker()
db = Db(str(tmp_path / "a.db"))
hub = Hub(db, MemBus(broker), attempt_timeout=0.2)
rec = RecoveryManager(hub, now_ms)
sim = SimBoard(MemBus(broker), B, present=(True, True, True, False, True))
dist = tmp_path / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<html>rack</html>", encoding="utf-8")
async def up():
await hub.start()
sim.start()
sim.tick(now_ms())
app = create_app(hub, rec, str(dist), startup=[up], shutdown=[hub.stop])
with TestClient(app) as c:
deadline = time.time() + 2
while B not in hub.latest and time.time() < deadline:
time.sleep(0.01)
yield c, hub, sim, db
def test_boards_and_snapshot(env):
c, hub, sim, db = env
boards = c.get("/api/boards").json()
assert [b["id"] for b in boards] == [B]
assert boards[0]["online"] is True and len(boards[0]["phones"]) == 5
assert c.get("/api/boards/nope").status_code == 404
def test_command_records_the_portal_user(env):
c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": False},
headers={"X-Auth-User": "zhouyang"})
assert r.status_code == 200 and r.json()["ok"] is True
assert sim.rails[0].en is False
a = c.get("/api/audit").json()[0]
assert a["user"] == "zhouyang" and a["request"]["cmd"] == "power" and a["ok"] == 1
# 没有门户头(服务器本机直接 curl)记作 local
c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 1, "on": True})
assert c.get("/api/audit").json()[0]["user"] == "local"
def test_bad_commands_never_reach_the_board(env):
c, hub, sim, db = env
n = len(sim.received)
for body in ({"cmd": "wifi", "ssid": "x"}, {"cmd": "set_voltage", "rail": 1, "mv": 4300},
{"cmd": "key", "rail": 1, "keys": "pwr", "ms": 60000}, {"cmd": "rm -rf"}):
r = c.post("/api/boards/%s/cmd" % B, json=body)
assert r.status_code == 400, body
assert len(sim.received) == n
def test_board_rejection_is_passed_through(env):
c, hub, sim, db = env
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power_cycle", "rail": 4})
assert r.status_code == 200 and r.json()["err"] == "empty"
def test_timeout_is_504(env):
c, hub, sim, db = env
sim.drop_next_replies = 99
r = c.post("/api/boards/%s/cmd" % B, json={"cmd": "power", "rail": 2, "on": True})
assert r.status_code == 504
def test_phone_settings_toggle_auto_recovery(env):
c, hub, sim, db = env
r = c.patch("/api/boards/%s/rails/3" % B, json={"label": "A-03", "auto_recover": True})
assert r.status_code == 200
ph = [p for p in r.json()["phones"] if p["rail"] == 3][0]
assert ph["label"] == "A-03" and ph["auto_recover"] == 1
rec = c.get("/api/recovery").json()
assert rec["params"]["window_s"] == 300
r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1})
assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收
def test_history_after_telemetry(env):
c, hub, sim, db = env
for _ in range(3):
sim.tick(now_ms())
time.sleep(0.1)
hub.flush()
h = c.get("/api/boards/%s/rails/1/history" % B, params={"start": now_ms() - 60000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 1
assert h["points"][0][2] is not None
def test_websocket_hello_and_live_tele(env):
c, hub, sim, db = env
with c.websocket_connect("/api/ws", headers={"X-Auth-User": "zhouyang"}) as ws:
hello = ws.receive_json()
assert hello["t"] == "hello" and hello["user"] == "zhouyang" and hello["boards"][0]["id"] == B
sim.tick(now_ms())
msg = ws.receive_json()
while msg["t"] != "tele":
msg = ws.receive_json()
assert msg["board"] == B and len(msg["status"]["rails"]) == 5
def test_spa_fallback_is_not_cached(env):
c, hub, sim, db = env
r = c.get("/some/deep/route")
assert r.status_code == 200 and "rack" in r.text
assert r.headers["cache-control"] == "no-cache"
def test_a_real_websocket_library_is_installed():
"""TestClient 自带 WS 实现,上面那条 WS 用例在没装 websockets 的机器上也是绿的;
而真 uvicorn 缺了它会把 /api/ws 当普通 GET(落到 SPA 兜底,回 200 HTML)。
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
from prrack.main import ws_library_ok
assert ws_library_ok()
def test_board_history_raw_and_minute(env):
c, hub, sim, db = env
base = now_ms() // 60000 * 60000 - 3 * 60000
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)]
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
assert h["resolution"] == "raw" and len(h["points"]) >= 100
p = h["points"][0]
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
assert db.downsample(base + 180000) > 0
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时
rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,))
assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060]
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119]
assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
assert m["resolution"] == "raw"
old = db.board_history(B, base - 10 * 86400000, base + 120000)
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
assert max(p[3] for p in old["points"]) == 35119
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404