三件事凑成一次不兼容变更: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)。
1266 lines
50 KiB
Python
1266 lines
50 KiB
Python
"""服务器层测试:牧场会话、调度时钟、SSE 订阅池、HTTP 接口、守护进程。
|
||
|
||
分成三档,缺依赖时自动降级而不是报错:
|
||
|
||
* 纯逻辑档(session / hub / daemon / 工具函数)——只需标准库;
|
||
* **TestClient 档**——用 FastAPI 的进程内客户端打接口,需要 ``httpx``;
|
||
* **真网络档**——真起 uvicorn、真开 socket、真读 SSE 流,只用标准库。
|
||
|
||
uv run python -m unittest tests.test_server -v
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
import urllib.request
|
||
|
||
from farm import config, net
|
||
from farm.engine import GameError
|
||
from farm.server.daemon import DaemonManager, pid_alive, port_busy
|
||
from farm.server.hub import (
|
||
CLOSE_MESSAGE,
|
||
Client,
|
||
ConnectionHub,
|
||
Publisher,
|
||
build_state_message,
|
||
next_seq,
|
||
sse_frame,
|
||
sse_heartbeat,
|
||
)
|
||
from farm.server.session import (
|
||
DEFAULT_FARM,
|
||
FarmClock,
|
||
FarmSession,
|
||
SessionManager,
|
||
validate_farm_id,
|
||
validate_owner,
|
||
)
|
||
|
||
try: # pragma: no cover - 环境相关
|
||
import fastapi # noqa: F401
|
||
|
||
from farm.server.app import (
|
||
BRIEF_KEYS,
|
||
ServerConfig,
|
||
ServerHandle,
|
||
_int,
|
||
as_asgi,
|
||
brief,
|
||
create_app,
|
||
rules_text,
|
||
)
|
||
|
||
HAS_FASTAPI = True
|
||
except ImportError: # pragma: no cover
|
||
HAS_FASTAPI = False
|
||
|
||
try: # pragma: no cover
|
||
from fastapi.testclient import TestClient
|
||
|
||
HAS_TESTCLIENT = True
|
||
except Exception: # pragma: no cover - 缺 httpx 时走这里
|
||
HAS_TESTCLIENT = False
|
||
|
||
try: # pragma: no cover
|
||
import uvicorn
|
||
|
||
HAS_UVICORN = True
|
||
except ImportError: # pragma: no cover
|
||
HAS_UVICORN = False
|
||
|
||
|
||
def free_port() -> int:
|
||
"""向系统要一个当前空闲的端口。"""
|
||
with socket.socket() as probe:
|
||
probe.bind(("127.0.0.1", 0))
|
||
return int(probe.getsockname()[1])
|
||
|
||
|
||
def wait_until(predicate, timeout: float = 6.0, interval: float = 0.05) -> bool:
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
if predicate():
|
||
return True
|
||
time.sleep(interval)
|
||
return bool(predicate())
|
||
|
||
|
||
class FakeLoop:
|
||
"""够用的假事件循环:直接同步执行,省掉为每条推送起真循环。"""
|
||
|
||
def __init__(self, broken: bool = False) -> None:
|
||
self.calls = 0
|
||
self.broken = broken
|
||
|
||
def call_soon_threadsafe(self, func, *args) -> None:
|
||
if self.broken:
|
||
raise RuntimeError("event loop is closed")
|
||
self.calls += 1
|
||
func(*args)
|
||
|
||
|
||
class FarmIdTestCase(unittest.TestCase):
|
||
"""牧场 ID 会变成存档文件名,所以它同时是安全边界。"""
|
||
|
||
def test_empty_falls_back_to_default(self):
|
||
self.assertEqual(validate_farm_id(None), DEFAULT_FARM)
|
||
self.assertEqual(validate_farm_id(""), DEFAULT_FARM)
|
||
# 只有 None 与空串才算没给;纯空白会被 strip 成空串,从而被正则挡下
|
||
with self.assertRaises(GameError) as ctx:
|
||
validate_farm_id(" ")
|
||
self.assertEqual(ctx.exception.code, "bad_farm")
|
||
|
||
def test_normal_ids_pass(self):
|
||
for raw in ("alice", "ai-1", "farm_01", "牧场一号", "A" * 24, "123"):
|
||
with self.subTest(raw=raw):
|
||
self.assertEqual(validate_farm_id(raw), raw)
|
||
|
||
def test_surrounding_whitespace_is_trimmed(self):
|
||
self.assertEqual(validate_farm_id(" alice "), "alice")
|
||
|
||
def test_numbers_are_stringified(self):
|
||
self.assertEqual(validate_farm_id(42), "42")
|
||
|
||
def test_path_traversal_is_refused(self):
|
||
"""这是这个校验存在的核心理由:ID 会被拼进文件路径。"""
|
||
for raw in ("../secret", "..\\secret", "a/b", "a\\b", "..", "."):
|
||
with self.subTest(raw=raw):
|
||
with self.assertRaises(GameError) as ctx:
|
||
validate_farm_id(raw)
|
||
self.assertEqual(ctx.exception.code, "bad_farm")
|
||
|
||
def test_other_illegal_shapes_are_refused(self):
|
||
for raw in ("a" * 25, "a b", "a.b", "a:b", "a*b", "牧场\n一号", "\x00"):
|
||
with self.subTest(raw=raw):
|
||
with self.assertRaises(GameError) as ctx:
|
||
validate_farm_id(raw)
|
||
self.assertEqual(ctx.exception.code, "bad_farm")
|
||
self.assertEqual(ctx.exception.status, 400)
|
||
|
||
def test_non_scalar_is_refused(self):
|
||
with self.assertRaises(GameError) as ctx:
|
||
validate_farm_id(["alice"])
|
||
self.assertEqual(ctx.exception.code, "bad_farm")
|
||
|
||
|
||
class OwnerTestCase(unittest.TestCase):
|
||
def test_missing_owner_is_empty_string(self):
|
||
self.assertEqual(validate_owner(None), "")
|
||
|
||
def test_scalars_are_accepted_and_trimmed(self):
|
||
self.assertEqual(validate_owner(" alice "), "alice")
|
||
self.assertEqual(validate_owner(42), "42")
|
||
|
||
def test_overlong_owner_is_refused(self):
|
||
with self.assertRaises(GameError) as ctx:
|
||
validate_owner("x" * 33)
|
||
self.assertEqual(ctx.exception.code, "bad_param")
|
||
|
||
def test_containers_are_refused(self):
|
||
with self.assertRaises(GameError):
|
||
validate_owner(["alice"])
|
||
|
||
|
||
class FarmSessionTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-session-")
|
||
|
||
def test_clock_is_scheduled_immediately(self):
|
||
"""回归:不设 next_tick_at 的话,新建牧场头 0.2 秒里倒计时是 None,
|
||
客户端会把它显示成"已暂停 / 手动模式"。"""
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
self.assertIsNotNone(session.engine.next_tick_at)
|
||
self.assertIsNotNone(session.snapshot()["seconds_to_tick"])
|
||
self.assertGreater(session.snapshot()["seconds_to_tick"], 0)
|
||
|
||
def test_snapshot_and_summary_carry_both_layers(self):
|
||
session = FarmSession("alice", self.dir, 2400.0, owner="爱丽丝")
|
||
snap = session.snapshot()
|
||
self.assertEqual(snap["farm"], "alice")
|
||
self.assertEqual(snap["owner"], "爱丽丝")
|
||
self.assertEqual(snap["tick_seconds"], 2400.0)
|
||
|
||
summary = session.summary(online=2)
|
||
self.assertEqual(summary["farm"], "alice")
|
||
self.assertEqual(summary["online"], 2)
|
||
self.assertIn("plots", summary)
|
||
self.assertIn("idle_seconds", summary)
|
||
|
||
def test_owner_defaults_to_the_farm_id(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
self.assertEqual(session.owner, "alice")
|
||
|
||
def test_new_farm_writes_a_welcome_log(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
texts = [entry["text"] for entry in session.engine.state.log]
|
||
self.assertTrue(any("alice" in text for text in texts))
|
||
|
||
def test_save_then_reload_keeps_progress(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.engine.state.day = 9
|
||
session.engine.state.gold = 777
|
||
path = session.save()
|
||
self.assertTrue(os.path.exists(path))
|
||
|
||
reopened = FarmSession("alice", self.dir, 2400.0)
|
||
self.assertEqual(reopened.engine.state.day, 9)
|
||
self.assertEqual(reopened.engine.state.gold, 777)
|
||
self.assertIn("第 9 天", reopened.load_message)
|
||
|
||
def test_broken_save_degrades_to_a_new_farm(self):
|
||
path = os.path.join(self.dir, "alice.json")
|
||
with open(path, "w", encoding="utf-8") as handle:
|
||
handle.write("这不是 JSON")
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
self.assertEqual(session.engine.state.day, 1)
|
||
self.assertIn("已开新档", session.load_message)
|
||
|
||
def test_reset_clears_progress_and_counts_as_an_action(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.engine.state.day = 5
|
||
session.reset()
|
||
self.assertEqual(session.engine.state.day, 1)
|
||
self.assertEqual(session.actions_taken, 1)
|
||
|
||
def test_advance_day_remembers_the_events(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.engine.plant(1, "wheat")
|
||
session.advance_day("测试")
|
||
self.assertEqual(session.engine.state.day, 2)
|
||
self.assertIsInstance(session.last_events, list)
|
||
|
||
def test_touch_marks_dirty_and_counts_actions(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.dirty = False
|
||
session.touch(count_action=True)
|
||
self.assertTrue(session.dirty)
|
||
self.assertEqual(session.actions_taken, 1)
|
||
|
||
def test_autosave_skips_when_clean(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.dirty = False
|
||
session.saved_at = 0.0
|
||
self.assertFalse(session.autosave())
|
||
|
||
def test_autosave_writes_when_dirty(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.dirty = True
|
||
session.saved_at = 0.0
|
||
self.assertTrue(session.autosave())
|
||
self.assertTrue(os.path.exists(session.path))
|
||
|
||
def test_autosave_waits_between_writes(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
session.dirty = True
|
||
session.saved_at = time.time()
|
||
self.assertFalse(session.autosave(), "刚存过就不用再存")
|
||
|
||
def test_state_message_shape(self):
|
||
session = FarmSession("alice", self.dir, 2400.0)
|
||
message = session.state_message(seq=7, note="给你")
|
||
self.assertEqual(message["type"], "state")
|
||
self.assertEqual(message["seq"], 7)
|
||
self.assertEqual(message["farm"], "alice")
|
||
self.assertEqual(message["note"], "给你")
|
||
self.assertIn("state", message)
|
||
self.assertIn("log", message)
|
||
|
||
|
||
class SessionManagerTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-manager-")
|
||
|
||
def manager(self, **kwargs) -> SessionManager:
|
||
return SessionManager(save_dir=self.dir, tick_seconds=2400.0, **kwargs)
|
||
|
||
def test_get_creates_and_caches(self):
|
||
manager = self.manager()
|
||
first = manager.get("alice")
|
||
second = manager.get("alice")
|
||
self.assertIs(first, second)
|
||
self.assertEqual(manager.count(), 1)
|
||
self.assertEqual(manager.ids(), ["alice"])
|
||
|
||
def test_get_without_create_reports_404(self):
|
||
manager = self.manager()
|
||
with self.assertRaises(GameError) as ctx:
|
||
manager.get("ghost", create=False)
|
||
self.assertEqual(ctx.exception.code, "no_such_farm")
|
||
self.assertEqual(ctx.exception.status, 404)
|
||
|
||
def test_find_does_not_create(self):
|
||
manager = self.manager()
|
||
self.assertIsNone(manager.find("ghost"))
|
||
manager.get("alice")
|
||
self.assertIsNotNone(manager.find("alice"))
|
||
|
||
def test_capacity_limit_is_503(self):
|
||
manager = self.manager(max_sessions=2)
|
||
manager.get("a")
|
||
manager.get("b")
|
||
with self.assertRaises(GameError) as ctx:
|
||
manager.get("c")
|
||
self.assertEqual(ctx.exception.code, "too_many_farms")
|
||
self.assertEqual(ctx.exception.status, 503)
|
||
|
||
def test_farms_are_isolated_from_each_other(self):
|
||
manager = self.manager()
|
||
alice = manager.get("alice")
|
||
bob = manager.get("bob")
|
||
alice.engine.plant(1, "wheat")
|
||
alice.engine.state.gold = 999
|
||
self.assertTrue(bob.engine.state.plots[0].is_empty)
|
||
self.assertEqual(bob.engine.state.gold, config.START_GOLD)
|
||
|
||
def test_leaderboard_sorts_by_net_worth(self):
|
||
manager = self.manager()
|
||
rich = manager.get("rich")
|
||
manager.get("poor")
|
||
rich.engine.state.gold = 5000
|
||
rows = manager.leaderboard()
|
||
self.assertEqual(rows[0]["farm"], "rich")
|
||
self.assertEqual([row["rank"] for row in rows], [1, 2])
|
||
self.assertGreater(rows[0]["net_worth"], rows[1]["net_worth"])
|
||
self.assertGreaterEqual(rows[0]["net_worth"], 5000.0)
|
||
|
||
def test_leaderboard_shows_online_counts(self):
|
||
manager = self.manager()
|
||
manager.get("alice")
|
||
rows = manager.leaderboard({"alice": 3})
|
||
self.assertEqual(rows[0]["online"], 3)
|
||
|
||
def test_reap_drops_idle_farms_after_saving(self):
|
||
manager = self.manager(idle_timeout=0.01)
|
||
session = manager.get("alice")
|
||
session.engine.state.gold = 321
|
||
session.last_active = time.time() - 10
|
||
|
||
dropped = manager.reap()
|
||
self.assertEqual(dropped, ["alice"])
|
||
self.assertEqual(manager.count(), 0)
|
||
self.assertTrue(os.path.exists(session.path), "回收前必须先落盘")
|
||
|
||
def test_reap_keeps_farms_that_are_still_fresh(self):
|
||
manager = self.manager(idle_timeout=3600)
|
||
manager.get("alice")
|
||
self.assertEqual(manager.reap(), [])
|
||
self.assertEqual(manager.count(), 1)
|
||
|
||
def test_reap_keeps_farms_with_viewers(self):
|
||
manager = self.manager(idle_timeout=0.01)
|
||
manager.get("alice")
|
||
session = manager.get("alice")
|
||
session.last_active = time.time() - 10
|
||
self.assertEqual(manager.reap({"alice": 1}), [])
|
||
self.assertEqual(manager.count(), 1)
|
||
|
||
def test_reap_keeps_paused_farms(self):
|
||
"""暂停是人为操作,说明玩家还打算回来。"""
|
||
manager = self.manager(idle_timeout=0.01)
|
||
session = manager.get("alice")
|
||
session.engine.pause(True)
|
||
session.last_active = time.time() - 10
|
||
self.assertEqual(manager.reap(), [])
|
||
self.assertEqual(manager.count(), 1)
|
||
|
||
def test_autosave_all_reports_how_many_were_written(self):
|
||
manager = self.manager()
|
||
manager.get("alice")
|
||
bob = manager.get("bob")
|
||
alice = manager.get("alice")
|
||
alice.dirty = True
|
||
bob.dirty = False
|
||
alice.saved_at = bob.saved_at = 0.0
|
||
self.assertEqual(manager.autosave_all(), 1)
|
||
|
||
def test_shutdown_saves_every_farm(self):
|
||
manager = self.manager()
|
||
for name in ("alice", "bob"):
|
||
manager.get(name)
|
||
manager.shutdown()
|
||
for name in ("alice", "bob"):
|
||
self.assertTrue(os.path.exists(os.path.join(self.dir, f"{name}.json")))
|
||
|
||
|
||
class FarmClockTestCase(unittest.TestCase):
|
||
"""多牧场共用一个时钟线程——不是每个牧场一个。"""
|
||
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-clock-")
|
||
self.manager = SessionManager(save_dir=self.dir, tick_seconds=2400.0)
|
||
|
||
def test_one_thread_advances_every_farm(self):
|
||
alice = self.manager.get("alice")
|
||
bob = self.manager.get("bob")
|
||
for session in (alice, bob):
|
||
session.engine.tick_seconds = 0.05 # 绕过 clamp,把一天压到 50ms
|
||
session.engine.next_tick_at = time.time() + session.engine.tick_seconds
|
||
|
||
clock = FarmClock(self.manager, accuracy=0.02)
|
||
clock.start()
|
||
try:
|
||
advanced = wait_until(
|
||
lambda: alice.engine.state.day >= 2 and bob.engine.state.day >= 2, timeout=4.0
|
||
)
|
||
finally:
|
||
clock.stop()
|
||
self.assertTrue(advanced, "两个牧场都应该自己往前走")
|
||
self.assertGreater(clock.ticks, 0)
|
||
|
||
def test_paused_farm_stays_put_while_others_run(self):
|
||
frozen = self.manager.get("frozen")
|
||
running = self.manager.get("running")
|
||
frozen.engine.pause(True)
|
||
for session in (frozen, running):
|
||
session.engine.tick_seconds = 0.05
|
||
session.engine.next_tick_at = time.time() + 0.05
|
||
|
||
clock = FarmClock(self.manager, accuracy=0.02)
|
||
clock.start()
|
||
try:
|
||
advanced = wait_until(lambda: running.engine.state.day >= 2, timeout=4.0)
|
||
finally:
|
||
clock.stop()
|
||
self.assertTrue(advanced)
|
||
self.assertEqual(frozen.engine.state.day, 1, "暂停的牧场不该被推进")
|
||
|
||
def test_stats_reports_ticks_and_accuracy(self):
|
||
clock = FarmClock(self.manager, accuracy=0.25)
|
||
self.assertEqual(clock.stats(), (0, 0.25))
|
||
|
||
|
||
class HubTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.hub = ConnectionHub()
|
||
|
||
def client(self, farm: str = "alice", broken: bool = False) -> Client:
|
||
return Client(farm, FakeLoop(broken=broken), name="tester")
|
||
|
||
def test_register_and_online_counts(self):
|
||
alice = self.client("alice")
|
||
bob = self.client("bob")
|
||
self.hub.register(alice)
|
||
self.hub.register(bob)
|
||
self.assertEqual(self.hub.online("alice"), 1)
|
||
self.assertEqual(self.hub.total(), 2)
|
||
self.assertEqual(self.hub.online_map(), {"alice": 1, "bob": 1})
|
||
|
||
def test_unregister_clears_the_empty_bucket(self):
|
||
alice = self.client("alice")
|
||
self.hub.register(alice)
|
||
self.hub.unregister(alice)
|
||
self.assertEqual(self.hub.online("alice"), 0)
|
||
self.assertFalse(alice.alive)
|
||
self.assertEqual(self.hub.online_map(), {})
|
||
|
||
def test_broadcast_stays_inside_one_farm(self):
|
||
alice = self.client("alice")
|
||
bob = self.client("bob")
|
||
self.hub.register(alice)
|
||
self.hub.register(bob)
|
||
sent = self.hub.broadcast("alice", {"type": "state"})
|
||
self.assertEqual(sent, 1)
|
||
self.assertEqual(alice.queue.qsize(), 1)
|
||
self.assertEqual(bob.queue.qsize(), 0, "不能把 A 的状态推给 B")
|
||
|
||
def test_broadcast_can_exclude_the_sender(self):
|
||
alice = self.client("alice")
|
||
other = self.client("alice")
|
||
self.hub.register(alice)
|
||
self.hub.register(other)
|
||
self.hub.broadcast("alice", {"type": "notice"}, exclude=alice)
|
||
self.assertEqual(alice.queue.qsize(), 0)
|
||
self.assertEqual(other.queue.qsize(), 1)
|
||
|
||
def test_broadcast_all_reaches_every_farm(self):
|
||
self.hub.register(self.client("alice"))
|
||
self.hub.register(self.client("bob"))
|
||
self.assertEqual(self.hub.broadcast_all({"type": "notice"}), 2)
|
||
|
||
def test_close_all_pushes_the_bye_message(self):
|
||
alice = self.client("alice")
|
||
self.hub.register(alice)
|
||
self.assertEqual(self.hub.close_all(), 1)
|
||
self.assertEqual(alice.queue.get_nowait()["type"], "bye")
|
||
self.assertEqual(CLOSE_MESSAGE["type"], "bye")
|
||
|
||
def test_snapshot_lists_connection_stats(self):
|
||
client = self.client("alice")
|
||
self.hub.register(client)
|
||
rows = self.hub.snapshot()
|
||
self.assertEqual(len(rows), 1)
|
||
self.assertEqual(rows[0]["client_id"], client.cid)
|
||
self.assertEqual(rows[0]["farm"], "alice")
|
||
self.assertIn("online_seconds", rows[0])
|
||
|
||
def test_push_is_dropped_when_the_loop_is_gone(self):
|
||
client = self.client("alice", broken=True)
|
||
client.push({"type": "state"})
|
||
self.assertFalse(client.alive, "循环已关闭就该老实标记自己死了")
|
||
|
||
def test_push_after_unregister_is_a_no_op(self):
|
||
client = self.client("alice")
|
||
self.hub.register(client)
|
||
self.hub.unregister(client)
|
||
client.push({"type": "state"})
|
||
self.assertEqual(client.queue.qsize(), 0)
|
||
|
||
def test_full_queue_drops_the_oldest_message(self):
|
||
"""实时状态宁可少一帧,也不能把连接拖死。"""
|
||
from farm.server import hub as hub_mod
|
||
|
||
client = self.client("alice")
|
||
for i in range(hub_mod.QUEUE_SIZE + 5):
|
||
client._enqueue({"type": "state", "seq": i})
|
||
self.assertEqual(client.queue.qsize(), hub_mod.QUEUE_SIZE)
|
||
self.assertEqual(client.queue.get_nowait()["seq"], 5, "最旧的应被丢掉")
|
||
self.assertGreater(client.stats()["dropped"], 0)
|
||
|
||
|
||
class SseProtocolTestCase(unittest.TestCase):
|
||
def test_frame_is_single_line_json(self):
|
||
frame = sse_frame({"type": "state", "text": "你好"})
|
||
self.assertTrue(frame.startswith("data: "))
|
||
self.assertTrue(frame.endswith("\n\n"))
|
||
payload = json.loads(frame[len("data: ") :].strip())
|
||
self.assertEqual(payload["text"], "你好")
|
||
self.assertNotIn("\\u", frame, "中文不该被转义成 \\uXXXX")
|
||
|
||
def test_newlines_inside_values_stay_escaped(self):
|
||
"""SSE 靠空行分帧,所以 JSON 里绝不能出现真换行。"""
|
||
frame = sse_frame({"text": "第一行\n第二行"})
|
||
body = frame[len("data: ") : -2]
|
||
self.assertEqual(len(body.splitlines()), 1)
|
||
|
||
def test_heartbeat_is_a_comment_line(self):
|
||
self.assertEqual(sse_heartbeat(), ": keep-alive\n\n")
|
||
|
||
def test_sequence_numbers_only_go_up(self):
|
||
values = [next_seq() for _ in range(50)]
|
||
self.assertEqual(values, sorted(values))
|
||
self.assertEqual(len(set(values)), len(values))
|
||
|
||
def test_state_message_is_json_serializable(self):
|
||
session = FarmSession("alice", tempfile.mkdtemp(prefix="simplefarm-msg-"), 2400.0)
|
||
message = build_state_message(session, next_seq(), extra={"online": 1})
|
||
json.dumps(message, ensure_ascii=False)
|
||
self.assertEqual(message["online"], 1)
|
||
self.assertEqual(message["type"], "state")
|
||
|
||
|
||
class PublisherTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-pub-")
|
||
self.manager = SessionManager(save_dir=self.dir, tick_seconds=2400.0)
|
||
self.hub = ConnectionHub()
|
||
self.publisher = Publisher(self.manager, self.hub, autosave_interval=3600.0)
|
||
self.session = self.manager.get("alice")
|
||
|
||
def attach(self) -> Client:
|
||
client = Client("alice", FakeLoop(), name="tui")
|
||
self.hub.register(client)
|
||
return client
|
||
|
||
def test_push_state_reaches_the_viewer(self):
|
||
client = self.attach()
|
||
self.publisher._push_state(self.session, time.time())
|
||
message = client.queue.get_nowait()
|
||
self.assertEqual(message["type"], "state")
|
||
self.assertEqual(message["online"], 1)
|
||
self.assertEqual(message["state"]["farm"], "alice")
|
||
self.assertEqual(self.publisher.pushes, 1)
|
||
|
||
def test_crossing_a_day_pushes_a_day_message_first(self):
|
||
client = self.attach()
|
||
self.session.engine.state.day = 2
|
||
self.publisher._push_state(self.session, time.time())
|
||
first = client.queue.get_nowait()
|
||
second = client.queue.get_nowait()
|
||
self.assertEqual(first["type"], "day")
|
||
self.assertEqual(first["day"], 2)
|
||
self.assertEqual(second["type"], "state")
|
||
|
||
def test_day_message_is_only_sent_once_per_day(self):
|
||
client = self.attach()
|
||
self.session.engine.state.day = 2
|
||
self.publisher._push_state(self.session, time.time())
|
||
while not client.queue.empty():
|
||
client.queue.get_nowait()
|
||
self.publisher._push_state(self.session, time.time())
|
||
self.assertEqual(client.queue.get_nowait()["type"], "state")
|
||
|
||
def test_errors_are_counted_instead_of_crashing(self):
|
||
"""推送线程绝不能因为单个牧场出错而退出。"""
|
||
|
||
class Exploding:
|
||
def all(self):
|
||
raise RuntimeError("牧场数据坏了")
|
||
|
||
publisher = Publisher(Exploding(), self.hub, autosave_interval=3600.0)
|
||
publisher.start()
|
||
try:
|
||
self.assertTrue(wait_until(lambda: publisher.errors > 0, timeout=3.0))
|
||
self.assertTrue(publisher.is_alive(), "出了错也要继续活着")
|
||
finally:
|
||
publisher.stop()
|
||
|
||
def test_uptime_is_a_number(self):
|
||
self.assertGreaterEqual(self.publisher.uptime(), 0.0)
|
||
|
||
|
||
class ServerHandleTestCase(unittest.TestCase):
|
||
def test_start_callbacks_all_run(self):
|
||
handle = ServerHandle()
|
||
seen: list[str] = []
|
||
handle.on_start(lambda: seen.append("a"))
|
||
handle.on_start(lambda: seen.append("b"))
|
||
handle.notify_start()
|
||
self.assertEqual(seen, ["a", "b"])
|
||
|
||
def test_request_stop_is_accepted_once(self):
|
||
handle = ServerHandle()
|
||
calls: list[str] = []
|
||
handle.on_stop(lambda: calls.append("stop"))
|
||
self.assertTrue(handle.request_stop())
|
||
self.assertEqual(calls, ["stop"])
|
||
self.assertFalse(handle.request_stop(), "重复请求要如实回答")
|
||
self.assertEqual(calls, ["stop"])
|
||
|
||
def test_a_failing_hook_does_not_block_the_others(self):
|
||
handle = ServerHandle()
|
||
calls: list[str] = []
|
||
|
||
def boom() -> None:
|
||
raise RuntimeError("钩子炸了")
|
||
|
||
handle.on_stop(boom)
|
||
handle.on_stop(lambda: calls.append("还是跑了"))
|
||
handle.on_start(boom)
|
||
handle.notify_start()
|
||
self.assertTrue(handle.request_stop())
|
||
self.assertEqual(calls, ["还是跑了"])
|
||
|
||
|
||
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
|
||
class BriefAndRulesTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-brief-")
|
||
self.manager = SessionManager(save_dir=self.dir, tick_seconds=2400.0)
|
||
self.hub = ConnectionHub()
|
||
self.session = self.manager.get("alice")
|
||
|
||
def test_brief_keys_exist_in_the_snapshot(self):
|
||
"""回归:snapshot 改字段名时,brief 必须跟着改,否则动作回执直接 500。"""
|
||
snap = self.session.snapshot()
|
||
for key in BRIEF_KEYS:
|
||
self.assertIn(key, snap, f"快照里没有 {key}")
|
||
|
||
def test_brief_exposes_the_to_do_lists(self):
|
||
self.session.engine.plant(1, "wheat")
|
||
self.session.engine.state.plots[0].growth = 2
|
||
self.session.engine.buy_animal("chicken")
|
||
data = brief(self.session, self.hub)
|
||
self.assertEqual(data["farm"], "alice")
|
||
self.assertEqual(data["ripe_plots"], [1])
|
||
self.assertEqual(len(data["hungry_animals"]), 1)
|
||
self.assertEqual(data["collectable_animals"], [])
|
||
self.assertEqual(data["online"], 0)
|
||
|
||
def test_brief_is_json_serializable(self):
|
||
json.dumps(brief(self.session, self.hub), ensure_ascii=False)
|
||
|
||
def test_rules_no_longer_mention_sleeping(self):
|
||
"""「休息」已经整体移除,给 AI 的说明书不能还留着它。"""
|
||
text = rules_text()
|
||
self.assertNotIn("sleep", text)
|
||
self.assertNotIn("休息", text)
|
||
self.assertNotIn("快进", text.replace("没有快进", ""))
|
||
self.assertIn("40 分钟", text)
|
||
|
||
def test_rules_point_at_the_recommended_loop(self):
|
||
text = rules_text()
|
||
self.assertIn("/api/actions", text)
|
||
self.assertIn("/api/stream", text)
|
||
|
||
|
||
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
|
||
class AppWiringTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.cfg = ServerConfig(
|
||
save_dir=tempfile.mkdtemp(prefix="simplefarm-app-"), tick_seconds=2400.0
|
||
)
|
||
self.app = create_app(self.cfg)
|
||
|
||
def test_app_state_is_fully_wired(self):
|
||
for name in ("manager", "hub", "publisher", "clock", "handle", "counters"):
|
||
self.assertTrue(hasattr(self.app.state, name), name)
|
||
|
||
def test_as_asgi_is_the_identity(self):
|
||
self.assertIs(as_asgi(self.app), self.app)
|
||
|
||
def test_default_config_matches_the_game_defaults(self):
|
||
cfg = ServerConfig()
|
||
self.assertEqual(cfg.host, "127.0.0.1")
|
||
self.assertEqual(cfg.tick_seconds, config.DEFAULT_TICK_SECONDS)
|
||
self.assertTrue(cfg.allow_remote_control)
|
||
|
||
def test_build_app_is_a_convenience_wrapper(self):
|
||
from farm.server.app import build_app
|
||
|
||
app = build_app(port=9999)
|
||
self.assertEqual(app.state.cfg.port, 9999)
|
||
self.assertEqual(app.state.cfg.host, "127.0.0.1")
|
||
|
||
def test_int_query_helper(self):
|
||
self.assertEqual(_int(None, 20, 1, 200), 20)
|
||
self.assertEqual(_int("", 20, 1, 200), 20)
|
||
self.assertEqual(_int("5", 20, 1, 200), 5)
|
||
self.assertEqual(_int("999", 20, 1, 200), 200)
|
||
self.assertEqual(_int("-5", 20, 1, 200), 1)
|
||
with self.assertRaises(GameError) as ctx:
|
||
_int("abc", 20, 1, 200)
|
||
self.assertEqual(ctx.exception.code, "bad_param")
|
||
|
||
|
||
class DaemonTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-daemon-")
|
||
self.pid_file = os.path.join(self.dir, "server.pid")
|
||
self.log_file = os.path.join(self.dir, "server.log")
|
||
|
||
def manager(self, port: int) -> DaemonManager:
|
||
return DaemonManager(
|
||
host="127.0.0.1",
|
||
port=port,
|
||
save_dir=self.dir,
|
||
pid_file=self.pid_file,
|
||
log_file=self.log_file,
|
||
)
|
||
|
||
def test_own_process_is_alive_and_nonsense_pids_are_not(self):
|
||
self.assertTrue(pid_alive(os.getpid()))
|
||
self.assertFalse(pid_alive(0))
|
||
self.assertFalse(pid_alive(-1))
|
||
|
||
def test_port_busy_detects_a_listening_socket(self):
|
||
with socket.socket() as listener:
|
||
listener.bind(("127.0.0.1", 0))
|
||
listener.listen(16) # 至少要能容纳本用例的两连接
|
||
port = int(listener.getsockname()[1])
|
||
self.assertTrue(port_busy("127.0.0.1", port))
|
||
self.assertTrue(self.manager(port).port_busy())
|
||
self.assertFalse(port_busy("127.0.0.1", port), "监听关掉之后就该是空闲的")
|
||
|
||
def test_pid_file_round_trip(self):
|
||
manager = self.manager(free_port())
|
||
self.assertIsNone(manager.read_pid())
|
||
manager.write_pid(4321)
|
||
self.assertEqual(manager.read_pid(), 4321)
|
||
manager.clear_pid()
|
||
self.assertIsNone(manager.read_pid())
|
||
|
||
def test_stale_pid_file_is_cleaned_up(self):
|
||
manager = self.manager(free_port())
|
||
manager.write_pid(999_999_999) # 必定不存在的 pid
|
||
self.assertIsNone(manager.current())
|
||
self.assertIsNone(manager.read_pid(), "陈旧记录应当被顺手清掉")
|
||
|
||
def test_status_reports_not_running_for_a_dead_port(self):
|
||
info = self.manager(free_port()).status()
|
||
self.assertFalse(info["running"])
|
||
self.assertIsNone(info["pid"])
|
||
self.assertIsNone(info["health"])
|
||
|
||
def test_probe_and_world_return_none_without_a_server(self):
|
||
manager = self.manager(free_port())
|
||
self.assertIsNone(manager.probe())
|
||
self.assertIsNone(manager.world())
|
||
|
||
def test_command_passes_the_pid_file_through(self):
|
||
"""pid 必须由服务进程自己写:父进程拿到的 pid 在 Windows 上未必是监听者。"""
|
||
args = self.manager(8765).command()
|
||
self.assertIn("--pid-file", args)
|
||
self.assertEqual(args[args.index("--pid-file") + 1], self.pid_file)
|
||
|
||
def test_stop_on_idle_port_is_not_an_error(self):
|
||
ok, message = self.manager(free_port()).stop()
|
||
self.assertTrue(ok)
|
||
self.assertIn("没有运行", message)
|
||
|
||
def test_tail_returns_empty_for_a_missing_log(self):
|
||
self.assertEqual(self.manager(free_port()).tail(), [])
|
||
|
||
def test_tail_reads_the_last_lines(self):
|
||
with open(self.log_file, "w", encoding="utf-8") as handle:
|
||
handle.write("\n".join(f"第{i}行" for i in range(50)))
|
||
lines = self.manager(free_port()).tail(5)
|
||
self.assertEqual(len(lines), 5)
|
||
self.assertEqual(lines[-1], "第49行")
|
||
|
||
|
||
@unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)")
|
||
class RestApiTestCase(unittest.TestCase):
|
||
"""进程内打接口:语义、鉴权、多牧场隔离。"""
|
||
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-rest-")
|
||
self.app = create_app(ServerConfig(save_dir=self.dir, tick_seconds=2400.0))
|
||
self.client = TestClient(self.app)
|
||
|
||
def test_health_needs_no_token(self):
|
||
response = self.client.get("/api/health")
|
||
self.assertEqual(response.status_code, 200)
|
||
payload = response.json()
|
||
self.assertTrue(payload["ok"])
|
||
self.assertEqual(payload["service"], "SimpleFarm")
|
||
self.assertIn("clock_ticks", payload)
|
||
|
||
def test_state_and_catalog(self):
|
||
payload = self.client.get("/api/state?farm=alice").json()
|
||
self.assertEqual(payload["farm"], "alice")
|
||
self.assertEqual(payload["state"]["day"], 1)
|
||
self.assertEqual(payload["online"], 0)
|
||
|
||
catalog = self.client.get("/api/catalog?farm=alice").json()["catalog"]
|
||
self.assertEqual(len(catalog["crops"]), len(config.CROPS))
|
||
self.assertIn("ap_cost", catalog)
|
||
|
||
def test_action_then_read_it_back(self):
|
||
response = self.client.post(
|
||
"/api/actions?farm=alice",
|
||
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
payload = response.json()
|
||
self.assertTrue(payload["ok"])
|
||
self.assertEqual(payload["action"], "plant")
|
||
self.assertEqual(payload["state"]["plot_count"], config.BASE_PLOTS)
|
||
self.assertIn("seq", payload)
|
||
|
||
snap = self.client.get("/api/state?farm=alice").json()["state"]
|
||
self.assertEqual(snap["plots"][0]["crop"], "wheat")
|
||
|
||
def test_flat_parameters_are_accepted(self):
|
||
"""AI 常把参数平铺在顶层,接口对此要宽容。"""
|
||
payload = self.client.post(
|
||
"/api/actions?farm=alice", json={"action": "plant", "plot": 2, "crop": "wheat"}
|
||
).json()
|
||
self.assertTrue(payload["ok"])
|
||
|
||
def test_full_flag_returns_the_whole_snapshot(self):
|
||
payload = self.client.post(
|
||
"/api/actions?farm=alice&full=1", json={"action": "buy_feed", "params": {}}
|
||
).json()
|
||
self.assertIn("snapshot", payload)
|
||
self.assertIn("plots", payload["snapshot"])
|
||
|
||
def test_errors_are_machine_readable(self):
|
||
response = self.client.post("/api/actions?farm=alice", json={"action": "fly"})
|
||
self.assertEqual(response.status_code, 400)
|
||
payload = response.json()
|
||
self.assertFalse(payload["ok"])
|
||
self.assertEqual(payload["error"], "unknown_action")
|
||
|
||
def test_missing_parameter_is_a_400(self):
|
||
response = self.client.post(
|
||
"/api/actions?farm=alice", json={"action": "plant", "params": {"plot": 1}}
|
||
)
|
||
self.assertEqual(response.status_code, 400)
|
||
self.assertEqual(response.json()["error"], "missing_param")
|
||
|
||
def test_broken_json_body(self):
|
||
response = self.client.post(
|
||
"/api/actions?farm=alice",
|
||
content="{ not json",
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
self.assertEqual(response.status_code, 400)
|
||
self.assertEqual(response.json()["error"], "bad_json")
|
||
|
||
def test_non_object_body(self):
|
||
response = self.client.post(
|
||
"/api/actions?farm=alice",
|
||
content="[1,2,3]",
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_farm_header_wins_over_the_default(self):
|
||
payload = self.client.get("/api/state", headers={"X-Farm-Id": "from-header"}).json()
|
||
self.assertEqual(payload["farm"], "from-header")
|
||
|
||
def test_bad_farm_id_is_refused(self):
|
||
response = self.client.get("/api/state?farm=../etc")
|
||
self.assertEqual(response.status_code, 400)
|
||
self.assertEqual(response.json()["error"], "bad_farm")
|
||
|
||
def test_batch_runs_every_action_and_reports_failures(self):
|
||
payload = self.client.post(
|
||
"/api/batch?farm=alice",
|
||
json={
|
||
"actions": [
|
||
{"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
|
||
{"action": "plant", "params": {"plot": 1, "crop": "wheat"}}, # 这块地已占用
|
||
{"action": "buy_feed", "params": {"quantity": 5}},
|
||
]
|
||
},
|
||
).json()
|
||
self.assertTrue(payload["ok"])
|
||
self.assertEqual(payload["done"], 2)
|
||
self.assertEqual(payload["failed"], 1)
|
||
self.assertEqual(payload["results"][1]["error"], "plot_busy")
|
||
|
||
def test_batch_validates_its_container(self):
|
||
response = self.client.post("/api/batch?farm=alice", json={"actions": []})
|
||
self.assertEqual(response.status_code, 400)
|
||
response = self.client.post("/api/batch?farm=alice", json={"actions": "nope"})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_batch_has_an_upper_bound(self):
|
||
items = [{"action": "buy_feed", "params": {"quantity": 1}} for _ in range(51)]
|
||
response = self.client.post("/api/batch?farm=alice", json={"actions": items})
|
||
self.assertEqual(response.status_code, 400)
|
||
self.assertEqual(response.json()["error"], "too_many")
|
||
|
||
def test_generated_per_action_routes_work(self):
|
||
payload = self.client.post("/api/buy_feed?farm=alice", json={"quantity": 3}).json()
|
||
self.assertTrue(payload["ok"])
|
||
self.assertEqual(payload["action"], "buy_feed")
|
||
|
||
def test_log_supports_limits_and_incremental_reads(self):
|
||
self.client.post("/api/actions?farm=alice", json={"action": "buy_feed", "params": {}})
|
||
first = self.client.get("/api/log?farm=alice&limit=2").json()
|
||
self.assertLessEqual(first["count"], 2)
|
||
mark = first["last_seq"]
|
||
|
||
self.client.post("/api/actions?farm=alice", json={"action": "buy_feed", "params": {}})
|
||
newer = self.client.get(f"/api/log?farm=alice&since={mark}").json()
|
||
self.assertTrue(all(entry["seq"] > mark for entry in newer["entries"]))
|
||
|
||
def test_log_rejects_a_non_numeric_limit(self):
|
||
response = self.client.get("/api/log?farm=alice&limit=abc")
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_farms_list_and_create(self):
|
||
created = self.client.post("/api/farms", json={"farm": "alice", "owner": "爱丽丝"}).json()
|
||
self.assertTrue(created["created"])
|
||
self.assertEqual(created["farm"], "alice")
|
||
self.assertEqual(created["state"]["farm"], "alice")
|
||
|
||
again = self.client.post("/api/farms", json={"farm": "alice"}).json()
|
||
self.assertFalse(again["created"])
|
||
|
||
rows = self.client.get("/api/farms").json()["farms"]
|
||
self.assertEqual(rows[0]["farm"], "alice")
|
||
|
||
def test_create_farm_can_tune_the_clock(self):
|
||
self.client.post("/api/farms", json={"farm": "fast", "tick": 5})
|
||
snap = self.client.get("/api/state?farm=fast").json()["state"]
|
||
self.assertEqual(snap["tick_seconds"], 5.0)
|
||
|
||
def test_create_farm_rejects_a_bad_tick(self):
|
||
response = self.client.post("/api/farms", json={"farm": "fast", "tick": "soon"})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_world_view(self):
|
||
self.client.post("/api/farms", json={"farm": "alice"})
|
||
payload = self.client.get("/api/world").json()
|
||
self.assertEqual(payload["farms"], 1)
|
||
self.assertIn("leaderboard", payload)
|
||
self.assertIn("connections", payload)
|
||
|
||
def test_help_describes_the_actions(self):
|
||
payload = self.client.get("/api/help").json()
|
||
names = {item["action"] for item in payload["actions"]}
|
||
self.assertIn("plant", names)
|
||
self.assertNotIn("sleep", names)
|
||
|
||
def test_farms_do_not_leak_into_each_other(self):
|
||
self.client.post(
|
||
"/api/actions?farm=alice",
|
||
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
|
||
)
|
||
bob = self.client.get("/api/state?farm=bob").json()["state"]
|
||
self.assertIsNone(bob["plots"][0]["crop"])
|
||
|
||
def test_save_goes_through_the_session_not_the_default_path(self):
|
||
"""存档位置由会话决定;直落 engine.save() 会写错地方、覆盖别人的档。"""
|
||
self.client.post("/api/actions?farm=alice", json={"action": "save", "params": {}})
|
||
self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json")))
|
||
self.assertFalse(os.path.exists(os.path.join(self.dir, "save.json")))
|
||
|
||
def test_reset_only_touches_its_own_farm(self):
|
||
self.client.post(
|
||
"/api/actions?farm=alice",
|
||
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
|
||
)
|
||
self.client.post(
|
||
"/api/actions?farm=bob",
|
||
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
|
||
)
|
||
self.client.post("/api/actions?farm=alice", json={"action": "reset", "params": {}})
|
||
|
||
alice = self.client.get("/api/state?farm=alice").json()["state"]
|
||
bob = self.client.get("/api/state?farm=bob").json()["state"]
|
||
self.assertIsNone(alice["plots"][0]["crop"])
|
||
self.assertEqual(bob["plots"][0]["crop"], "wheat")
|
||
|
||
def test_admin_save_writes_every_farm(self):
|
||
self.client.post("/api/farms", json={"farm": "alice"})
|
||
self.client.post("/api/farms", json={"farm": "bob"})
|
||
payload = self.client.post("/api/admin/save").json()
|
||
self.assertEqual(sorted(payload["farms"]), ["alice", "bob"])
|
||
|
||
def test_sleep_endpoint_is_gone(self):
|
||
"""整条"手动推进一天"的链路都不该存在。"""
|
||
response = self.client.post("/api/actions?farm=alice", json={"action": "sleep"})
|
||
self.assertEqual(response.status_code, 400)
|
||
self.assertEqual(response.json()["error"], "unknown_action")
|
||
|
||
def test_dashboard_pages_render(self):
|
||
self.assertEqual(self.client.get("/").status_code, 200)
|
||
text = self.client.get("/text").text
|
||
self.assertIn("SimpleFarm", text)
|
||
|
||
|
||
@unittest.skipUnless(HAS_TESTCLIENT, "未安装 httpx(TestClient 依赖它)")
|
||
class AuthTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.mkdtemp(prefix="simplefarm-auth-")
|
||
self.app = create_app(ServerConfig(save_dir=self.dir, token="s3cret", tick_seconds=2400.0))
|
||
self.client = TestClient(self.app)
|
||
|
||
def test_health_stays_open(self):
|
||
self.assertEqual(self.client.get("/api/health").status_code, 200)
|
||
|
||
def test_state_needs_a_token(self):
|
||
response = self.client.get("/api/state")
|
||
self.assertEqual(response.status_code, 401)
|
||
self.assertEqual(response.json()["error"], "unauthorized")
|
||
|
||
def test_wrong_token_is_refused(self):
|
||
response = self.client.get("/api/state?token=nope")
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
def test_query_token_works(self):
|
||
self.assertEqual(self.client.get("/api/state?token=s3cret").status_code, 200)
|
||
|
||
def test_header_token_works(self):
|
||
response = self.client.get("/api/state", headers={"X-API-Token": "s3cret"})
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_writes_are_guarded_too(self):
|
||
response = self.client.post("/api/actions?farm=alice", json={"action": "buy_feed"})
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
def test_remote_shutdown_can_be_switched_off(self):
|
||
app = create_app(
|
||
ServerConfig(
|
||
save_dir=self.dir, token="s3cret", allow_remote_control=False, tick_seconds=2400.0
|
||
)
|
||
)
|
||
client = TestClient(app)
|
||
response = client.post("/api/admin/shutdown?token=s3cret")
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
|
||
@unittest.skipUnless(HAS_UVICORN and HAS_FASTAPI, "未安装 uvicorn / FastAPI")
|
||
class LiveServerTestCase(unittest.TestCase):
|
||
"""真起一个服务器进程内的 uvicorn,用真 socket 说话。"""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
cls.dir = tempfile.mkdtemp(prefix="simplefarm-live-")
|
||
cls.port = free_port()
|
||
cls.cfg = ServerConfig(
|
||
host="127.0.0.1", port=cls.port, tick_seconds=config.MIN_TICK_SECONDS, save_dir=cls.dir
|
||
)
|
||
cls.app = create_app(cls.cfg)
|
||
config_obj = uvicorn.Config(
|
||
as_asgi(cls.app),
|
||
host="127.0.0.1",
|
||
port=cls.port,
|
||
log_level="warning",
|
||
http="zttp",
|
||
http2=True,
|
||
timeout_graceful_shutdown=3,
|
||
)
|
||
cls.server = uvicorn.Server(config_obj)
|
||
# __main__ 里做的那件事:停机请求要能把 should_exit 置位
|
||
cls.app.state.handle.on_stop(lambda: setattr(cls.server, "should_exit", True))
|
||
cls.thread = threading.Thread(target=cls.server.run, name="test-live-server", daemon=True)
|
||
cls.thread.start()
|
||
if not wait_until(cls.healthy, timeout=20.0):
|
||
raise unittest.SkipTest("服务器没能在 20 秒内起来")
|
||
|
||
@classmethod
|
||
def healthy(cls) -> bool:
|
||
try:
|
||
with net.open_url(f"http://127.0.0.1:{cls.port}/api/health", timeout=1.0) as response:
|
||
json.loads(response.read().decode("utf-8"))
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
cls.app.state.handle.request_stop()
|
||
cls.thread.join(timeout=8.0)
|
||
|
||
def endpoint(self) -> str:
|
||
return f"http://127.0.0.1:{self.port}"
|
||
|
||
def connect(self, farm: str, realtime: bool = False):
|
||
from farm.client import RemoteFarm
|
||
|
||
return RemoteFarm(endpoint=self.endpoint(), farm=farm, realtime=realtime, timeout=5.0)
|
||
|
||
def read_stream(self, farm: str, frames: int = 1, timeout: float = 8.0) -> list[dict]:
|
||
"""裸读 SSE 流,取到指定帧数就断开——顺便验证协议本身。"""
|
||
url = f"{self.endpoint()}/api/stream?farm={farm}"
|
||
request = urllib.request.Request(url, headers={"Accept": "text/event-stream"})
|
||
collected: list[dict] = []
|
||
with net.open_url(request, timeout=timeout) as response:
|
||
content_type = response.headers.get("content-type") or ""
|
||
self.assertTrue(content_type.startswith("text/event-stream"), content_type)
|
||
for raw in response:
|
||
line = raw.decode("utf-8", errors="replace").strip()
|
||
if not line.startswith("data:"):
|
||
continue
|
||
collected.append(json.loads(line[len("data:") :].strip()))
|
||
if len(collected) >= frames:
|
||
break
|
||
return collected
|
||
|
||
def test_http_round_trip(self):
|
||
client = self.connect("live-a")
|
||
self.assertTrue(client.connected, client.last_error)
|
||
before = client.state.gold
|
||
client.plant(1, "wheat")
|
||
self.assertEqual(client.state.plots[0].crop, "wheat")
|
||
self.assertLess(client.state.gold, before)
|
||
|
||
def test_two_clients_share_one_farm(self):
|
||
first = self.connect("live-shared")
|
||
second = self.connect("live-shared")
|
||
first.buy_feed(4)
|
||
self.assertTrue(wait_until(lambda: second.refresh() and second.state.feed >= 9))
|
||
self.assertEqual(second.state.feed, first.state.feed)
|
||
|
||
def test_other_farms_stay_separate(self):
|
||
alice = self.connect("live-alice")
|
||
bob = self.connect("live-bob")
|
||
alice.plant(1, "wheat")
|
||
bob.refresh()
|
||
self.assertIsNone(bob.state.plots[0].crop)
|
||
|
||
def test_sse_stream_opens_with_a_welcome_frame(self):
|
||
frames = self.read_stream("live-sse", frames=1)
|
||
welcome = frames[0]
|
||
self.assertEqual(welcome["type"], "welcome")
|
||
self.assertEqual(welcome["farm"], "live-sse")
|
||
self.assertIn("catalog", welcome)
|
||
self.assertIn("actions", welcome)
|
||
self.assertEqual(welcome["server"]["version"], self.app.version)
|
||
|
||
def test_sse_pushes_what_another_client_did(self):
|
||
listener = self.connect("live-push", realtime=True)
|
||
try:
|
||
self.assertTrue(
|
||
wait_until(lambda: listener.mode == "sse", timeout=8.0), listener.last_error
|
||
)
|
||
actor = self.connect("live-push")
|
||
actor.buy_feed(6)
|
||
self.assertTrue(
|
||
wait_until(lambda: listener.state.feed >= 9, timeout=8.0),
|
||
"另一个客户端的操作应当被推过来",
|
||
)
|
||
finally:
|
||
listener.stop()
|
||
|
||
def test_writes_keep_working_after_the_stream_is_cut(self):
|
||
client = self.connect("live-cut", realtime=True)
|
||
self.assertTrue(wait_until(lambda: client.mode == "sse", timeout=8.0))
|
||
client.stop()
|
||
client.buy_feed(2)
|
||
self.assertGreaterEqual(client.state.feed, 7)
|
||
|
||
def test_bot_can_run_purely_over_the_wire(self):
|
||
from farm.bot import RuleBot
|
||
|
||
client = self.connect("live-bot")
|
||
bot = RuleBot(client, verbose=False, pause=0.0, name="tester")
|
||
summary = bot.run(rounds=6, idle_limit=1)
|
||
self.assertGreaterEqual(summary["actions"], 1)
|
||
self.assertEqual(summary["name"], "tester")
|
||
|
||
def test_health_reports_a_running_world(self):
|
||
with net.open_url(f"{self.endpoint()}/api/health", timeout=3.0) as response:
|
||
payload = json.loads(response.read().decode("utf-8"))
|
||
self.assertTrue(payload["ok"])
|
||
self.assertGreaterEqual(payload["farms"], 1)
|
||
|
||
def test_second_instance_on_the_same_port_refuses_to_start(self):
|
||
"""端口自检必须用"能不能连上"判断,否则会静默顶掉已有实例。"""
|
||
result = subprocess.run(
|
||
[sys.executable, "-m", "farm.server", "--port", str(self.port)],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
)
|
||
self.assertEqual(result.returncode, 3, result.stdout + result.stderr)
|
||
self.assertIn("已经有程序在监听", result.stdout + result.stderr)
|
||
|
||
def test_graceful_shutdown_writes_every_farm(self):
|
||
"""停机路径必须落盘。这里只验证"再启一个实例仍能读到存档"的前置条件。"""
|
||
client = self.connect("live-save")
|
||
client.plant(1, "wheat")
|
||
client.save()
|
||
self.assertTrue(os.path.exists(os.path.join(self.dir, "live-save.json")))
|
||
|
||
|
||
class SessionIsolationAcrossProcessesTestCase(unittest.TestCase):
|
||
"""守护进程的 pid 文件语义——不启动真服务,只验证文件层的约定。"""
|
||
|
||
def test_clear_pid_only_removes_our_own_record(self):
|
||
from farm.server.__main__ import clear_pid_file
|
||
|
||
directory = tempfile.mkdtemp(prefix="simplefarm-pid-")
|
||
path = os.path.join(directory, "server.pid")
|
||
|
||
with open(path, "w", encoding="utf-8") as handle:
|
||
handle.write("424242")
|
||
clear_pid_file(path)
|
||
self.assertTrue(os.path.exists(path), "不是自己写的 pid 文件不能删")
|
||
|
||
with open(path, "w", encoding="utf-8") as handle:
|
||
handle.write(str(os.getpid()))
|
||
clear_pid_file(path)
|
||
self.assertFalse(os.path.exists(path))
|
||
|
||
def test_clear_pid_tolerates_missing_files(self):
|
||
from farm.server.__main__ import clear_pid_file
|
||
|
||
clear_pid_file(None)
|
||
clear_pid_file(os.path.join(tempfile.mkdtemp(), "nothing-here.pid"))
|
||
|
||
def test_tick_is_clamped_by_the_entry_point(self):
|
||
from farm.server.__main__ import clamp_tick
|
||
|
||
self.assertEqual(clamp_tick(0), config.MIN_TICK_SECONDS)
|
||
self.assertEqual(clamp_tick(10**9), config.MAX_TICK_SECONDS)
|
||
self.assertEqual(clamp_tick(900), 900.0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|