Files
SimpleFarm/tests/test_client.py
T
snowy-yang f8e093c98f 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)。
2026-09-19 12:55:45 +08:00

991 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""远程客户端测试:视图语义、地址归一化、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()