feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径

三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
This commit is contained in:
snowy-yang committed 2026-09-19 12:55:45 +08:00
1 parent 47dd818360
commit f8e093c98f
36 files changed
+6865 -2204

No files matched your search

+990
View File
@@ -0,0 +1,990 @@
"""远程客户端测试:视图语义、地址归一化、SSE 消息分发、以及 HTTP 传输层。
这里是「TUI 不需要知道自己在跟谁说话」这句承诺的兑现处:
``StateView`` 必须把服务器发来的两种东西区分开——**完整快照**(带 plots/animals)
要整体替换,**精简状态**(动作回执里那几个标量)只能做字段合并。搞反了的后果
很具体:播一次种,整片农田就从界面上消失了。
除 ``HttpTransportTestCase`` 会起一个纯标准库的回环桩服务外,其余全部离线;
桩服务只绑 127.0.0.1,不依赖 fastapi / httpx / uvicorn。
uv run python -m unittest tests.test_client -v
"""
from __future__ import annotations
import http.server
import socket
import threading
import unittest
import urllib.parse
import urllib.request
from typing import ClassVar
from farm import config, net
from farm.client import (
DEFAULT_ENDPOINT,
LOG_KEEP,
AnimalView,
PlotView,
RemoteBotClient,
RemoteFarm,
StateView,
_underlying_socket,
)
from farm.engine import GameError
# --------------------------------------------------------------------- 构造假数据
def plot_dict(**overrides):
"""一份「服务器会发过来的」地块 JSON。"""
data = {
"index": 0,
"position": 1,
"crop": "carrot",
"crop_name": "胡萝卜",
"growth": 1.0,
"grow_days": 3,
"days_left": 2,
"moisture": 3,
"is_ripe": False,
"is_empty": False,
"stage": "生长中",
}
data.update(overrides)
return data
def animal_dict(**overrides):
data = {
"id": 7,
"kind": "cow",
"name": "奶牛",
"product": "milk",
"fed": True,
"pending": 0,
"cycle": 3,
"progress": 1,
"starve": 0,
"days_to_product": 2,
"is_sick": False,
}
data.update(overrides)
return data
def offline_client(**kwargs):
"""不连网络的 RemoteFarm(autostart=False 是关键)。"""
kwargs.setdefault("autostart", False)
kwargs.setdefault("realtime", False)
return RemoteFarm(**kwargs)
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class _StubHandler(http.server.BaseHTTPRequestHandler):
"""按路径吐预设响应的小桩,够本文件用。**
``payloads`` 由测试在 ``setUp`` 里填:path -> (status, body_bytes, content_type)。
"""
protocol_version = "HTTP/1.1"
payloads: ClassVar[dict[str, tuple[int, bytes, str]]] = {}
def do_GET(self) -> None:
self._reply()
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
if length:
self.rfile.read(length)
self._reply()
def _reply(self) -> None:
path = urllib.parse.urlsplit(self.path).path
status, body, content_type = self.payloads.get(
path, (404, b'{"ok": false, "error": "not_found"}', "application/json")
)
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args) -> None:
"""别把访问日志刷到测试输出里。"""
# ===================================================================== 视图字段映射
class PlotViewTestCase(unittest.TestCase):
def test_fields_follow_the_server_payload(self):
view = PlotView(plot_dict())
self.assertEqual(view.index, 0)
self.assertEqual(view.position, 1)
self.assertEqual(view.crop, "carrot")
self.assertEqual(view.crop_name, "胡萝卜")
self.assertEqual(view.growth, 1.0)
self.assertEqual(view.grow_days, 3)
self.assertEqual(view.days_left, 2)
self.assertEqual(view.moisture, 3)
self.assertFalse(view.is_ripe)
self.assertFalse(view.is_empty)
self.assertEqual(view.stage, "生长中")
def test_missing_fields_fall_back_to_defaults(self):
view = PlotView({})
self.assertEqual(view.index, 0)
# position 缺省时由 index 推出(地块编号是给人看的,从 1 起)
self.assertEqual(view.position, 1)
self.assertIsNone(view.crop)
self.assertEqual(view.crop_name, "")
self.assertEqual(view.growth, 0.0)
self.assertEqual(view.moisture, 0)
self.assertEqual(view.stage, "")
def test_position_defaults_to_index_plus_one(self):
self.assertEqual(PlotView({"index": 4}).position, 5)
def test_empty_defaults_to_crop_is_none(self):
self.assertTrue(PlotView({"crop": None}).is_empty)
self.assertFalse(PlotView({"crop": "carrot"}).is_empty)
def test_explicit_is_empty_wins_over_crop(self):
self.assertTrue(PlotView({"crop": "carrot", "is_empty": True}).is_empty)
def test_numeric_strings_are_coerced(self):
# JSON 里数字有可能是字符串,视图不该把它漏给渲染层
view = PlotView({"growth": "2.5", "moisture": "3", "is_ripe": 1})
self.assertEqual(view.growth, 2.5)
self.assertEqual(view.moisture, 3)
self.assertIs(view.is_ripe, True)
def test_to_dict_is_a_copy(self):
view = PlotView(plot_dict())
dumped = view.to_dict()
dumped["crop"] = "corn"
self.assertEqual(view.crop, "carrot")
def test_view_is_read_only(self):
view = PlotView(plot_dict())
with self.assertRaises(AttributeError):
view.index = 9 # type: ignore[misc]
class AnimalViewTestCase(unittest.TestCase):
def test_fields_follow_the_server_payload(self):
view = AnimalView(animal_dict())
self.assertEqual(view.aid, 7)
self.assertEqual(view.kind, "cow")
self.assertEqual(view.name, "奶牛")
self.assertEqual(view.product, "milk")
self.assertTrue(view.fed)
self.assertEqual(view.pending, 0)
self.assertEqual(view.cycle, 3)
self.assertEqual(view.progress, 1)
self.assertEqual(view.days_to_product, 2)
self.assertFalse(view.is_sick)
def test_id_maps_to_aid(self):
# 服务器用 "id",本地模型叫 .aid —— 同构靠的就是这一层改名
self.assertEqual(AnimalView({"id": 12}).aid, 12)
def test_missing_fields_fall_back_to_defaults(self):
view = AnimalView({})
self.assertEqual(view.aid, 0)
self.assertEqual(view.kind, "")
self.assertEqual(view.product, "")
self.assertFalse(view.fed)
self.assertEqual(view.cycle, 1) # 牲畜至少处在第 1 个周期
self.assertEqual(view.starve, 0)
self.assertFalse(view.is_sick)
def test_to_dict_is_a_copy(self):
view = AnimalView(animal_dict())
dumped = view.to_dict()
dumped["kind"] = "sheep"
self.assertEqual(view.kind, "cow")
# ===================================================================== 状态视图语义
class StateViewTestCase(unittest.TestCase):
def test_full_snapshot_replaces_everything(self):
view = StateView({"day": 1, "plots": [plot_dict()], "animals": [animal_dict()]})
self.assertEqual(len(view.plots), 1)
self.assertEqual(len(view.animals), 1)
view.apply({"day": 2, "plots": [], "animals": []})
self.assertEqual(view.day, 2)
self.assertEqual(view.plots, [])
self.assertEqual(view.animals, [])
def test_brief_state_merges_and_keeps_the_farm(self):
"""回归:动作回执里的精简 state 绝不能清空农田。"""
view = StateView(
{"day": 1, "gold": 100, "plots": [plot_dict(), plot_dict(index=1)], "animals": []}
)
view.apply({"gold": 60, "ap": 5})
self.assertEqual(view.gold, 60)
self.assertEqual(view.ap, 5)
self.assertEqual(view.day, 1, "没提到的字段要保留")
self.assertEqual(len(view.plots), 2, "一次播种不该让农田消失")
def test_brief_state_after_a_full_snapshot_keeps_views_usable(self):
view = StateView({"plots": [plot_dict(crop="corn")], "animals": [animal_dict()]})
view.apply({"gold": 1})
self.assertEqual(view.plots[0].crop, "corn")
self.assertEqual(len(view.plots), 1)
self.assertEqual(len(view.animals), 1)
def test_empty_data_is_ignored(self):
view = StateView({"day": 3, "gold": 50})
view.apply({})
self.assertEqual(view.day, 3)
self.assertEqual(view.gold, 50)
def test_initial_construction_is_tolerant(self):
view = StateView()
self.assertEqual(view.plots, [])
self.assertEqual(view.animals, [])
self.assertEqual(view.day, 1)
self.assertEqual(view.gold, 0)
self.assertEqual(view.level, 1)
self.assertEqual(view.market, {})
def test_an_empty_view_looks_like_an_empty_farm(self):
"""没收到任何快照时也必须能安全渲染。
服务器连不上、或客户端是 ``autostart=False`` 起的,界面拿到的是空视图;
这时读 ``.plots`` 必须是"空农田",不能是 AttributeError。
"""
view = StateView()
self.assertTrue(hasattr(view, "plots"))
self.assertTrue(hasattr(view, "animals"))
self.assertEqual(len(view.plots), 0)
self.assertEqual(len(view.animals), 0)
self.assertEqual(view.to_dict(), {})
def test_offline_client_exposes_a_renderable_empty_state(self):
client = offline_client(endpoint="http://127.0.0.1:1/")
self.assertEqual(client.state.plots, [])
self.assertEqual(client.snapshot(), {})
def test_non_dict_entries_are_filtered_out(self):
view = StateView({"plots": [plot_dict(), "垃圾", 3], "animals": [None, animal_dict()]})
self.assertEqual(len(view.plots), 1)
self.assertEqual(len(view.animals), 1)
def test_scalar_accessors_read_through(self):
view = StateView(
{
"day": 5,
"gold": 320,
"level": 4,
"exp": 11,
"ap": 2,
"feed": 9,
"market": {"corn": 12},
}
)
self.assertEqual((view.day, view.gold, view.level), (5, 320, 4))
self.assertEqual((view.exp, view.ap, view.feed), (11, 2, 9))
self.assertEqual(view.market["corn"], 12)
def test_get_and_item_access(self):
view = StateView({"online": 3, "paused": False})
self.assertEqual(view.get("online"), 3)
self.assertEqual(view.get("nope", "兜底"), "兜底")
self.assertEqual(view["online"], 3)
with self.assertRaises(KeyError):
_ = view["nope"]
def test_to_dict_round_trips_the_raw_payload(self):
payload = {"day": 2, "gold": 5, "plots": [plot_dict()]}
self.assertEqual(StateView(payload).to_dict(), payload)
def test_to_dict_is_a_copy(self):
view = StateView({"gold": 5})
dumped = view.to_dict()
dumped["gold"] = 999
self.assertEqual(view.gold, 5)
# ===================================================================== 地址与 URL
class AddressTestCase(unittest.TestCase):
def test_normalize_adds_the_scheme(self):
self.assertEqual(RemoteFarm._normalize("127.0.0.1:8765"), "http://127.0.0.1:8765")
def test_normalize_maps_the_legacy_tcp_scheme(self):
self.assertEqual(RemoteFarm._normalize("tcp://127.0.0.1:8765"), "http://127.0.0.1:8765")
def test_normalize_strips_whitespace_and_trailing_slashes(self):
self.assertEqual(RemoteFarm._normalize(" http://host:9000/// "), "http://host:9000")
def test_normalize_keeps_https(self):
self.assertEqual(RemoteFarm._normalize("https://farm.example"), "https://farm.example")
def test_normalize_falls_back_when_empty(self):
self.assertEqual(RemoteFarm._normalize(""), DEFAULT_ENDPOINT)
self.assertEqual(RemoteFarm._normalize(" "), DEFAULT_ENDPOINT)
def test_normalize_falls_back_for_degenerate_endpoints(self):
"""畸形端点不能变成"没有主机名"的地址。
否则 host 是空串,``net.is_loopback`` 判成 False,请求会溜到系统代理上,
报回来一个和真实原因毫无关系的 502。回落到本机默认才是对的。
"""
for endpoint in ("", " ", "//", "http://", "https://", "tcp://"):
with self.subTest(endpoint=endpoint):
self.assertEqual(RemoteFarm._normalize(endpoint), DEFAULT_ENDPOINT)
def test_a_normalized_endpoint_always_has_a_host(self):
candidates = ("", " ", "http://", "127.0.0.1:1", "http://host:9000/", "tcp://h:1")
for endpoint in candidates:
with self.subTest(endpoint=endpoint):
normalized = RemoteFarm._normalize(endpoint)
self.assertTrue(urllib.parse.urlsplit(normalized).netloc, normalized)
def test_init_stores_a_normalized_endpoint(self):
client = offline_client(endpoint=" tcp://127.0.0.1:9999/ ")
self.assertEqual(client.endpoint, "http://127.0.0.1:9999")
self.assertEqual(client.host, "127.0.0.1:9999")
def test_farm_name_defaults_to_default(self):
self.assertEqual(offline_client(farm="").farm, "default")
self.assertEqual(offline_client(farm=None).farm, "default") # type: ignore[arg-type]
def test_host_falls_back_when_the_endpoint_has_none(self):
self.assertEqual(offline_client(endpoint="http://").host, "127.0.0.1:8765")
def test_url_always_carries_the_farm(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="阿黄")
parts = urllib.parse.urlsplit(client.url("/api/state"))
self.assertEqual(parts.path, "/api/state")
self.assertEqual(urllib.parse.parse_qs(parts.query), {"farm": ["阿黄"]})
def test_url_includes_the_token_when_present(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", token="s3cret")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.url("/api/state")).query)
self.assertEqual(query["token"], ["s3cret"])
def test_url_omits_the_token_when_absent(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.url("/api/state")).query)
self.assertNotIn("token", query)
def test_url_carries_extra_params(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
url = client.url("/api/log", limit=30)
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
self.assertEqual(query["limit"], ["30"])
def test_url_drops_params_that_are_none(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
url = client.url("/api/log", limit=None, since=0)
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
self.assertNotIn("limit", query)
self.assertEqual(query["since"], ["0"], "0 是有效值,不能当 None 丢掉")
def test_url_is_fully_qualified(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
self.assertTrue(client.url("/api/state").startswith("http://127.0.0.1:1/api/state?"))
def test_stream_url_carries_farm_and_name(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", name="阿黄")
parts = urllib.parse.urlsplit(client.stream_url)
self.assertEqual(parts.path, "/api/stream")
query = urllib.parse.parse_qs(parts.query)
self.assertEqual(query["farm"], ["a"])
# 令牌只能走查询参数:EventSource / urllib 都没法给长连接加自定义头
self.assertEqual(query["name"], ["阿黄"])
def test_stream_url_skips_the_empty_name(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", name="")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.stream_url).query)
self.assertNotIn("name", query)
def test_stream_url_includes_the_token(self):
client = offline_client(endpoint="http://127.0.0.1:1/", farm="a", token="t")
query = urllib.parse.parse_qs(urllib.parse.urlsplit(client.stream_url).query)
self.assertEqual(query["token"], ["t"])
# ===================================================================== SSE 消息分发
class StreamMessageTestCase(unittest.TestCase):
def setUp(self):
self.client = offline_client(endpoint="http://127.0.0.1:1/", farm="a")
def push(self, message):
import json
self.client._handle_stream_message(json.dumps(message, ensure_ascii=False))
def test_welcome_installs_state_catalog_and_server_info(self):
self.push(
{
"type": "welcome",
"farm": "b",
"online": 4,
"server": {"version": "2.3"},
"catalog": {"crops": [{"key": "carrot"}]},
"state": {"day": 6, "gold": 88, "plots": [plot_dict()], "animals": []},
"log": [],
}
)
self.assertEqual(self.client.farm, "b")
self.assertEqual(self.client.server_info, {"version": "2.3"})
self.assertEqual(self.client.catalog()["crops"], [{"key": "carrot"}])
self.assertEqual(self.client.state.day, 6)
self.assertEqual(self.client.state.gold, 88)
self.assertEqual(self.client.state.get("online"), 4, "在线人数要合进状态视图")
self.assertEqual(len(self.client.state.plots), 1)
self.assertEqual(self.client.stats["pushes"], 1)
def test_state_and_sync_share_the_welcome_branch(self):
for kind in ("state", "sync"):
with self.subTest(kind=kind):
self.push({"type": kind, "state": {"gold": 7}, "plots": None})
self.assertEqual(self.client.state.gold, 7)
self.assertEqual(self.client.stats["pushes"], 2)
def test_brief_state_push_does_not_wipe_the_farm(self):
self.push({"type": "state", "state": {"plots": [plot_dict()], "animals": []}})
self.push({"type": "state", "state": {"gold": 3}})
self.assertEqual(len(self.client.state.plots), 1)
self.assertEqual(self.client.state.gold, 3)
def test_push_without_state_still_counts_and_keeps_old_data(self):
self.push({"type": "state", "state": {"day": 9}})
self.push({"type": "state"})
self.assertEqual(self.client.state.day, 9)
self.assertEqual(self.client.stats["pushes"], 2)
def test_push_ignores_a_bad_online_value(self):
self.push({"type": "state", "online": "很多", "state": {"day": 1}})
self.assertIsNone(self.client.state.get("online"))
def test_online_is_dropped_when_the_state_is_empty(self):
self.push({"type": "state", "online": 3})
self.assertIsNone(self.client.state.get("online"))
def test_push_merges_the_log(self):
self.push(
{
"type": "state",
"state": {"day": 1},
"log": [{"seq": 2, "text": "b"}, {"seq": 1, "text": "a"}],
}
)
entries = self.client.recent_log()
self.assertEqual([e["seq"] for e in entries], [1, 2], "日志要按 seq 排序")
self.assertEqual([e["text"] for e in entries], ["a", "b"])
def test_day_message_appends_a_header_and_its_events(self):
self.push({"type": "day", "day": 4, "events": ["奶牛生病了", "一块地旱了"]})
entries = self.client.recent_log()
self.assertEqual(len(entries), 3)
self.assertEqual(entries[0]["text"], "第 4 天开始")
self.assertEqual(entries[0]["kind"], "info")
self.assertEqual([e["text"] for e in entries[1:]], ["奶牛生病了", "一块地旱了"])
self.assertTrue(all(e["kind"] == "warn" for e in entries[1:]))
self.assertTrue(all(e["day"] == 4 for e in entries))
def test_day_message_continues_the_sequence(self):
self.push({"type": "state", "state": {"day": 1}, "log": [{"seq": 5, "text": "旧"}]})
self.push({"type": "day", "day": 2, "events": ["一"]})
self.assertEqual([e["seq"] for e in self.client.recent_log()], [5, 6, 7])
def test_day_message_survives_missing_events(self):
self.push({"type": "day", "day": 2})
entries = self.client.recent_log()
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["text"], "第 2 天开始")
def test_error_message_is_recorded(self):
self.push({"type": "error", "message": "牧场不存在"})
self.assertEqual(self.client.last_error, "牧场不存在")
self.assertEqual(self.client.stats["errors"], 1)
def test_bye_disconnects_with_a_reason(self):
self.client.connected = True
self.push({"type": "bye", "message": "服务器正在停机"})
self.assertFalse(self.client.connected)
self.assertEqual(self.client.last_error, "服务器正在停机")
def test_bye_without_a_reason_uses_a_default(self):
self.client.connected = True
self.push({"type": "bye"})
self.assertFalse(self.client.connected)
self.assertEqual(self.client.last_error, "服务器已停机")
def test_malformed_payloads_are_ignored(self):
for payload in ("不是 JSON", "[1, 2, 3]", '"字符串"', "3", ""):
with self.subTest(payload=payload):
self.client._handle_stream_message(payload)
self.assertEqual(self.client.stats["pushes"], 0)
self.assertEqual(self.client.stats["errors"], 0)
self.assertEqual(self.client.last_error, "")
def test_unknown_message_types_are_ignored(self):
self.push({"type": "谁知道呢", "state": {"gold": 7}})
self.assertEqual(self.client.state.gold, 0)
self.assertEqual(self.client.stats["pushes"], 0)
# ===================================================================== 日志与回调
class LogAndCallbackTestCase(unittest.TestCase):
def setUp(self):
self.client = offline_client(endpoint="http://127.0.0.1:1/")
def test_merge_log_deduplicates_by_seq(self):
self.client._merge_log([{"seq": 1, "text": "a"}])
self.client._merge_log([{"seq": 1, "text": "a"}, {"seq": 2, "text": "b"}])
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1, 2])
def test_merge_log_skips_entries_without_a_sequence(self):
self.client._merge_log([{"text": "无号"}, {"seq": 0, "text": "零号"}, {"seq": 1}])
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1])
def test_merge_log_keeps_only_the_most_recent_entries(self):
self.client._merge_log([{"seq": n} for n in range(1, LOG_KEEP + 51)])
entries = self.client.recent_log(limit=0)
self.assertEqual(len(entries), LOG_KEEP)
self.assertEqual(entries[-1]["seq"], LOG_KEEP + 50)
def test_recent_log_filters_by_since(self):
self.client._merge_log([{"seq": n} for n in range(1, 6)])
self.assertEqual([e["seq"] for e in self.client.recent_log(since=3)], [4, 5])
def test_recent_log_limit_takes_the_tail(self):
self.client._merge_log([{"seq": n} for n in range(1, 6)])
self.assertEqual([e["seq"] for e in self.client.recent_log(limit=2)], [4, 5])
def test_recent_log_limit_zero_returns_everything(self):
self.client._merge_log([{"seq": n} for n in range(1, 6)])
self.assertEqual(len(self.client.recent_log(limit=0)), 5)
def test_apply_fires_the_change_callback(self):
hits = []
self.client.on_change = lambda: hits.append(1)
self.client._apply({"gold": 1})
self.client._apply({"gold": 2})
self.assertEqual(len(hits), 2)
def test_apply_does_not_fire_for_empty_state(self):
hits = []
self.client.on_change = lambda: hits.append(1)
self.client._apply({})
self.assertEqual(hits, [])
def test_a_broken_callback_cannot_break_data_sync(self):
def boom():
raise RuntimeError("界面炸了")
self.client.on_change = boom
self.client._apply({"gold": 42})
self.assertEqual(self.client.state.gold, 42)
def test_transport_label_reports_the_active_channel(self):
self.client.mode = "sse"
self.assertEqual(self.client.transport_label(), "SSE 实时")
self.client.mode = "poll"
self.assertEqual(self.client.transport_label(), "HTTP 轮询")
self.client.mode = "http"
self.assertEqual(self.client.transport_label(), "HTTP")
def test_catalog_starts_with_local_config_defaults(self):
catalog = self.client.catalog()
self.assertEqual(catalog["feed_price"], config.FEED_UNIT_PRICE)
self.assertEqual(catalog["water_capacity"], config.WATER_CAPACITY)
self.assertEqual(catalog["crops"], [])
# ===================================================================== 动作转发
class ActionForwardingTestCase(unittest.TestCase):
"""写操作统一走 POST /api/actions —— 用假的 _http 把请求原样抓下来核对。"""
def setUp(self):
self.client = offline_client(endpoint="http://127.0.0.1:1/")
self.requests: list[tuple[str, str, dict, dict]] = []
self.reply: dict = {}
self.client._http = self._fake_http # type: ignore[method-assign]
def _fake_http(self, method, path, payload=None, **params):
self.requests.append((method, path, payload or {}, params))
return self.reply
def last(self):
return self.requests[-1]
def test_plant_posts_the_right_payload(self):
self.client.plant(3, "carrot")
method, path, payload, params = self.last()
self.assertEqual(method, "POST")
self.assertEqual(path, "/api/actions")
self.assertEqual(payload, {"action": "plant", "params": {"plot": 3, "crop": "carrot"}})
self.assertEqual(params, {"full": 1}, "顺手要一份完整快照,省掉操作后再查一次")
def test_simple_actions_carry_their_arguments(self):
cases = [
(lambda: self.client.water(2), "water", {"plot": 2}),
(lambda: self.client.harvest(1), "harvest", {"plot": 1}),
(lambda: self.client.harvest_all(), "harvest_all", {}),
(lambda: self.client.buy_animal("cow"), "buy_animal", {"kind": "cow"}),
(lambda: self.client.collect(7), "collect", {"animal_id": 7}),
(lambda: self.client.buy_plot(), "buy_plot", {}),
]
for call, action, params in cases:
with self.subTest(action=action):
call()
self.assertEqual(self.last()[2], {"action": action, "params": params})
def test_feed_omits_quantity_when_not_given(self):
self.client.feed(7)
self.assertEqual(self.last()[2]["params"], {"animal_id": 7})
def test_feed_forwards_quantity(self):
self.client.feed(7, 3)
self.assertEqual(self.last()[2]["params"], {"animal_id": 7, "quantity": 3})
def test_buy_feed_defaults_to_bulk(self):
self.client.buy_feed()
self.assertEqual(self.last()[2]["params"], {"quantity": config.FEED_BULK})
def test_pause_without_argument_is_a_toggle(self):
self.client.pause()
self.assertEqual(self.last()[2], {"action": "pause", "params": {}})
def test_pause_forwards_an_explicit_value(self):
self.client.pause(True)
self.assertEqual(self.last()[2]["params"], {"paused": True})
def test_pause_forwards_an_explicit_false(self):
# False 与"没传"必须区分开,否则永远只能切不能停
self.client.pause(False)
self.assertEqual(self.last()[2]["params"], {"paused": False})
def test_save_forwards_to_the_server(self):
self.client.save()
self.assertEqual(self.last()[2], {"action": "save", "params": {}})
def test_batch_posts_a_single_request(self):
items = [{"action": "water", "params": {"plot": 1}}, {"action": "harvest", "params": {}}]
self.client.batch(items)
method, path, payload, params = self.last()
self.assertEqual(method, "POST")
self.assertEqual(path, "/api/batch")
self.assertEqual(payload, {"actions": items})
self.assertEqual(params, {"full": 1})
self.assertEqual(len(self.requests), 1, "批量就该是一个请求")
def test_action_applies_the_returned_snapshot(self):
self.reply = {"snapshot": {"plots": [plot_dict()], "gold": 10}, "log": [{"seq": 1}]}
self.client.water(1)
self.assertEqual(self.client.state.gold, 10)
self.assertEqual(len(self.client.state.plots), 1)
self.assertEqual(len(self.client.recent_log()), 1)
def test_action_falls_back_to_the_brief_state(self):
self.reply = {"state": {"gold": 5}}
self.client.water(1)
self.assertEqual(self.client.state.gold, 5)
def test_snapshot_wins_over_brief_state(self):
self.reply = {"snapshot": {"gold": 10}, "state": {"gold": 5}}
self.client.water(1)
self.assertEqual(self.client.state.gold, 10)
def test_reset_clears_the_log_and_refreshes(self):
self.client._merge_log([{"seq": 1, "text": "旧"}])
refreshed = []
original = self.client.refresh
self.client.refresh = lambda *a, **k: refreshed.append(1) or True # type: ignore[method-assign]
self.client.reset()
self.client.refresh = original # type: ignore[method-assign]
self.assertEqual(self.client.recent_log(), [])
self.assertEqual(self.last()[2], {"action": "reset", "params": {}})
self.assertEqual(refreshed, [1], "重置后要重新拉全量")
# ===================================================================== 实时通道
class RealtimeTestCase(unittest.TestCase):
def test_start_is_a_noop_when_realtime_is_off(self):
client = offline_client(endpoint="http://127.0.0.1:1/", realtime=False)
client.start()
self.assertIsNone(client._thread)
def test_start_spawns_only_one_thread(self):
client = offline_client(endpoint="http://127.0.0.1:1/", realtime=True)
client._realtime_loop = lambda: None # type: ignore[method-assign]
try:
client.start()
first = client._thread
client.start()
self.assertIs(client._thread, first, "重复 start 不该开第二条连接")
finally:
client.stop()
def test_stop_is_safe_without_a_connection(self):
client = offline_client(endpoint="http://127.0.0.1:1/")
client.stop() # 不该抛
self.assertTrue(client._stop.is_set())
def test_underlying_socket_walks_the_attribute_chain(self):
class _Raw:
_sock = "套接字"
class _Fp:
raw = _Raw()
class _Response:
fp = _Fp()
self.assertEqual(_underlying_socket(_Response()), "套接字")
def test_underlying_socket_degrades_to_none(self):
"""掏不到就返回 None,调用方会退化成 close() —— 允许变慢,不允许崩。"""
for fake in (object(), type("R", (), {"fp": None})(), type("R", (), {"fp": object()})()):
with self.subTest(fake=type(fake).__name__):
self.assertIsNone(_underlying_socket(fake))
def test_stop_shuts_down_the_stream_socket(self):
events = []
class _Sock:
def shutdown(self, how):
events.append(("shutdown", how))
class _Resp:
def close(self):
events.append(("close", None))
client = offline_client(endpoint="http://127.0.0.1:1/")
client._stream_socket = _Sock()
client._stream_response = _Resp()
client.stop()
self.assertIn(("shutdown", socket.SHUT_RDWR), events)
self.assertIn(("close", None), events)
# shutdown 必须在 close 之前:close 会等读线程的锁,最长拖到下一次心跳
self.assertLess(events.index(("shutdown", socket.SHUT_RDWR)), events.index(("close", None)))
def test_stop_tolerates_broken_stream_objects(self):
class _Sock:
def shutdown(self, how):
raise OSError("已经断了")
class _Resp:
def close(self):
raise RuntimeError("也炸了")
client = offline_client(endpoint="http://127.0.0.1:1/")
client._stream_socket = _Sock()
client._stream_response = _Resp()
client.stop() # 停机路径必须稳
class BotClientTestCase(unittest.TestCase):
def test_wait_online_returns_as_soon_as_refresh_works(self):
client = RemoteBotClient(endpoint="http://127.0.0.1:1/", autostart=False, realtime=False)
client.refresh = lambda *a, **k: True # type: ignore[method-assign]
self.assertTrue(client.wait_online(seconds=5))
def test_wait_online_gives_up_after_the_deadline(self):
client = RemoteBotClient(endpoint="http://127.0.0.1:1/", autostart=False, realtime=False)
calls = []
client.refresh = lambda *a, **k: calls.append(1) or False # type: ignore[method-assign]
self.assertFalse(client.wait_online(seconds=0))
self.assertEqual(calls, [], "时限已过就不该再去连")
# ===================================================================== HTTP 传输层
class HttpTransportTestCase(unittest.TestCase):
"""用纯标准库的回环桩服务验证 _http 的成功与各类失败分支。
这不是"外部服务器":桩只绑 127.0.0.1,起不来就整类跳过。
顺带验证了 net.open_url 的回环直连——本机设了 HTTP_PROXY,
走代理的话这里会拿到 502,而不是桩真正返回的内容。
"""
server: http.server.ThreadingHTTPServer
@classmethod
def setUpClass(cls):
payload = b'{"ok": true, "state": {"day": 3, "gold": 42}, "online": 2}'
_StubHandler.payloads = {
"/api/state": (200, payload, "application/json"),
"/api/catalog": (
200,
b'{"ok": true, "catalog": {"feed_price": 6}}',
"application/json",
),
"/api/log": (
200,
b'{"ok": true, "entries": [{"seq": 1, "text": "a"}]}',
"application/json",
),
"/api/bad": (200, "<html>不是 JSON</html>".encode(), "text/html"),
"/api/list": (200, b"[1, 2, 3]", "application/json"),
"/api/empty": (204, b"", "application/json"),
"/api/rejected": (
400,
'{"ok": false, "error": "locked", "message": "这块地还没解锁"}'.encode(),
"application/json",
),
"/api/explode": (500, "内部错误,没有 JSON".encode(), "text/plain"),
}
try:
cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler)
except OSError as exc: # pragma: no cover - 环境不允许绑端口
raise unittest.SkipTest(f"起不了回环桩服务:{exc}") from exc
cls.port = int(cls.server.server_address[1])
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=5)
def setUp(self):
self.client = offline_client(endpoint=f"127.0.0.1:{self.port}", farm="测试场")
def test_get_returns_the_parsed_body(self):
data = self.client._http("GET", "/api/state")
self.assertEqual(data["state"]["gold"], 42)
self.assertTrue(self.client.connected)
self.assertEqual(self.client.last_error, "")
self.assertEqual(self.client.stats["http"], 1)
def test_loopback_requests_do_not_go_through_the_proxy(self):
# 桩返回的是我们自己的 JSON;被系统代理截走的话这里会变成 502 页面
self.assertEqual(self.client._http("GET", "/api/catalog")["catalog"]["feed_price"], 6)
def test_open_url_accepts_a_plain_string(self):
url = f"http://127.0.0.1:{self.port}/api/state"
with net.open_url(url, timeout=5) as response:
self.assertEqual(response.status, 200)
self.assertIn("gold", response.read().decode("utf-8"))
def test_refresh_pulls_state_catalog_and_log(self):
self.assertTrue(self.client.refresh())
self.assertEqual(self.client.state.day, 3)
self.assertEqual(self.client.state.gold, 42)
self.assertEqual(self.client.state.get("online"), 2)
self.assertEqual(self.client.catalog()["feed_price"], 6)
self.assertEqual([e["seq"] for e in self.client.recent_log()], [1])
self.assertEqual(self.client.stats["http"], 3)
def test_refresh_swallows_errors_by_default(self):
client = offline_client(endpoint="127.0.0.1:1", farm="测试场") # 没人监听
self.assertFalse(client.refresh())
self.assertFalse(client.connected)
self.assertTrue(client.last_error)
def test_refresh_can_raise_on_error(self):
client = offline_client(endpoint="127.0.0.1:1", farm="测试场")
with self.assertRaises(GameError) as ctx:
client.refresh(raise_on_error=True)
self.assertEqual(ctx.exception.code, "network")
def test_empty_body_is_treated_as_an_empty_object(self):
self.assertEqual(self.client._http("GET", "/api/empty"), {})
def test_non_json_body_is_reported_as_a_bad_response(self):
with self.assertRaises(GameError) as ctx:
self.client._http("GET", "/api/bad")
self.assertEqual(ctx.exception.code, "bad_response")
self.assertEqual(ctx.exception.status, 502)
def test_json_that_is_not_an_object_is_rejected(self):
with self.assertRaises(GameError) as ctx:
self.client._http("GET", "/api/list")
self.assertEqual(ctx.exception.code, "bad_response")
self.assertIn("不是对象", ctx.exception.message)
def test_server_rejection_becomes_a_game_error(self):
with self.assertRaises(GameError) as ctx:
self.client._http("POST", "/api/rejected", {"action": "plant"})
self.assertEqual(ctx.exception.code, "locked")
self.assertEqual(ctx.exception.message, "这块地还没解锁")
self.assertEqual(ctx.exception.status, 400)
self.assertEqual(self.client.stats["errors"], 1)
def test_error_responses_that_are_not_json_say_so(self):
with self.assertRaises(GameError) as ctx:
self.client._http("GET", "/api/explode")
self.assertEqual(ctx.exception.code, "bad_response")
def test_a_dead_server_becomes_a_network_error_with_a_hint(self):
dead = offline_client(endpoint=f"127.0.0.1:{free_port()}", farm="测试场")
with self.assertRaises(GameError) as ctx:
dead._http("GET", "/api/state")
self.assertEqual(ctx.exception.code, "network")
self.assertEqual(ctx.exception.status, 503)
self.assertIn("main.py serve", ctx.exception.message, "错误信息要告诉用户怎么补救")
self.assertFalse(dead.connected)
self.assertEqual(dead.stats["errors"], 1)
def test_action_response_is_applied_end_to_end(self):
_StubHandler.payloads["/api/actions"] = (
200,
b'{"ok": true, "snapshot": {"gold": 7, "plots": []}, "log": [{"seq": 9}]}',
"application/json",
)
try:
self.client._action("harvest_all")
finally:
_StubHandler.payloads.pop("/api/actions", None)
self.assertEqual(self.client.state.gold, 7)
self.assertEqual([e["seq"] for e in self.client.recent_log()], [9])
# ===================================================================== 代理绕行
class NetTestCase(unittest.TestCase):
def test_loopback_hosts_are_recognized(self):
for host in ("127.0.0.1", "127.0.0.5", "localhost", "LocalHost", "::1", "[::1]", ""):
with self.subTest(host=host):
self.assertTrue(net.is_loopback(host))
def test_loopback_check_tolerates_surrounding_space(self):
self.assertTrue(net.is_loopback(" 127.0.0.1 "))
def test_remote_hosts_are_not_loopback(self):
for host in ("farm.example.com", "10.0.0.1", "192.168.1.9", "128.0.0.1", None):
with self.subTest(host=host):
self.assertFalse(net.is_loopback(host))
def test_host_of_extracts_from_a_string_or_a_request(self):
self.assertEqual(net.host_of("http://127.0.0.1:8765/api/state"), "127.0.0.1")
request = urllib.request.Request("http://example.com:9000/x")
self.assertEqual(net.host_of(request), "example.com")
def test_host_of_returns_none_when_unparseable(self):
self.assertIsNone(net.host_of("没有协议也没有主机"))
def test_endpoint_host_accepts_a_bare_host_port(self):
self.assertEqual(net.endpoint_host("127.0.0.1:8765"), "127.0.0.1")
def test_endpoint_host_falls_back_to_the_default(self):
self.assertEqual(net.endpoint_host("http://"), "127.0.0.1")
self.assertEqual(net.endpoint_host("", default="10.0.0.1"), "10.0.0.1")
def test_the_loopback_opener_registers_no_proxy_handler_at_all(self):
"""回环直连的机制:这个 opener 里压根没有代理处理器。
``ProxyHandler({})`` 不会注册任何 ``*_open`` 钩子,而 ``add_handler``
只在"至少挂上一个钩子"时才把 handler 记进 ``handlers``,所以它自己
反而没被收进去——结果是 http 请求直接进 ``HTTPHandler``,必然直连。
"""
handlers = net._no_proxy_opener.handlers
self.assertFalse([h for h in handlers if isinstance(h, urllib.request.ProxyHandler)])
if __name__ == "__main__": # pragma: no cover
unittest.main()