"""Main-menu regressions without speech, GUI, audio, or configuration services. Run from the library root: python -m unittest discover -s tests -v """ import importlib.util from pathlib import Path import sys from types import ModuleType, SimpleNamespace import unittest from unittest.mock import Mock, patch import pygame def load_menu(): """Load the real menu code with isolated framework dependencies.""" packageName = "_menu_test_library" package = ModuleType(packageName) package.__path__ = [str(Path(__file__).resolve().parents[1])] modules = {packageName: package} exports = { "speech": ["messagebox", "Speech"], "sound": ["adjust_master_volume", "adjust_bgm_volume", "adjust_sfx_volume", "play_bgm"], "display": ["display_text"], "scoreboard": ["Scoreboard"], "services": ["PathService", "ConfigService"], } for moduleName, names in exports.items(): dependency = ModuleType(f"{packageName}.{moduleName}") for name in names: setattr(dependency, name, Mock()) modules[dependency.__name__] = dependency spec = importlib.util.spec_from_file_location(f"{packageName}.menu", Path(package.__path__[0]) / "menu.py") menu = importlib.util.module_from_spec(spec) with patch.dict(sys.modules, modules): spec.loader.exec_module(menu) return menu class MenuTests(unittest.TestCase): def make_menu(self, keyCodes, highScores=True, files=True): menu = load_menu() menu.Scoreboard.has_high_scores.return_value = highScores menu.pygame = Mock() for name in dir(pygame): if name.startswith("K_") or name in ["KEYDOWN", "QUIT", "KMOD_ALT"]: setattr(menu.pygame, name, getattr(pygame, name)) menu.pygame.key.get_mods.return_value = 0 menu.pygame.mixer.music.get_busy.return_value = True menu.pygame.event.wait.side_effect = [ SimpleNamespace(type=pygame.KEYDOWN, key=keyCode) for keyCode in keyCodes ] menu.time = Mock() pathPatch = patch.object(menu.os.path, "isfile", return_value=files) pathPatch.start() self.addCleanup(pathPatch.stop) return menu def spoken_labels(self, menu): return [call.args[0] for call in menu.Speech.get_instance.return_value.speak.call_args_list] def test_custom_action_is_spoken_readably_and_returned_unchanged(self): menu = self.make_menu([pygame.K_DOWN, pygame.K_DOWN, pygame.K_RETURN]) result = menu.game_menu({}, None, "load_game") self.assertEqual(result, "load_game") self.assertEqual(self.spoken_labels(menu), ["play", "high scores", "load game"]) def test_standard_actions_still_dispatch_to_their_handlers(self): options = ["play", "high_scores", "load_game", "learn_sounds", "instructions", "credits", "donate", "exit_game"] for actionName in ["high_scores", "learn_sounds", "instructions", "credits", "donate"]: with self.subTest(actionName=actionName): keyCodes = [pygame.K_DOWN] * options.index(actionName) + [pygame.K_RETURN, pygame.K_HOME, pygame.K_RETURN] menu = self.make_menu(keyCodes) for handlerName in ["learn_sounds", "instructions", "credits", "donate"]: setattr(menu, handlerName, Mock()) sounds = {} self.assertEqual(menu.game_menu(sounds, None, "load_game"), "play") self.assertIn(actionName.replace("_", " "), self.spoken_labels(menu)) if actionName == "high_scores": menu.Scoreboard.display_high_scores.assert_called_once_with() elif actionName == "learn_sounds": menu.learn_sounds.assert_called_once_with(sounds) else: getattr(menu, actionName).assert_called_once_with() def test_navigation_home_end_and_arrows_keep_the_same_order(self): menu = self.make_menu([pygame.K_DOWN, pygame.K_DOWN, pygame.K_UP, pygame.K_END, pygame.K_HOME, pygame.K_RETURN]) self.assertEqual(menu.game_menu({}, None, "load_game"), "play") self.assertEqual(self.spoken_labels(menu), ["play", "high scores", "load game", "high scores", "exit game", "play"]) def test_exit_action_and_escape_keep_exit_behavior(self): for keyCodes in [[pygame.K_END, pygame.K_RETURN], [pygame.K_ESCAPE]]: with self.subTest(keyCodes=keyCodes): menu = self.make_menu(keyCodes) menu.exit_game = Mock(side_effect=SystemExit) with self.assertRaises(SystemExit): menu.game_menu({}) menu.exit_game.assert_called_once_with(500) if keyCodes[0] == pygame.K_END: self.assertIn("exit game", self.spoken_labels(menu)) def test_play_callback_and_custom_return_keep_original_contract(self): menu = self.make_menu([pygame.K_RETURN, pygame.K_DOWN, pygame.K_RETURN], highScores=False, files=False) playCallback = Mock() self.assertEqual(menu.game_menu({}, playCallback, "exit_level"), "exit_level") playCallback.assert_called_once_with() self.assertIn("exit level", self.spoken_labels(menu)) def test_custom_labels_keep_existing_case_and_punctuation(self): menu = self.make_menu([pygame.K_DOWN, pygame.K_RETURN], highScores=False, files=False) label = "About My Game!" self.assertEqual(menu.game_menu({}, None, label), label) self.assertEqual(self.spoken_labels(menu), ["play", label]) def test_generic_instruction_menu_does_not_rewrite_option_text(self): menu = self.make_menu([pygame.K_DOWN, pygame.K_RETURN]) self.assertEqual(menu.instruction_menu({}, "Select a file:", "example_file.json"), "example_file.json") self.assertEqual(self.spoken_labels(menu), ["Select a file:", "example_file.json"]) if __name__ == "__main__": unittest.main()