三件事凑成一次不兼容变更: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)。
656 lines
25 KiB
Python
656 lines
25 KiB
Python
"""键盘与 TUI 测试:这里曾经漏掉一个只有真按下去才会发现的 bug。
|
|
|
|
历史事故
|
|
--------
|
|
``keys.py`` 在 Windows 上返回**大写** ``"UP"``,而 ``tui.py`` 的处理表里写的是
|
|
**小写** ``"up"``,于是方向键、空格、Tab、ESC 全部**静默失效**——字母键因为
|
|
``char.lower()`` 恰好对得上而正常,所以肉眼很难察觉。
|
|
|
|
因此这里的测试分两层:
|
|
* 约定层:两套平台映射表必须落在**同一批小写键名**里;
|
|
* 行为层:逐个具名键驱动**真实的** :class:`farm.tui.TuiApp`,确认真的有人处理。
|
|
|
|
uv run python -m unittest tests.test_tui_keys -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from farm import config, keys
|
|
from farm.engine import FarmEngine
|
|
from farm.keys import KeyReader
|
|
from farm.tui import (
|
|
ANSI_RE,
|
|
TuiApp,
|
|
bar,
|
|
display_width,
|
|
draw_box,
|
|
hstack,
|
|
pad,
|
|
strip_ansi,
|
|
truncate,
|
|
)
|
|
|
|
# 主模式下"按下去必须有反应"的具名键。ENTER / HOME / END / DELETE 等在主界面
|
|
# 没有绑定,静默忽略是设计如此,所以不在此列(ESC 同理:它只在菜单里取消)。
|
|
NAMED_KEYS_WITH_MAIN_BINDINGS = (
|
|
keys.UP,
|
|
keys.DOWN,
|
|
keys.LEFT,
|
|
keys.RIGHT,
|
|
keys.SPACE,
|
|
keys.TAB,
|
|
keys.CTRL_C,
|
|
)
|
|
|
|
|
|
def fake_reader(data: bytes) -> KeyReader:
|
|
"""造一个只带解析能力的 KeyReader:绕过 tty 初始化,直接喂字节。"""
|
|
reader = KeyReader.__new__(KeyReader)
|
|
reader._fd = None
|
|
reader._saved_term = None
|
|
reader._raw = data
|
|
reader.reason = ""
|
|
reader.available = True
|
|
return reader
|
|
|
|
|
|
def make_app(**kwargs) -> TuiApp:
|
|
"""真实引擎 + 真实 TuiApp,只是不写盘、不上色,方便断言。"""
|
|
engine = FarmEngine()
|
|
engine.state.gold = 3000
|
|
directory = tempfile.mkdtemp(prefix="simplefarm-tui-")
|
|
options = {
|
|
"save_path": os.path.join(directory, "save.json"),
|
|
"use_color": False,
|
|
"autosave": False,
|
|
}
|
|
options.update(kwargs)
|
|
return TuiApp(engine, **options)
|
|
|
|
|
|
class KeyNameConventionTestCase(unittest.TestCase):
|
|
"""约定层:全小写、集中定义、两端一致。"""
|
|
|
|
def test_every_named_key_constant_is_lowercase(self):
|
|
for name in keys.NAMED_KEYS:
|
|
with self.subTest(key=name):
|
|
self.assertIsInstance(name, str)
|
|
self.assertEqual(name, name.lower(), f"{name!r} 必须全小写")
|
|
self.assertNotEqual(name, "", "空字符串不能当键名")
|
|
|
|
def test_named_keys_is_exactly_the_set_of_constants(self):
|
|
constants = {
|
|
getattr(keys, attr)
|
|
for attr in dir(keys)
|
|
if attr.isupper() and isinstance(getattr(keys, attr), str)
|
|
}
|
|
self.assertEqual(set(keys.NAMED_KEYS), constants)
|
|
|
|
def test_named_keys_do_not_collide_with_typeable_characters(self):
|
|
"""具名键不能是单个字符,否则会和普通按键混在一起分不清。"""
|
|
for name in keys.NAMED_KEYS:
|
|
with self.subTest(key=name):
|
|
self.assertGreater(len(name), 1)
|
|
|
|
def test_constant_values_are_the_lowercase_form_of_their_names(self):
|
|
self.assertEqual(keys.UP, "up")
|
|
self.assertEqual(keys.DOWN, "down")
|
|
self.assertEqual(keys.LEFT, "left")
|
|
self.assertEqual(keys.RIGHT, "right")
|
|
self.assertEqual(keys.SPACE, "space")
|
|
self.assertEqual(keys.TAB, "tab")
|
|
self.assertEqual(keys.ESC, "esc")
|
|
self.assertEqual(keys.ENTER, "enter")
|
|
self.assertEqual(keys.CTRL_C, "ctrl_c")
|
|
self.assertEqual(keys.BACKSPACE, "backspace")
|
|
self.assertEqual(keys.DELETE, "delete")
|
|
self.assertEqual(keys.HOME, "home")
|
|
self.assertEqual(keys.END, "end")
|
|
|
|
def test_platform_tables_only_produce_known_names(self):
|
|
tables = {
|
|
"windows 扩展键": keys._WIN_EXTENDED,
|
|
"windows 控制键": keys._WIN_CTRL,
|
|
"posix 控制键": keys._POSIX_CTRL,
|
|
"posix 转义序列": keys._POSIX_CSI,
|
|
}
|
|
for label, table in tables.items():
|
|
for raw, name in table.items():
|
|
with self.subTest(table=label, raw=raw):
|
|
self.assertIn(name, keys.NAMED_KEYS, f"{label} 产出了未登记的键名 {name!r}")
|
|
self.assertEqual(name, name.lower())
|
|
|
|
def test_both_platforms_produce_the_same_function_keys(self):
|
|
"""这条断言就是那个 bug 的护栏:两端必须产出同一批方向键/功能键。"""
|
|
windows = set(keys._WIN_EXTENDED.values())
|
|
posix = set(keys._POSIX_CSI.values())
|
|
self.assertEqual(windows, posix)
|
|
self.assertEqual(
|
|
windows, {keys.UP, keys.DOWN, keys.LEFT, keys.RIGHT, keys.HOME, keys.END, keys.DELETE}
|
|
)
|
|
|
|
def test_both_platforms_produce_the_same_control_keys(self):
|
|
windows = set(keys._WIN_CTRL.values())
|
|
posix = set(keys._POSIX_CTRL.values())
|
|
self.assertEqual(posix, windows - {keys.ESC}, "POSIX 的 ESC 走单独的转义序列分支")
|
|
|
|
|
|
class KeyReaderAvailabilityTestCase(unittest.TestCase):
|
|
def test_non_tty_stdin_degrades_with_a_reason(self):
|
|
with mock.patch.object(sys, "stdin", None):
|
|
reader = KeyReader()
|
|
self.assertFalse(reader.available)
|
|
self.assertIn("标准输入", reader.reason)
|
|
|
|
def test_piped_stdin_degrades_with_a_reason(self):
|
|
class NotATty:
|
|
def isatty(self) -> bool:
|
|
return False
|
|
|
|
with mock.patch.object(sys, "stdin", NotATty()):
|
|
reader = KeyReader()
|
|
self.assertFalse(reader.available)
|
|
self.assertNotEqual(reader.reason, "")
|
|
|
|
def test_unavailable_reader_returns_nothing(self):
|
|
reader = KeyReader.__new__(KeyReader)
|
|
reader._fd = None
|
|
reader._saved_term = None
|
|
reader._raw = b""
|
|
reader.reason = "测试"
|
|
reader.available = False
|
|
self.assertIsNone(reader.poll())
|
|
|
|
def test_close_is_safe_without_a_terminal(self):
|
|
reader = KeyReader.__new__(KeyReader)
|
|
reader._fd = None
|
|
reader._saved_term = None
|
|
reader._raw = b"leftover"
|
|
reader.reason = ""
|
|
reader.available = False
|
|
reader.close()
|
|
self.assertEqual(reader._raw, b"")
|
|
|
|
|
|
class PosixByteParsingTestCase(unittest.TestCase):
|
|
"""POSIX 侧把原始字节流翻译成按键名——纯函数,不碰真终端。"""
|
|
|
|
def parse(self, data: bytes) -> str | None:
|
|
return fake_reader(data)._parse_posix()
|
|
|
|
def drain(self, data: bytes, times: int = 8) -> list[str]:
|
|
reader = fake_reader(data)
|
|
out: list[str] = []
|
|
for _ in range(times):
|
|
key = reader._parse_posix()
|
|
if key is None:
|
|
break
|
|
out.append(key)
|
|
return out
|
|
|
|
def test_plain_characters_are_lowercased(self):
|
|
self.assertEqual(self.parse(b"a"), "a")
|
|
self.assertEqual(self.parse(b"A"), "a")
|
|
self.assertEqual(self.parse(b"7"), "7")
|
|
|
|
def test_control_characters(self):
|
|
self.assertEqual(self.parse(b"\x03"), keys.CTRL_C)
|
|
self.assertEqual(self.parse(b"\r"), keys.ENTER)
|
|
self.assertEqual(self.parse(b"\n"), keys.ENTER)
|
|
self.assertEqual(self.parse(b"\t"), keys.TAB)
|
|
self.assertEqual(self.parse(b"\x7f"), keys.BACKSPACE)
|
|
self.assertEqual(self.parse(b" "), keys.SPACE)
|
|
|
|
def test_arrow_keys(self):
|
|
self.assertEqual(self.parse(b"\x1b[A"), keys.UP)
|
|
self.assertEqual(self.parse(b"\x1b[B"), keys.DOWN)
|
|
self.assertEqual(self.parse(b"\x1b[C"), keys.RIGHT)
|
|
self.assertEqual(self.parse(b"\x1b[D"), keys.LEFT)
|
|
|
|
def test_home_end_and_delete(self):
|
|
self.assertEqual(self.parse(b"\x1b[H"), keys.HOME)
|
|
self.assertEqual(self.parse(b"\x1b[F"), keys.END)
|
|
self.assertEqual(self.parse(b"\x1b[1~"), keys.HOME)
|
|
self.assertEqual(self.parse(b"\x1b[3~"), keys.DELETE)
|
|
self.assertEqual(self.parse(b"\x1b[4~"), keys.END)
|
|
|
|
def test_application_mode_arrows(self):
|
|
"""有些终端把方向键发成 ESC O A 而不是 ESC [ A。"""
|
|
self.assertEqual(self.parse(b"\x1bOA"), keys.UP)
|
|
self.assertEqual(self.parse(b"\x1bOD"), keys.LEFT)
|
|
|
|
def test_lone_escape_is_the_escape_key(self):
|
|
self.assertEqual(self.parse(b"\x1b"), keys.ESC)
|
|
|
|
def test_escape_followed_by_a_plain_character(self):
|
|
reader = fake_reader(b"\x1bX")
|
|
self.assertEqual(reader._parse_posix(), keys.ESC)
|
|
self.assertEqual(reader._raw, b"X", "多出来的字符要留给下一轮,不能吞掉")
|
|
|
|
def test_unknown_sequence_is_dropped_not_guessed(self):
|
|
"""认不出来的功能键宁可丢掉,也不能退化成一个错误的动作。"""
|
|
self.assertIsNone(self.parse(b"\x1b[99~"))
|
|
|
|
def test_utf8_character_is_decoded_as_a_whole(self):
|
|
self.assertEqual(self.parse("中".encode()), "中")
|
|
|
|
def test_half_a_character_waits_for_the_rest(self):
|
|
reader = fake_reader("中".encode()[:2])
|
|
self.assertIsNone(reader._parse_posix())
|
|
self.assertEqual(len(reader._raw), 2, "半个字符要留在缓冲区里等下一轮")
|
|
reader._raw += "中".encode()[2:]
|
|
self.assertEqual(reader._parse_posix(), "中")
|
|
|
|
def test_several_keys_arriving_at_once_are_returned_one_by_one(self):
|
|
"""老实现只取第一个字节、把其余静默丢掉,快打或粘贴就会丢键。"""
|
|
self.assertEqual(self.drain(b"abc"), ["a", "b", "c"])
|
|
|
|
def test_mixed_burst_keeps_order(self):
|
|
self.assertEqual(self.drain(b"a\x1b[B"), ["a", keys.DOWN])
|
|
|
|
def test_backspace_then_letter(self):
|
|
self.assertEqual(self.drain(b"\x7fp"), [keys.BACKSPACE, "p"])
|
|
|
|
def test_incomplete_sequence_falls_back_to_escape(self):
|
|
"""序列读不全又不能干等:当作单按 ESC,总比把按键吞掉好。"""
|
|
reader = fake_reader(b"\x1b[")
|
|
self.assertEqual(reader._parse_posix(), keys.ESC)
|
|
reader._raw = b"\x1b[A"
|
|
self.assertEqual(reader._parse_posix(), keys.UP)
|
|
|
|
|
|
class TextLayoutTestCase(unittest.TestCase):
|
|
"""渲染用的纯函数:宽度算错的话,边框在中文环境下就会歪。"""
|
|
|
|
def test_ansi_codes_do_not_count_as_width(self):
|
|
self.assertEqual(display_width("\x1b[1mhi\x1b[0m"), 2)
|
|
|
|
def test_cjk_characters_are_double_width(self):
|
|
self.assertEqual(display_width("中文"), 4)
|
|
self.assertEqual(display_width("a中"), 3)
|
|
|
|
def test_strip_ansi_removes_color_codes(self):
|
|
self.assertEqual(strip_ansi("\x1b[31m红\x1b[0m"), "红")
|
|
self.assertIsNone(ANSI_RE.search(strip_ansi("\x1b[31m红\x1b[0m")))
|
|
|
|
def test_pad_aligns_by_display_width(self):
|
|
for align in ("left", "right", "center"):
|
|
with self.subTest(align=align):
|
|
self.assertEqual(display_width(pad("中文", 10, align)), 10)
|
|
|
|
def test_pad_truncates_overlong_content(self):
|
|
self.assertEqual(display_width(pad("非常长的一段中文内容", 6)), 6)
|
|
|
|
def test_truncate_adds_an_ellipsis(self):
|
|
text = truncate("非常长的一段中文内容", 7)
|
|
self.assertLessEqual(display_width(text), 7)
|
|
self.assertTrue(text.endswith("…"))
|
|
|
|
def test_truncate_keeps_short_text_intact(self):
|
|
self.assertEqual(truncate("短的", 10), "短的")
|
|
|
|
def test_bar_renders_filled_and_empty_cells(self):
|
|
self.assertEqual(bar(0, 10, 10), "░" * 10)
|
|
self.assertEqual(bar(10, 10, 10), "█" * 10)
|
|
self.assertEqual(len(bar(5, 10, 10)), 10)
|
|
self.assertEqual(len(bar(1, 0, 4)), 4, "分母为 0 也不能崩")
|
|
|
|
def test_box_lines_are_exactly_the_requested_width(self):
|
|
lines = draw_box("标题", ["内容", "另一行"], 30, use_color=False)
|
|
for line in lines:
|
|
with self.subTest(line=line):
|
|
self.assertEqual(display_width(line), 30)
|
|
|
|
def test_hstack_pads_the_shorter_column(self):
|
|
merged = hstack(["a", "b"], ["1"])
|
|
self.assertEqual(len(merged), 2)
|
|
self.assertTrue(merged[1].startswith("b"))
|
|
|
|
|
|
class TuiNamedKeyBehaviourTestCase(unittest.TestCase):
|
|
"""行为层:真的把键交给 TuiApp,看它有没有动。"""
|
|
|
|
def setUp(self):
|
|
self.app = make_app()
|
|
|
|
def test_arrow_keys_move_the_plot_cursor(self):
|
|
self.app.grid_cols = 3
|
|
self.assertEqual(self.app.plot_cursor, 0)
|
|
|
|
self.app._handle_key(keys.RIGHT)
|
|
self.assertEqual(self.app.plot_cursor, 1)
|
|
self.app._handle_key(keys.DOWN)
|
|
self.assertEqual(self.app.plot_cursor, 4)
|
|
self.app._handle_key(keys.UP)
|
|
self.assertEqual(self.app.plot_cursor, 1)
|
|
self.app._handle_key(keys.LEFT)
|
|
self.assertEqual(self.app.plot_cursor, 0)
|
|
|
|
def test_cursor_cannot_leave_the_grid(self):
|
|
self.app.grid_cols = 3
|
|
self.app._handle_key(keys.LEFT)
|
|
self.assertEqual(self.app.plot_cursor, 0)
|
|
self.app._handle_key(keys.UP)
|
|
self.assertEqual(self.app.plot_cursor, 0)
|
|
|
|
def test_space_toggles_time(self):
|
|
self.assertFalse(self.app.engine.paused)
|
|
self.app._handle_key(keys.SPACE)
|
|
self.assertTrue(self.app.engine.paused)
|
|
self.app._handle_key(keys.SPACE)
|
|
self.assertFalse(self.app.engine.paused)
|
|
|
|
def test_tab_cycles_through_animals(self):
|
|
self.app._handle_key(keys.TAB)
|
|
self.assertIn("还没有动物", self.app.message)
|
|
|
|
for _ in range(2):
|
|
self.app.engine.buy_animal("chicken")
|
|
self.app._handle_key(keys.TAB)
|
|
self.assertEqual(self.app.animal_cursor, 1)
|
|
self.app._handle_key(keys.TAB)
|
|
self.assertEqual(self.app.animal_cursor, 0, "应当循环回第一只")
|
|
|
|
def test_ctrl_c_quits(self):
|
|
self.assertTrue(self.app.running)
|
|
self.app._handle_key(keys.CTRL_C)
|
|
self.assertFalse(self.app.running)
|
|
|
|
def test_every_bound_named_key_does_something(self):
|
|
"""这是那个 bug 的正面护栏:逐个具名键驱动真实 TuiApp。"""
|
|
for key in NAMED_KEYS_WITH_MAIN_BINDINGS:
|
|
with self.subTest(key=key):
|
|
app = make_app()
|
|
app.engine.buy_animal("chicken")
|
|
app.engine.buy_animal("chicken")
|
|
app.grid_cols = 3
|
|
app.plot_cursor = 4 # 摆在中间,上下左右才都动得了
|
|
before = (
|
|
app.running,
|
|
app.plot_cursor,
|
|
app.animal_cursor,
|
|
app.mode,
|
|
app.engine.paused,
|
|
)
|
|
app._handle_key(key)
|
|
after = (
|
|
app.running,
|
|
app.plot_cursor,
|
|
app.animal_cursor,
|
|
app.mode,
|
|
app.engine.paused,
|
|
)
|
|
self.assertNotEqual(before, after, f"按键 {key!r} 没有任何反应")
|
|
|
|
def test_unbound_named_keys_are_ignored_without_crashing(self):
|
|
for key in (keys.ENTER, keys.HOME, keys.END, keys.DELETE, keys.BACKSPACE):
|
|
with self.subTest(key=key):
|
|
app = make_app()
|
|
app._handle_key(key)
|
|
self.assertEqual(app.plot_cursor, 0)
|
|
self.assertTrue(app.running)
|
|
|
|
def test_unknown_key_is_ignored(self):
|
|
self.app._handle_key("zzz")
|
|
self.assertEqual(self.app.plot_cursor, 0)
|
|
self.assertTrue(self.app.running)
|
|
|
|
|
|
class TuiLetterKeyBehaviourTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = make_app()
|
|
self.engine = self.app.engine
|
|
|
|
def test_p_opens_the_crop_menu_and_a_digit_plants(self):
|
|
self.app._handle_key("p")
|
|
self.assertEqual(self.app.mode, "select_crop")
|
|
self.assertTrue(self.app.menu)
|
|
self.app._handle_key("1")
|
|
self.assertEqual(self.app.mode, "main")
|
|
self.assertIsNotNone(self.engine.state.plots[0].crop)
|
|
|
|
def test_escape_closes_the_menu(self):
|
|
self.app._handle_key("p")
|
|
self.app._handle_key(keys.ESC)
|
|
self.assertEqual(self.app.mode, "main")
|
|
self.assertIsNone(self.engine.state.plots[0].crop)
|
|
|
|
def test_menu_rejects_a_locked_choice(self):
|
|
self.engine.state.level = 1
|
|
self.app._handle_key("p")
|
|
locked_index = next(
|
|
i for i, item in enumerate(self.app.menu, start=1) if item["key"] == "strawberry"
|
|
)
|
|
self.app._handle_key(str(locked_index))
|
|
self.assertIsNone(self.engine.state.plots[0].crop)
|
|
self.assertIn("尚未解锁", self.app.message)
|
|
|
|
def test_menu_ignores_out_of_range_choices(self):
|
|
self.app._handle_key("p")
|
|
self.app._handle_key("0")
|
|
self.assertIn("没有这个选项", self.app.message)
|
|
self.app._handle_key("9")
|
|
self.assertIn("没有这个选项", self.app.message)
|
|
|
|
def test_b_buys_an_animal_through_the_menu(self):
|
|
self.app._handle_key("b")
|
|
self.assertEqual(self.app.mode, "select_animal")
|
|
self.app._handle_key("1")
|
|
self.assertEqual(len(self.engine.state.animals), 1)
|
|
|
|
def test_w_waters_and_h_harvests_the_selected_plot(self):
|
|
self.engine.plant(1, "wheat")
|
|
self.engine.state.plots[0].moisture = 0
|
|
self.app._handle_key("w")
|
|
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY)
|
|
|
|
self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"]
|
|
gold = self.engine.state.gold
|
|
self.app._handle_key("h")
|
|
self.assertGreater(self.engine.state.gold, gold)
|
|
|
|
def test_e_harvests_everything_ripe(self):
|
|
for number in (1, 2, 3):
|
|
self.engine.plant(number, "wheat")
|
|
for index in (0, 1):
|
|
self.engine.state.plots[index].growth = 2
|
|
self.app._handle_key("e")
|
|
self.assertTrue(self.engine.state.plots[0].is_empty)
|
|
self.assertTrue(self.engine.state.plots[1].is_empty)
|
|
self.assertFalse(self.engine.state.plots[2].is_empty, "没熟的留着")
|
|
|
|
def test_e_without_anything_ripe_says_so(self):
|
|
self.app._handle_key("e")
|
|
self.assertIn("没有成熟", self.app.message)
|
|
|
|
def test_f_feeds_the_focused_animal(self):
|
|
self.engine.buy_animal("chicken")
|
|
self.app._handle_key("f")
|
|
self.assertTrue(self.engine.state.animals[0].fed)
|
|
|
|
def test_c_collects_the_focused_animal(self):
|
|
self.engine.buy_animal("chicken")
|
|
self.engine.state.animals[0].pending = 2
|
|
gold = self.engine.state.gold
|
|
self.app._handle_key("c")
|
|
self.assertGreater(self.engine.state.gold, gold)
|
|
|
|
def test_l_buys_land_and_k_buys_feed(self):
|
|
self.engine.state.level = 2
|
|
plots = len(self.engine.state.plots)
|
|
self.app._handle_key("l")
|
|
self.assertEqual(len(self.engine.state.plots), plots + 1)
|
|
|
|
feed = self.engine.state.feed
|
|
self.app._handle_key("k")
|
|
self.assertEqual(self.engine.state.feed, feed + config.FEED_BULK)
|
|
|
|
def test_s_saves_the_game(self):
|
|
self.app.autosave = False
|
|
self.app._handle_key("s")
|
|
self.assertTrue(os.path.exists(self.app.save_path))
|
|
|
|
def test_r_asks_before_resetting(self):
|
|
self.engine.state.day = 5
|
|
self.app._handle_key("r")
|
|
self.assertEqual(self.app.mode, "confirm_reset")
|
|
|
|
self.app._handle_key("n")
|
|
self.assertEqual(self.app.mode, "main")
|
|
self.assertEqual(self.engine.state.day, 5, "没确认就不该重置")
|
|
|
|
def test_r_then_y_resets(self):
|
|
self.engine.state.day = 5
|
|
self.app._handle_key("r")
|
|
self.app._handle_key("y")
|
|
self.assertEqual(self.engine.state.day, 1)
|
|
|
|
def test_question_mark_opens_help_and_any_key_closes_it(self):
|
|
self.app._handle_key("?")
|
|
self.assertEqual(self.app.mode, "help")
|
|
self.app._handle_key("x")
|
|
self.assertEqual(self.app.mode, "main")
|
|
|
|
def test_q_quits(self):
|
|
self.app._handle_key("q")
|
|
self.assertFalse(self.app.running)
|
|
|
|
def test_failed_action_shows_the_reason_instead_of_crashing(self):
|
|
self.app._handle_key("w") # 空地浇水
|
|
self.assertIn("空地", self.app.message)
|
|
self.assertTrue(self.app.running)
|
|
|
|
|
|
class TuiRenderTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = make_app()
|
|
# TuiApp 自己不驱动时钟,倒计时要靠 next_tick_at 才有值
|
|
self.app.engine.next_tick_at = time.time() + config.DEFAULT_TICK_SECONDS
|
|
|
|
def capture(self) -> str:
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
self.app._render()
|
|
return buffer.getvalue()
|
|
|
|
def test_first_frame_contains_every_panel(self):
|
|
output = self.render_plain()
|
|
for label in ("SimpleFarm", "农田", "牧场", "市场行情", "农事日志"):
|
|
with self.subTest(panel=label):
|
|
self.assertIn(label, output)
|
|
|
|
def render_plain(self) -> str:
|
|
return strip_ansi(self.capture())
|
|
|
|
def test_frame_fits_the_terminal_width(self):
|
|
output = strip_ansi(self.capture())
|
|
for line in output.splitlines():
|
|
with self.subTest(line=line[:20]):
|
|
self.assertLessEqual(display_width(line.rstrip("\r")), 200)
|
|
|
|
def test_countdown_uses_the_human_format(self):
|
|
output = self.render_plain()
|
|
self.assertNotIn("s \n", output)
|
|
self.assertRegex(output, r"\d+:\d{2}")
|
|
|
|
def test_paused_state_is_visible(self):
|
|
self.app.engine.pause(True)
|
|
self.assertIn("已暂停", self.render_plain())
|
|
|
|
def test_crop_state_is_visible(self):
|
|
self.app.engine.plant(1, "wheat")
|
|
self.assertIn("小麦", self.render_plain())
|
|
|
|
def test_market_panel_uses_the_china_convention(self):
|
|
"""涨红跌绿是中国习惯,别跟着欧美反过来。"""
|
|
tui_path = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "farm", "tui.py"
|
|
)
|
|
with open(tui_path, encoding="utf-8") as handle:
|
|
source = handle.read()
|
|
self.assertIn('trend, tone = "涨", C_RED', source)
|
|
self.assertIn('trend, tone = "跌", C_GREEN', source)
|
|
|
|
def test_help_page_is_current(self):
|
|
self.app._handle_key("?")
|
|
output = self.render_plain()
|
|
self.assertIn("分钟", output)
|
|
self.assertNotIn("[N]休息", output)
|
|
self.assertNotIn("15 秒", output)
|
|
|
|
def test_keyboard_notice_appears_when_input_is_unavailable(self):
|
|
self.app.reader.available = False
|
|
self.app.reader.reason = "标准输入不是终端(管道或重定向)"
|
|
self.app.show_keyboard_notice = True
|
|
self.assertIn("读不到按键", self.render_plain())
|
|
|
|
def test_keyboard_notice_can_be_suppressed_for_previews(self):
|
|
self.app.reader.available = False
|
|
self.app.show_keyboard_notice = False
|
|
output = self.render_plain()
|
|
self.assertNotIn("读不到按键", output)
|
|
self.assertIn("↑↓←→", output)
|
|
|
|
def test_menu_frame_renders_choices(self):
|
|
self.app._handle_key("p")
|
|
output = self.render_plain()
|
|
self.assertIn("选择要种在", output)
|
|
self.assertIn("小麦", output)
|
|
|
|
def test_auto_save_only_runs_when_enabled(self):
|
|
app = make_app(autosave=True)
|
|
app._exit()
|
|
self.assertTrue(os.path.exists(app.save_path))
|
|
|
|
|
|
class TuiSnapshotCompatibilityTestCase(unittest.TestCase):
|
|
"""TUI 要能同时吃本地引擎和远程客户端——两者的快照字段必须对齐。"""
|
|
|
|
def test_render_works_with_a_remote_shaped_snapshot(self):
|
|
from farm.client import StateView
|
|
|
|
app = make_app()
|
|
view = StateView(app.engine.snapshot())
|
|
app.engine = view # 用一个"长得像引擎"的对象
|
|
lines = app._render_status(view.to_dict(), 100)
|
|
rendered = strip_ansi("\n".join(lines))
|
|
self.assertIn("金币", rendered)
|
|
|
|
def test_log_entries_are_rendered(self):
|
|
app = make_app()
|
|
app.engine.next_tick_at = time.time() + config.DEFAULT_TICK_SECONDS
|
|
app.engine.add_log("测试事件", "good")
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
app._render()
|
|
self.assertIn("测试事件", strip_ansi(buffer.getvalue()))
|
|
|
|
def test_recent_messages_expire(self):
|
|
app = make_app()
|
|
app._flash("一闪而过", seconds=0.0)
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
app._render()
|
|
self.assertNotIn("一闪而过", strip_ansi(buffer.getvalue()))
|
|
|
|
def test_json_round_trip_of_the_snapshot_is_renderable(self):
|
|
"""远程客户端的快照是过了一遍 JSON 的,字段类型可能变。"""
|
|
self.app = make_app()
|
|
raw = json.loads(json.dumps(self.app.engine.snapshot(), ensure_ascii=False))
|
|
self.app._render_status(raw, 100)
|
|
self.app._render_farm(raw, 60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|