chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前, 先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。 此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
This commit is contained in:
commit
a7725f8812
35 files changed
+11871
No files matched your search
@@ -0,0 +1,458 @@
|
||||
"""核心引擎的单元测试。
|
||||
|
||||
运行方式:
|
||||
python -m unittest discover -s tests -v
|
||||
或:
|
||||
python tests/test_engine.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from farm import config # noqa: E402
|
||||
from farm.engine import FarmEngine, GameError # noqa: E402
|
||||
from farm.models import GameState, Plot # noqa: E402
|
||||
from farm.storage import load_state, save_state # noqa: E402
|
||||
|
||||
|
||||
class EngineTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
random.seed(20260918)
|
||||
self.engine = FarmEngine(tick_seconds=5)
|
||||
# 存档相关测试会写文件,统一放到临时目录
|
||||
self.tmp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp")
|
||||
os.makedirs(self.tmp, exist_ok=True)
|
||||
|
||||
# ============================================================ 初始状态
|
||||
def test_initial_state(self) -> None:
|
||||
state = self.engine.state
|
||||
self.assertEqual(state.day, 1)
|
||||
self.assertEqual(state.gold, config.START_GOLD)
|
||||
self.assertEqual(state.level, 1)
|
||||
self.assertEqual(state.ap, config.START_AP)
|
||||
self.assertEqual(len(state.plots), config.BASE_PLOTS)
|
||||
self.assertEqual(state.animals, [])
|
||||
self.assertEqual(state.exp_needed, config.exp_to_next(1))
|
||||
|
||||
# ============================================================ 种植
|
||||
def test_plant_costs_gold_and_ap(self) -> None:
|
||||
before_gold = self.engine.state.gold
|
||||
before_ap = self.engine.state.ap
|
||||
self.engine.plant(1, "wheat")
|
||||
plot = self.engine.state.plots[0]
|
||||
self.assertEqual(plot.crop, "wheat")
|
||||
self.assertEqual(self.engine.state.gold, before_gold - config.CROPS["wheat"]["seed_cost"])
|
||||
self.assertEqual(self.engine.state.ap, before_ap - config.AP_COST["plant"])
|
||||
self.assertEqual(plot.moisture, config.PLANT_INITIAL_MOISTURE)
|
||||
|
||||
def test_plant_twice_on_same_plot_fails(self) -> None:
|
||||
self.engine.plant(1, "wheat")
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.plant(1, "carrot")
|
||||
self.assertEqual(ctx.exception.code, "plot_busy")
|
||||
|
||||
def test_plant_out_of_range_plot(self) -> None:
|
||||
for bad in (0, -1, 99, "abc", None):
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.plant(bad, "wheat")
|
||||
self.assertIn(ctx.exception.code, ("no_such_plot", "bad_param"))
|
||||
|
||||
def test_plant_unknown_crop(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.plant(1, "banana")
|
||||
self.assertEqual(ctx.exception.code, "unknown_crop")
|
||||
with self.assertRaises(GameError) as ctx2:
|
||||
self.engine.plant(1, 123)
|
||||
self.assertEqual(ctx2.exception.code, "unknown_crop")
|
||||
|
||||
def test_plant_locked_crop(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.plant(1, "pumpkin")
|
||||
self.assertEqual(ctx.exception.code, "locked")
|
||||
|
||||
def test_plant_without_gold(self) -> None:
|
||||
self.engine.state.gold = 1
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.plant(1, "wheat")
|
||||
self.assertEqual(ctx.exception.code, "not_enough_gold")
|
||||
|
||||
def test_plant_without_ap(self) -> None:
|
||||
self.engine.state.ap = 1
|
||||
self.engine.plant(1, "wheat")
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.plant(2, "wheat")
|
||||
self.assertEqual(ctx.exception.code, "no_ap")
|
||||
|
||||
# ============================================================ 生长与水分
|
||||
def test_crop_needs_water_to_grow(self) -> None:
|
||||
self.engine.plant(1, "wheat")
|
||||
plot = self.engine.state.plots[0]
|
||||
# 播种自带 2 点水分,推进 2 天刚好成熟
|
||||
self.engine.advance_day()
|
||||
self.engine.advance_day()
|
||||
self.assertTrue(plot.is_ripe)
|
||||
|
||||
def test_crop_stops_growing_without_water(self) -> None:
|
||||
self.engine.state.level = 5
|
||||
self.engine.plant(1, "corn") # 需要 6 天
|
||||
plot = self.engine.state.plots[0]
|
||||
for _ in range(5):
|
||||
self.engine.advance_day()
|
||||
# 只有 2 点初始水分,所以最多长 2 天
|
||||
self.assertEqual(plot.growth, 2)
|
||||
self.assertEqual(plot.moisture, 0)
|
||||
self.assertFalse(plot.is_ripe)
|
||||
|
||||
def test_water_restores_moisture(self) -> None:
|
||||
self.engine.state.level = 5
|
||||
self.engine.plant(1, "corn")
|
||||
plot = self.engine.state.plots[0]
|
||||
self.engine.state.ap = 10
|
||||
self.engine.water(1)
|
||||
self.assertEqual(plot.moisture, config.WATER_CAPACITY)
|
||||
|
||||
def test_water_empty_plot_fails(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.water(1)
|
||||
self.assertEqual(ctx.exception.code, "plot_empty")
|
||||
|
||||
def test_water_when_full_fails(self) -> None:
|
||||
self.engine.plant(1, "wheat")
|
||||
self.engine.water(1)
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.water(1)
|
||||
self.assertEqual(ctx.exception.code, "already_watered")
|
||||
|
||||
# ============================================================ 收获
|
||||
def test_harvest_not_ripe(self) -> None:
|
||||
self.engine.plant(1, "wheat")
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.harvest(1)
|
||||
self.assertEqual(ctx.exception.code, "not_ripe")
|
||||
|
||||
def test_harvest_empty_plot(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.harvest(1)
|
||||
self.assertEqual(ctx.exception.code, "plot_empty")
|
||||
|
||||
def test_harvest_gives_gold_and_exp(self) -> None:
|
||||
self.engine.plant(1, "wheat")
|
||||
self.engine.advance_day()
|
||||
self.engine.advance_day()
|
||||
gold = self.engine.state.gold
|
||||
result = self.engine.harvest(1)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertGreater(result["earned"], 0)
|
||||
self.assertEqual(self.engine.state.gold, gold + result["earned"])
|
||||
self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"])
|
||||
self.assertIsNone(self.engine.state.plots[0].crop)
|
||||
|
||||
def test_exp_accumulates(self) -> None:
|
||||
self.engine.plant(1, "wheat")
|
||||
self.engine.advance_day()
|
||||
self.engine.advance_day()
|
||||
self.engine.harvest(1)
|
||||
self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"])
|
||||
|
||||
# ============================================================ 等级与土地
|
||||
def test_level_up_unlocks_and_rewards(self) -> None:
|
||||
state = self.engine.state
|
||||
state.exp = state.exp_needed - 1
|
||||
# 种一茬小麦拿经验,触发升级
|
||||
self.engine.plant(1, "wheat")
|
||||
self.engine.advance_day()
|
||||
self.engine.advance_day()
|
||||
gold_before = state.gold
|
||||
self.engine.harvest(1)
|
||||
self.assertEqual(state.level, 2)
|
||||
self.assertGreaterEqual(state.gold, gold_before + config.level_up_bonus(2))
|
||||
self.assertEqual(state.max_plots, config.BASE_PLOTS + config.PLOTS_PER_LEVEL)
|
||||
|
||||
def test_buy_plot_and_limit(self) -> None:
|
||||
self.engine.state.gold = 99999
|
||||
self.engine.state.level = 10
|
||||
before = len(self.engine.state.plots)
|
||||
self.engine.buy_plot()
|
||||
self.assertEqual(len(self.engine.state.plots), before + 1)
|
||||
|
||||
while len(self.engine.state.plots) < config.MAX_PLOTS:
|
||||
self.engine.buy_plot()
|
||||
self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS)
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.buy_plot()
|
||||
self.assertEqual(ctx.exception.code, "plot_limit")
|
||||
|
||||
def test_buy_plot_without_gold(self) -> None:
|
||||
self.engine.state.level = 5 # 先解锁土地上限,才能走到"钱不够"这一步
|
||||
self.engine.state.gold = 0
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.buy_plot()
|
||||
self.assertEqual(ctx.exception.code, "not_enough_gold")
|
||||
|
||||
def test_buy_plot_blocked_at_level_cap(self) -> None:
|
||||
self.engine.state.gold = 10 ** 6
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.buy_plot()
|
||||
self.assertEqual(ctx.exception.code, "plot_limit")
|
||||
|
||||
# ============================================================ 牲畜
|
||||
def test_buy_animal_and_produce(self) -> None:
|
||||
self.engine.state.gold = 1000
|
||||
result = self.engine.buy_animal("chicken")
|
||||
self.assertEqual(result["animal"]["kind"], "chicken")
|
||||
self.assertEqual(len(self.engine.state.animals), 1)
|
||||
|
||||
animal = self.engine.state.animals[0]
|
||||
self.engine.feed(animal.aid)
|
||||
self.assertTrue(animal.fed)
|
||||
self.engine.advance_day()
|
||||
self.assertEqual(animal.pending, 1)
|
||||
|
||||
gold = self.engine.state.gold
|
||||
result = self.engine.collect(animal.aid)
|
||||
self.assertEqual(result["quantity"], 1)
|
||||
self.assertGreater(self.engine.state.gold, gold - 1)
|
||||
self.assertEqual(animal.pending, 0)
|
||||
|
||||
def test_feed_twice_fails(self) -> None:
|
||||
self.engine.state.gold = 1000
|
||||
self.engine.buy_animal("chicken")
|
||||
animal = self.engine.state.animals[0]
|
||||
self.engine.feed(animal.aid)
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.feed(animal.aid)
|
||||
self.assertEqual(ctx.exception.code, "already_fed")
|
||||
|
||||
def test_collect_without_product(self) -> None:
|
||||
self.engine.state.gold = 1000
|
||||
self.engine.buy_animal("chicken")
|
||||
animal = self.engine.state.animals[0]
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.collect(animal.aid)
|
||||
self.assertEqual(ctx.exception.code, "nothing_to_collect")
|
||||
|
||||
def test_animal_starves_to_death(self) -> None:
|
||||
self.engine.state.gold = 1000
|
||||
self.engine.buy_animal("chicken")
|
||||
for _ in range(3):
|
||||
self.engine.advance_day()
|
||||
self.assertEqual(len(self.engine.state.animals), 0)
|
||||
|
||||
def test_animal_survives_if_fed(self) -> None:
|
||||
self.engine.state.gold = 1000
|
||||
self.engine.state.feed = 10
|
||||
self.engine.buy_animal("chicken")
|
||||
animal = self.engine.state.animals[0]
|
||||
for _ in range(6):
|
||||
self.engine.feed(animal.aid)
|
||||
self.engine.advance_day()
|
||||
self.assertEqual(len(self.engine.state.animals), 1)
|
||||
self.assertGreater(animal.pending, 0)
|
||||
|
||||
def test_unknown_animal(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.buy_animal("dragon")
|
||||
self.assertEqual(ctx.exception.code, "unknown_animal")
|
||||
|
||||
def test_pasture_limit(self) -> None:
|
||||
self.engine.state.gold = 10 ** 6
|
||||
for _ in range(config.MAX_ANIMALS):
|
||||
self.engine.buy_animal("chicken")
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.buy_animal("chicken")
|
||||
self.assertEqual(ctx.exception.code, "pasture_full")
|
||||
|
||||
def test_emergency_feed_when_out_of_stock(self) -> None:
|
||||
self.engine.state.gold = 1000
|
||||
self.engine.buy_animal("chicken")
|
||||
self.engine.state.feed = 0
|
||||
animal = self.engine.state.animals[0]
|
||||
gold = self.engine.state.gold
|
||||
self.engine.feed(animal.aid)
|
||||
self.assertTrue(animal.fed)
|
||||
self.assertLess(self.engine.state.gold, gold) # 被扣了高价补货费
|
||||
|
||||
def test_buy_feed_validation(self) -> None:
|
||||
self.engine.state.gold = 10 ** 6
|
||||
with self.assertRaises(GameError):
|
||||
self.engine.buy_feed(0)
|
||||
with self.assertRaises(GameError):
|
||||
self.engine.buy_feed(-5)
|
||||
with self.assertRaises(GameError):
|
||||
self.engine.buy_feed(config.MAX_FEED_PURCHASE + 1)
|
||||
with self.assertRaises(GameError):
|
||||
self.engine.buy_feed("十份")
|
||||
self.engine.buy_feed(5)
|
||||
self.assertEqual(self.engine.state.feed, config.START_FEED + 5)
|
||||
|
||||
# ============================================================ 时间与市场
|
||||
def test_advance_day_resets_ap(self) -> None:
|
||||
self.engine.state.ap = 0
|
||||
self.engine.advance_day()
|
||||
self.assertEqual(self.engine.state.ap, config.START_AP)
|
||||
self.assertEqual(self.engine.state.day, 2)
|
||||
|
||||
def test_sleep_cooldown(self) -> None:
|
||||
self.engine.sleep()
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
self.engine.sleep()
|
||||
self.assertEqual(ctx.exception.code, "cooldown")
|
||||
|
||||
def test_market_prices_stay_in_range(self) -> None:
|
||||
for _ in range(60):
|
||||
self.engine.advance_day()
|
||||
for key, info in config.CROPS.items():
|
||||
price = self.engine.market_price(key)
|
||||
base = info["base_price"]
|
||||
self.assertGreaterEqual(price, base * 0.5)
|
||||
self.assertLessEqual(price, base * 1.6)
|
||||
|
||||
def test_pause_toggle(self) -> None:
|
||||
self.engine.pause(True)
|
||||
self.assertTrue(self.engine.paused)
|
||||
self.engine.pause(False)
|
||||
self.assertFalse(self.engine.paused)
|
||||
result = self.engine.pause()
|
||||
self.assertTrue(result["paused"])
|
||||
self.engine.pause(False)
|
||||
|
||||
# ============================================================ 查询接口
|
||||
def test_snapshot_is_serializable(self) -> None:
|
||||
import json
|
||||
|
||||
self.engine.plant(1, "wheat")
|
||||
snap = self.engine.snapshot()
|
||||
text = json.dumps(snap, ensure_ascii=False)
|
||||
self.assertIn("plots", text)
|
||||
|
||||
def test_available_actions_reference_valid_plots(self) -> None:
|
||||
actions = self.engine.available_actions()
|
||||
self.assertTrue(any(a["action"] == "plant" for a in actions))
|
||||
for action in actions:
|
||||
if action["action"] in ("plant", "water", "harvest"):
|
||||
plot = action["params"]["plot"]
|
||||
self.assertTrue(1 <= plot <= len(self.engine.state.plots))
|
||||
|
||||
def test_catalog_marks_locked(self) -> None:
|
||||
catalog = self.engine.catalog()
|
||||
by_key = {c["key"]: c for c in catalog["crops"]}
|
||||
self.assertFalse(by_key["wheat"]["locked"])
|
||||
self.assertTrue(by_key["pumpkin"]["locked"])
|
||||
|
||||
# ============================================================ 存档
|
||||
def test_save_load_roundtrip(self) -> None:
|
||||
self.engine.plant(1, "carrot")
|
||||
self.engine.buy_animal("chicken")
|
||||
self.engine.state.gold = 500 # 操作完成后再定金币,避免被扣费干扰断言
|
||||
self.engine.state.feed = 7
|
||||
self.engine.state.day = 13
|
||||
path = os.path.join(self.tmp, "roundtrip.json")
|
||||
|
||||
save_state(self.engine.state, path)
|
||||
loaded, message = load_state(path)
|
||||
self.assertIsNotNone(loaded, message)
|
||||
assert loaded is not None
|
||||
self.assertEqual(loaded.gold, 500)
|
||||
self.assertEqual(loaded.feed, 7)
|
||||
self.assertEqual(loaded.day, 13)
|
||||
self.assertEqual(loaded.plots[0].crop, "carrot")
|
||||
self.assertEqual(loaded.animals[0].kind, "chicken")
|
||||
|
||||
def test_load_missing_file(self) -> None:
|
||||
state, message = load_state(os.path.join(self.tmp, "nope.json"))
|
||||
self.assertIsNone(state)
|
||||
self.assertIn("未找到", message)
|
||||
|
||||
def test_load_corrupt_file(self) -> None:
|
||||
path = os.path.join(self.tmp, "broken.json")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write("{ not valid json")
|
||||
state, message = load_state(path)
|
||||
self.assertIsNone(state)
|
||||
self.assertIn("解析", message)
|
||||
|
||||
def test_load_wrong_version(self) -> None:
|
||||
import json
|
||||
|
||||
path = os.path.join(self.tmp, "old.json")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump({"version": 99, "state": {}}, handle)
|
||||
state, message = load_state(path)
|
||||
self.assertIsNone(state)
|
||||
self.assertIn("版本", message)
|
||||
|
||||
def test_state_from_dict_repairs_bad_data(self) -> None:
|
||||
state = GameState.from_dict(
|
||||
{
|
||||
"day": -5,
|
||||
"gold": -100,
|
||||
"level": 0,
|
||||
"plots": [{"index": 0, "crop": "不存在", "growth": 999, "moisture": 99}],
|
||||
"animals": [{"kind": "独角兽"}],
|
||||
"market": {"wheat": "not a number"},
|
||||
}
|
||||
)
|
||||
self.assertEqual(state.day, 1)
|
||||
self.assertEqual(state.gold, 0)
|
||||
self.assertEqual(state.level, 1)
|
||||
self.assertIsNone(state.plots[0].crop) # 非法作物被清空
|
||||
self.assertEqual(state.animals, []) # 非法动物被丢弃
|
||||
self.assertGreater(state.market["wheat"], 0) # 非法价格回落默认值
|
||||
self.assertGreaterEqual(len(state.plots), config.BASE_PLOTS)
|
||||
|
||||
# ============================================================ 并发
|
||||
def test_concurrent_operations_are_safe(self) -> None:
|
||||
self.engine.state.gold = 10 ** 6
|
||||
errors = []
|
||||
|
||||
def worker(index: int) -> None:
|
||||
try:
|
||||
for _ in range(30):
|
||||
self.engine.snapshot()
|
||||
self.engine.available_actions()
|
||||
if index % 3 == 0:
|
||||
try:
|
||||
self.engine.buy_feed(1)
|
||||
except GameError:
|
||||
pass
|
||||
except Exception as exc: # 记录任何非预期异常
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(12)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
class PlotModelTestCase(unittest.TestCase):
|
||||
def test_stage_progression(self) -> None:
|
||||
plot = Plot(0)
|
||||
self.assertEqual(plot.stage, "空地")
|
||||
plot.crop = "wheat"
|
||||
self.assertEqual(plot.stage, "已播种")
|
||||
plot.growth = 0.7
|
||||
self.assertEqual(plot.stage, "幼苗")
|
||||
plot.growth = 1.5
|
||||
self.assertEqual(plot.stage, "生长中")
|
||||
plot.growth = 2
|
||||
self.assertTrue(plot.is_ripe)
|
||||
self.assertEqual(plot.stage, "已成熟")
|
||||
|
||||
def test_days_left_never_negative(self) -> None:
|
||||
plot = Plot(0)
|
||||
plot.crop = "wheat"
|
||||
plot.growth = 99
|
||||
self.assertEqual(plot.days_left, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,596 @@
|
||||
"""服务器层测试:动作表校验、REST 接口、多牧场隔离、鉴权、WebSocket 端到端。
|
||||
|
||||
分三层
|
||||
------
|
||||
1. :class:`TestActionTable` / :class:`TestFarmId` —— 纯函数,不需要服务器;
|
||||
2. :class:`TestRestAPI` —— 用 FastAPI 的 TestClient 在进程内打接口;
|
||||
3. :class:`TestLiveServer` —— 真的起一个 uvicorn,再用 :class:`farm.client.RemoteFarm`
|
||||
通过 HTTP + WebSocket 连上去。这一层才是"客户端到底行不行"的答案。
|
||||
|
||||
如果机器上没装 FastAPI/uvicorn,第 2、3 层会跳过而不是失败——
|
||||
单机引擎的测试在 test_engine.py,那部分永远可跑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from farm import config
|
||||
from farm.engine import FarmEngine, GameError
|
||||
from farm.server.actions import ACTIONS, describe_actions, run_action
|
||||
from farm.server.session import FarmSession, SessionManager, validate_farm_id
|
||||
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
HAS_FASTAPI = True
|
||||
except ImportError: # pragma: no cover - 取决于环境
|
||||
HAS_FASTAPI = False
|
||||
|
||||
try:
|
||||
import uvicorn # noqa: F401
|
||||
|
||||
HAS_UVICORN = True
|
||||
except ImportError: # pragma: no cover
|
||||
HAS_UVICORN = False
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
# ======================================================================= 纯逻辑
|
||||
class TestActionTable(unittest.TestCase):
|
||||
"""动作表是所有入口的唯一执行通道,参数校验必须严格。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.engine = FarmEngine(tick_seconds=60)
|
||||
|
||||
def test_all_actions_documented(self) -> None:
|
||||
described = describe_actions()
|
||||
self.assertEqual(len(described), len(ACTIONS))
|
||||
for item in described:
|
||||
self.assertTrue(item["summary"])
|
||||
self.assertIn(item["action"], ACTIONS)
|
||||
|
||||
def test_plant_and_harvest_via_action_table(self) -> None:
|
||||
result = run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"})
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["action"], "plant")
|
||||
state = self.engine.snapshot()
|
||||
self.assertEqual(state["plots"][0]["crop"], "wheat")
|
||||
|
||||
self.engine.advance_day()
|
||||
self.engine.advance_day()
|
||||
harvested = run_action(self.engine, "harvest", {"plot": 1})
|
||||
self.assertGreater(harvested["earned"], 0)
|
||||
|
||||
def test_string_number_is_coerced(self) -> None:
|
||||
"""命令行/表单常把数字传成字符串,这里必须能识别。"""
|
||||
result = run_action(self.engine, "plant", {"plot": "1", "crop": "wheat"})
|
||||
self.assertTrue(result["ok"])
|
||||
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "fly_to_moon", {})
|
||||
self.assertEqual(ctx.exception.code, "unknown_action")
|
||||
self.assertEqual(ctx.exception.status, 400)
|
||||
|
||||
def test_missing_required_param(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "plant", {"plot": 1})
|
||||
self.assertEqual(ctx.exception.code, "missing_param")
|
||||
|
||||
def test_unknown_param_rejected(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "plant", {"plot": 1, "crop": "wheat", "magic": 1})
|
||||
self.assertEqual(ctx.exception.code, "bad_param")
|
||||
self.assertIn("magic", ctx.exception.message)
|
||||
|
||||
def test_bad_type_rejected(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "plant", {"plot": "第一块地", "crop": "wheat"})
|
||||
self.assertEqual(ctx.exception.code, "bad_param")
|
||||
|
||||
def test_bool_param_forms(self) -> None:
|
||||
result = run_action(self.engine, "pause", {"paused": "true"})
|
||||
self.assertTrue(self.engine.paused)
|
||||
result = run_action(self.engine, "pause", {"paused": False})
|
||||
self.assertFalse(self.engine.paused)
|
||||
self.assertEqual(result["action"], "pause")
|
||||
with self.assertRaises(GameError):
|
||||
run_action(self.engine, "pause", {"paused": "也许"})
|
||||
|
||||
def test_harvest_all_with_nothing_ripe(self) -> None:
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "harvest_all", {})
|
||||
self.assertEqual(ctx.exception.code, "nothing_to_harvest")
|
||||
|
||||
def test_business_error_keeps_code(self) -> None:
|
||||
"""业务错误要保留机器可读的 code,AI 靠它分支。"""
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "plant", {"plot": 99, "crop": "wheat"})
|
||||
self.assertEqual(ctx.exception.code, "no_such_plot")
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
run_action(self.engine, "buy_animal", {"kind": "dragon"})
|
||||
self.assertEqual(ctx.exception.code, "unknown_animal")
|
||||
|
||||
|
||||
class TestFarmId(unittest.TestCase):
|
||||
"""牧场 ID 会变成文件名,必须挡住路径穿越之类的输入。"""
|
||||
|
||||
def test_normal(self) -> None:
|
||||
for name in ("default", "alice", "ai-1", "玩家甲", "farm_01"):
|
||||
self.assertEqual(validate_farm_id(name), name)
|
||||
|
||||
def test_default_when_empty(self) -> None:
|
||||
self.assertEqual(validate_farm_id(None), "default")
|
||||
self.assertEqual(validate_farm_id(""), "default")
|
||||
|
||||
def test_rejects_path_traversal(self) -> None:
|
||||
for bad in ("../etc/passwd", "a/b", "a\\b", "..", "a b", "x" * 40, "农场:1"):
|
||||
with self.assertRaises(GameError):
|
||||
validate_farm_id(bad)
|
||||
|
||||
|
||||
class TestSessionManager(unittest.TestCase):
|
||||
"""多牧场隔离与回收策略。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.mkdtemp(prefix="farm-session-")
|
||||
self.manager = SessionManager(save_dir=self.tmp, tick_seconds=60)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.manager.shutdown()
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_farms_are_isolated(self) -> None:
|
||||
alice = self.manager.get("alice")
|
||||
bob = self.manager.get("bob")
|
||||
alice.engine.plant(1, "wheat")
|
||||
alice.engine.state.gold = 999
|
||||
|
||||
self.assertIsNone(bob.engine.state.plots[0].crop)
|
||||
self.assertNotEqual(bob.engine.state.gold, 999)
|
||||
self.assertEqual(self.manager.count(), 2)
|
||||
self.assertIs(self.manager.get("alice"), alice)
|
||||
|
||||
def test_save_and_reload_roundtrip(self) -> None:
|
||||
alice = self.manager.get("alice")
|
||||
alice.engine.plant(1, "carrot")
|
||||
alice.engine.state.gold = 777
|
||||
alice.save()
|
||||
|
||||
fresh = SessionManager(save_dir=self.tmp, tick_seconds=60)
|
||||
try:
|
||||
again = fresh.get("alice")
|
||||
self.assertEqual(again.engine.state.gold, 777)
|
||||
self.assertEqual(again.engine.state.plots[0].crop, "carrot")
|
||||
finally:
|
||||
fresh.shutdown()
|
||||
|
||||
def test_leaderboard_sorted_by_net_worth(self) -> None:
|
||||
poor = self.manager.get("poor")
|
||||
rich = self.manager.get("rich")
|
||||
poor.engine.state.gold = 10
|
||||
rich.engine.state.gold = 5000
|
||||
rows = self.manager.leaderboard()
|
||||
self.assertEqual(rows[0]["farm"], "rich")
|
||||
self.assertEqual(rows[0]["rank"], 1)
|
||||
self.assertEqual(rows[1]["farm"], "poor")
|
||||
|
||||
def test_reap_only_drops_idle_offline_farms(self) -> None:
|
||||
busy = self.manager.get("busy")
|
||||
idle = self.manager.get("idle")
|
||||
busy.last_active = time.time() - 9999
|
||||
idle.last_active = time.time() - 9999
|
||||
self.manager.idle_timeout = 10
|
||||
|
||||
dropped = self.manager.reap(online_map={"busy": 1})
|
||||
self.assertEqual(dropped, ["idle"])
|
||||
self.assertIsNotNone(self.manager.find("busy"))
|
||||
self.assertIsNone(self.manager.find("idle"))
|
||||
# 被回收的牧场一定已经落盘
|
||||
self.assertTrue(os.path.exists(os.path.join(self.tmp, "idle.json")))
|
||||
|
||||
def test_paused_farm_is_not_reaped(self) -> None:
|
||||
session = self.manager.get("thinking")
|
||||
session.engine.pause(True)
|
||||
session.last_active = time.time() - 9999
|
||||
self.manager.idle_timeout = 10
|
||||
self.assertEqual(self.manager.reap(), [])
|
||||
|
||||
def test_max_sessions_guard(self) -> None:
|
||||
manager = SessionManager(save_dir=self.tmp, max_sessions=2)
|
||||
try:
|
||||
manager.get("a")
|
||||
manager.get("b")
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
manager.get("c")
|
||||
self.assertEqual(ctx.exception.code, "too_many_farms")
|
||||
finally:
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
# ======================================================================= REST
|
||||
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
|
||||
class TestRestAPI(unittest.TestCase):
|
||||
"""用 TestClient 在进程内验证 HTTP 语义。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
from farm.server.app import ServerConfig, create_app
|
||||
|
||||
self.tmp = tempfile.mkdtemp(prefix="farm-api-")
|
||||
self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp,
|
||||
tick_seconds=60)
|
||||
self.app = create_app(self.cfg)
|
||||
self.client = TestClient(self.app)
|
||||
self.client.__enter__()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.__exit__(None, None, None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# ------------------------------------------------------------ 基础
|
||||
def test_health_without_token(self) -> None:
|
||||
data = self.client.get("/api/health").json()
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertIn("uptime", data)
|
||||
|
||||
def test_dashboard_and_text_index(self) -> None:
|
||||
page = self.client.get("/")
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertIn("SimpleFarm", page.text)
|
||||
self.assertIn("WebSocket", page.text)
|
||||
text = self.client.get("/text").text
|
||||
self.assertIn("/api/actions", text)
|
||||
|
||||
def test_help_lists_actions(self) -> None:
|
||||
data = self.client.get("/api/help").json()
|
||||
self.assertIn("rules", data)
|
||||
names = {item["action"] for item in data["actions"]}
|
||||
self.assertIn("plant", names)
|
||||
self.assertIn("harvest_all", names)
|
||||
|
||||
def test_openapi_schema_available(self) -> None:
|
||||
schema = self.client.get("/openapi.json").json()
|
||||
self.assertIn("/api/actions", schema["paths"])
|
||||
|
||||
# ------------------------------------------------------------ 牧场读写
|
||||
def test_state_creates_farm_lazily(self) -> None:
|
||||
data = self.client.get("/api/state?farm=newbie").json()
|
||||
self.assertEqual(data["state"]["day"], 1)
|
||||
self.assertEqual(data["state"]["gold"], config.START_GOLD)
|
||||
|
||||
def test_generic_action_entry(self) -> None:
|
||||
body = {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}
|
||||
data = self.client.post("/api/actions?farm=one", json=body).json()
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertIn("已种下", data["message"])
|
||||
self.assertEqual(data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"])
|
||||
|
||||
state = self.client.get("/api/state?farm=one").json()["state"]
|
||||
self.assertEqual(state["plots"][0]["crop"], "wheat")
|
||||
|
||||
def test_flat_params_also_accepted(self) -> None:
|
||||
"""{"action":"plant","plot":1,"crop":"wheat"} 这种平铺写法也要能用。"""
|
||||
data = self.client.post("/api/actions?farm=flat",
|
||||
json={"action": "plant", "plot": 1, "crop": "wheat"}).json()
|
||||
self.assertTrue(data["ok"])
|
||||
|
||||
def test_convenience_route(self) -> None:
|
||||
data = self.client.post("/api/plant?farm=short", json={"plot": 2, "crop": "carrot"}).json()
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertEqual(data["action"], "plant")
|
||||
|
||||
def test_action_error_shape(self) -> None:
|
||||
response = self.client.post("/api/actions?farm=err",
|
||||
json={"action": "plant", "params": {"plot": 1, "crop": "nope"}})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = response.json()
|
||||
self.assertFalse(data["ok"])
|
||||
self.assertEqual(data["error"], "unknown_crop")
|
||||
self.assertIn("message", data)
|
||||
|
||||
def test_out_of_range_plot(self) -> None:
|
||||
response = self.client.post("/api/actions?farm=err",
|
||||
json={"action": "water", "params": {"plot": 999}})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "no_such_plot")
|
||||
|
||||
def test_bad_json_body(self) -> None:
|
||||
response = self.client.post("/api/actions?farm=err", content=b"{oops",
|
||||
headers={"Content-Type": "application/json"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "bad_json")
|
||||
|
||||
def test_actions_endpoint_matches_state(self) -> None:
|
||||
data = self.client.get("/api/actions?farm=solo").json()
|
||||
names = {item["action"] for item in data["actions"]}
|
||||
self.assertIn("plant", names) # 有空地 + 有钱 → 可播种
|
||||
self.assertNotIn("harvest", names) # 还没种东西 → 不可收获
|
||||
|
||||
def test_batch_endpoint(self) -> None:
|
||||
items = {"actions": [
|
||||
{"action": "buy_feed", "params": {"quantity": 5}},
|
||||
{"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
|
||||
{"action": "harvest", "params": {"plot": 1}}, # 必然失败
|
||||
]}
|
||||
data = self.client.post("/api/batch?farm=multi", json=items).json()
|
||||
self.assertEqual(data["done"], 2)
|
||||
self.assertEqual(data["failed"], 1)
|
||||
self.assertEqual(data["results"][2]["error"], "not_ripe")
|
||||
|
||||
def test_log_entries_and_since(self) -> None:
|
||||
self.client.post("/api/actions?farm=logging",
|
||||
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}})
|
||||
entries = self.client.get("/api/log?farm=logging").json()["entries"]
|
||||
self.assertTrue(entries)
|
||||
last = entries[-1]["seq"]
|
||||
newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"]
|
||||
self.assertEqual(newer, [])
|
||||
self.client.post("/api/actions?farm=logging", json={"action": "save"})
|
||||
newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"]
|
||||
self.assertTrue(newer)
|
||||
|
||||
def test_farms_list_and_isolation(self) -> None:
|
||||
self.client.post("/api/actions?farm=alice",
|
||||
json={"action": "buy_feed", "params": {"quantity": 10}})
|
||||
self.client.post("/api/actions?farm=bob", json={"action": "save"})
|
||||
rows = self.client.get("/api/farms").json()["farms"]
|
||||
by_name = {row["farm"]: row for row in rows}
|
||||
self.assertIn("alice", by_name)
|
||||
self.assertEqual(by_name["alice"]["gold"],
|
||||
config.START_GOLD - 10 * config.FEED_UNIT_PRICE)
|
||||
|
||||
def test_create_farm_reports_created_flag(self) -> None:
|
||||
first = self.client.post("/api/farms", json={"farm": "zoe", "owner": "小Z"}).json()
|
||||
self.assertTrue(first["created"])
|
||||
self.assertEqual(first["summary"]["owner"], "小Z")
|
||||
again = self.client.post("/api/farms", json={"farm": "zoe"}).json()
|
||||
self.assertFalse(again["created"])
|
||||
|
||||
def test_bad_farm_id_rejected(self) -> None:
|
||||
response = self.client.get("/api/state?farm=../evil")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "bad_farm")
|
||||
|
||||
def test_farm_via_header(self) -> None:
|
||||
data = self.client.get("/api/state", headers={"X-Farm-Id": "header-farm"}).json()
|
||||
self.assertEqual(data["farm"], "header-farm")
|
||||
|
||||
def test_admin_save(self) -> None:
|
||||
self.client.post("/api/actions?farm=saver",
|
||||
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}})
|
||||
data = self.client.post("/api/admin/save").json()
|
||||
self.assertIn("saver", data["farms"])
|
||||
self.assertTrue(os.path.exists(os.path.join(self.tmp, "saver.json")))
|
||||
|
||||
def test_world_view(self) -> None:
|
||||
self.client.post("/api/actions?farm=w1", json={"action": "save"})
|
||||
data = self.client.get("/api/world").json()
|
||||
self.assertEqual(data["farms"], 1)
|
||||
self.assertEqual(data["leaderboard"][0]["farm"], "w1")
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
|
||||
class TestAuth(unittest.TestCase):
|
||||
"""令牌鉴权:没带令牌必须 401,带了才能操作。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
from farm.server.app import ServerConfig, create_app
|
||||
|
||||
self.tmp = tempfile.mkdtemp(prefix="farm-auth-")
|
||||
self.app = create_app(ServerConfig(save_dir=self.tmp, token="s3cret", tick_seconds=60))
|
||||
self.client = TestClient(self.app)
|
||||
self.client.__enter__()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.__exit__(None, None, None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_health_is_public(self) -> None:
|
||||
self.assertEqual(self.client.get("/api/health").status_code, 200)
|
||||
|
||||
def test_state_requires_token(self) -> None:
|
||||
response = self.client.get("/api/state")
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["error"], "unauthorized")
|
||||
|
||||
def test_token_via_header(self) -> None:
|
||||
response = self.client.get("/api/state", headers={"X-API-Token": "s3cret"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_token_via_query(self) -> None:
|
||||
response = self.client.get("/api/state?token=s3cret")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_wrong_token(self) -> None:
|
||||
self.assertEqual(self.client.get("/api/state?token=nope").status_code, 401)
|
||||
|
||||
def test_action_blocked_without_token(self) -> None:
|
||||
response = self.client.post("/api/actions",
|
||||
json={"action": "buy_plot"})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
# ======================================================================= 真网络
|
||||
@unittest.skipUnless(HAS_FASTAPI and HAS_UVICORN, "未安装 FastAPI/uvicorn")
|
||||
class TestLiveServer(unittest.TestCase):
|
||||
"""真的起 uvicorn,用真实客户端走 HTTP 与 WebSocket。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from farm.server.app import ServerConfig, create_app
|
||||
|
||||
cls.tmp = tempfile.mkdtemp(prefix="farm-live-")
|
||||
cls.port = free_port()
|
||||
cls.url = f"http://127.0.0.1:{cls.port}"
|
||||
app = create_app(ServerConfig(host="127.0.0.1", port=cls.port, save_dir=cls.tmp,
|
||||
tick_seconds=600, autosave_interval=3600))
|
||||
import uvicorn
|
||||
|
||||
cls.server = uvicorn.Server(uvicorn.Config(
|
||||
app, host="127.0.0.1", port=cls.port, log_level="warning",
|
||||
access_log=False, lifespan="on",
|
||||
))
|
||||
cls.thread = threading.Thread(target=cls.server.run, name="live-server", daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", cls.port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
else: # pragma: no cover
|
||||
raise unittest.SkipTest("服务器没能在 20 秒内起来")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.server.should_exit = True
|
||||
cls.thread.join(timeout=10)
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def client(self, farm: str, realtime: bool = True):
|
||||
from farm.client import RemoteFarm
|
||||
|
||||
return RemoteFarm(endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0,
|
||||
autostart=True)
|
||||
|
||||
def test_http_roundtrip_through_real_client(self) -> None:
|
||||
client = self.client("live-http", realtime=False)
|
||||
try:
|
||||
state = client.snapshot()
|
||||
self.assertEqual(state["farm"], "live-http")
|
||||
result = client.plant(1, "wheat")
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(client.state.plots[0].crop, "wheat")
|
||||
self.assertFalse(client.state.plots[0].is_ripe)
|
||||
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
client.plant(1, "carrot") # 地已经占用了
|
||||
self.assertEqual(ctx.exception.code, "plot_busy")
|
||||
|
||||
actions = client.available_actions()
|
||||
names = {item["action"] for item in actions}
|
||||
self.assertIn("plant", names) # 还有空地可以种
|
||||
self.assertNotIn("harvest", names) # 小麦还没成熟
|
||||
|
||||
client.water(1) # 播种后水分 2/3,可以补满
|
||||
self.assertEqual(client.state.plots[0].moisture, config.WATER_CAPACITY)
|
||||
with self.assertRaises(GameError) as ctx:
|
||||
client.water(1) # 水分已满,再浇就是错的
|
||||
self.assertEqual(ctx.exception.code, "already_watered")
|
||||
|
||||
catalog = client.catalog()
|
||||
self.assertTrue(catalog["crops"])
|
||||
self.assertTrue(client.leaderboard())
|
||||
self.assertIn("规则速览", client.help_text())
|
||||
finally:
|
||||
client.stop()
|
||||
|
||||
def test_two_clients_talk_to_the_same_farm(self) -> None:
|
||||
"""两个客户端改同一个牧场:一方操作,另一方从推送里看到。"""
|
||||
watcher = self.client("shared")
|
||||
actor = self.client("shared")
|
||||
try:
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and len(watcher.state.plots) == 0:
|
||||
time.sleep(0.1)
|
||||
self.assertTrue(watcher.state.plots)
|
||||
|
||||
actor.plant(1, "carrot")
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and watcher.state.plots[0].crop != "carrot":
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(watcher.state.plots[0].crop, "carrot",
|
||||
"实时通道应该把另一方的操作推过来")
|
||||
self.assertEqual(watcher.mode, "ws")
|
||||
finally:
|
||||
watcher.stop()
|
||||
actor.stop()
|
||||
|
||||
def test_websocket_channel_is_really_used(self) -> None:
|
||||
"""写了动作应该走 WS 通道,而不是退化到 HTTP。"""
|
||||
client = self.client("ws-farm")
|
||||
try:
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and client.mode != "ws":
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(client.mode, "ws", "WebSocket 没能连上")
|
||||
client.buy_feed(3)
|
||||
self.assertEqual(client.stats["ws_actions"], 1)
|
||||
self.assertEqual(client.stats["http"] > 0, True)
|
||||
finally:
|
||||
client.stop()
|
||||
|
||||
def test_farms_do_not_leak_into_each_other(self) -> None:
|
||||
a = self.client("iso-a", realtime=False)
|
||||
b = self.client("iso-b", realtime=False)
|
||||
try:
|
||||
a.plant(1, "wheat")
|
||||
self.assertIsNone(b.state.plots[0].crop)
|
||||
self.assertNotEqual(a.state.gold, b.state.gold)
|
||||
finally:
|
||||
a.stop()
|
||||
b.stop()
|
||||
|
||||
def test_save_on_server_persists(self) -> None:
|
||||
client = self.client("persist", realtime=False)
|
||||
try:
|
||||
client.plant(1, "wheat")
|
||||
client.save()
|
||||
path = os.path.join(self.tmp, "persist.json")
|
||||
self.assertTrue(os.path.exists(path))
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
self.assertEqual(payload["state"]["plots"][0]["crop"], "wheat")
|
||||
finally:
|
||||
client.stop()
|
||||
|
||||
def test_bot_can_play_via_the_api(self) -> None:
|
||||
"""规则机器人只调接口,也应该能真的把农场经营起来。"""
|
||||
from farm.bot import RuleBot
|
||||
|
||||
client = self.client("bot-farm", realtime=False)
|
||||
bot = RuleBot(client, verbose=False, pause=0.02, name="bot-farm")
|
||||
try:
|
||||
summary = bot.run(rounds=12, idle_limit=3)
|
||||
self.assertGreaterEqual(summary["actions"], 1)
|
||||
self.assertIn("net_worth", summary)
|
||||
self.assertEqual(bot.failures, 0)
|
||||
self.assertTrue(client.available_actions() is not None)
|
||||
finally:
|
||||
client.stop()
|
||||
|
||||
def test_rate_free_path_uses_ws_and_falls_back(self) -> None:
|
||||
"""WS 断开后写操作要能自动退回 HTTP,而不是直接失败。"""
|
||||
client = self.client("fallback")
|
||||
try:
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and client.mode != "ws":
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(client.mode, "ws")
|
||||
# 强行掐断:让后续写操作只能走 HTTP
|
||||
client._stop.set()
|
||||
client._ws_socket = None
|
||||
result = client.buy_feed(1)
|
||||
self.assertTrue(result["ok"])
|
||||
finally:
|
||||
client.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user