"""键盘与 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)