`saves/farm.db` 一张 farms 表,farm_id 主键,**一个牧场一行**。 接口签名不变(save_state / load_state / delete_save 只多了 farm_id 参数), 调用方只改了路径语义。 设计取舍:标量列 + 整份 payload。 * day/gold/level/ap/weather/forecast/net_worth 单独成列——外部工具或 AI 一条 SELECT 就能排榜、画曲线,不必先把每份存档读进内存; * 完整状态仍整份存 payload JSON:游戏数据隔三差五长新字段,规范化成 几十张表意味着每加字段写一次迁移,而存档只做整体读、整体写, 所以结构化列管可查询,完整性交给 JSON——加字段不用改表结构。 崩溃安全升级:写入包在事务里。原来的"临时文件 + os.replace"能挡进程 崩溃,挡不住写到一半掉电;SQLite 的日志能。读取失败照旧降级开新档。 向后兼容:SAVE_VERSION=2 / MIN_SAVE_VERSION=1 不变。老 JSON 存档由 migrate_json_saves() 在服务器启动时自动导入:同名已存在就跳过(宁可不 迁不覆盖),导入成功原文件改名 *.json.migrated 留底,先写库再改名 保证幂等;坏档一律跳过——迁移不许有能力阻止服务器启动。 异常边界:StorageError = sqlite3.Error,刻意不是 OSError 子类—— 调用方原来的 except OSError 全部跟着改,否则写盘失败会穿透出去。 性能实测:PRAGMA journal_mode=WAL 是持久属性,建库时设一次就够, 每次连接重设白花 2.2ms;save_state 约 6.9ms/次,大头是 synchronous=FULL 的 fsync——不省,存档的价值就是不丢档。bench 热路径无回归。 顺手修一个真 bug:connect() 建表失败时连接会泄漏,Windows 上句柄没关 就删不掉文件,而调用方正是靠"读失败→开新档"容错的。补 close。 测试:StorageTestCase 重写(往返/版本/损坏降级/多牧场隔离/迁移), test_server 断言 *.json 的用例改查库。513 项全过,ruff/pyright 全绿。 端到端:预置 v1/v2 老档起真服务器验 22 项——自动迁移、字段还原、 list_farms() 排榜、重启后进度分毫不差,全过。
658 lines
25 KiB
Python
658 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, "farm.db"),
|
|
"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"]
|
|
before = self.engine.state.inventory.get("wheat", 0)
|
|
self.app._handle_key("h")
|
|
self.assertGreater(self.engine.state.inventory.get("wheat", 0), before, "收获进谷仓")
|
|
|
|
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
|
|
before = self.engine.state.inventory.get("animal:chicken", 0)
|
|
self.app._handle_key("c")
|
|
self.assertGreater(
|
|
self.engine.state.inventory.get("animal:chicken", 0), before, "产物进谷仓"
|
|
)
|
|
|
|
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)
|