动手前先修掉了两处「死机制」,新增的三个系统各自激活一个: * 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。 现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而 `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。 5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。 * 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与 收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。 仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。 * 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、 喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。 每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。 存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段 一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的 确定性入口。 顺带修两个真 bug: * `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt` 这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出 `ensure_parent_dir()` 兜底。 * `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据, 看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。 质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、 bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。 另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
1296 lines
51 KiB
Python
1296 lines
51 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, ensure_parent_dir, 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_brief_payload(self):
|
||
"""回归:brief_source 改字段名时,BRIEF_KEYS 必须跟着改,否则动作回执直接 500。
|
||
|
||
BRIEF_KEYS 的数据源是 ``FarmEngine.brief_source()``(精简状态),
|
||
**不是**完整快照 ``session.snapshot()``——后者包含 market/plots 等大块数据,
|
||
拿它来断言会把"brief 只读精简字段"这个设计意图测丢。
|
||
"""
|
||
data = brief(self.session, self.hub)
|
||
for key in BRIEF_KEYS:
|
||
self.assertIn(key, data, 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行")
|
||
|
||
def test_ensure_parent_dir_tolerates_a_bare_file_name(self):
|
||
"""回归:路径没有目录部分时 dirname 返回空串,makedirs("") 直接崩。"""
|
||
ensure_parent_dir("server.pid") # 不该抛
|
||
ensure_parent_dir(os.path.join(self.dir, "a", "b", "server.pid"))
|
||
self.assertTrue(os.path.isdir(os.path.join(self.dir, "a", "b")))
|
||
|
||
def test_pid_file_without_a_directory_still_works(self):
|
||
"""``--pid-file server.pid`` 这种只给文件名的用法必须能起来。"""
|
||
manager = DaemonManager(
|
||
host="127.0.0.1",
|
||
port=free_port(),
|
||
save_dir=self.dir,
|
||
pid_file="server.pid",
|
||
log_file="server.log",
|
||
)
|
||
cwd = os.getcwd()
|
||
os.chdir(self.dir)
|
||
try:
|
||
manager.write_pid(4321)
|
||
self.assertEqual(manager.read_pid(), 4321)
|
||
manager.clear_pid()
|
||
self.assertIsNone(manager.read_pid())
|
||
finally:
|
||
os.chdir(cwd)
|
||
|
||
|
||
@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)
|