|
| 1 | +import ast |
| 2 | +import sys |
| 3 | +from pathlib import Path |
| 4 | +from types import ModuleType, SimpleNamespace |
| 5 | + |
| 6 | +import pytest |
| 7 | + |
| 8 | + |
| 9 | +ROOT = Path(__file__).resolve().parents[1] |
| 10 | +PACKAGE_ROOT = ROOT / "game" / "python-packages" |
| 11 | +if str(PACKAGE_ROOT) not in sys.path: |
| 12 | + sys.path.insert(0, str(PACKAGE_ROOT)) |
| 13 | + |
| 14 | +import maica_mtrigger |
| 15 | + |
| 16 | + |
| 17 | +def _builtin_trigger_runtime(monkeypatch, web_music=False): |
| 18 | + source_path = ROOT / "game" / "Submods" / "MAICA_ChatSubmod" / "trigger.rpy" |
| 19 | + source = source_path.read_text(encoding="utf-8").split( |
| 20 | + "init 999 python in maica:", 1 |
| 21 | + )[1] |
| 22 | + source = "\n".join( |
| 23 | + line[4:] if line.startswith(" ") else line for line in source.splitlines() |
| 24 | + ) |
| 25 | + tree = ast.parse(source, filename=str(source_path)) |
| 26 | + names = { |
| 27 | + "_reserved_mtrigger_item_error", |
| 28 | + "_add_mtrigger_item", |
| 29 | + "_valid_mtrigger_selection", |
| 30 | + "get_unlocked_games", |
| 31 | + "minigame_callback", |
| 32 | + "ClothesTrigger", |
| 33 | + "HairTrigger", |
| 34 | + "MinigameTrigger", |
| 35 | + "WeatherTrigger", |
| 36 | + "MusicTrigger", |
| 37 | + "AccessoryTrigger", |
| 38 | + } |
| 39 | + tree.body = [ |
| 40 | + node for node in tree.body |
| 41 | + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in names |
| 42 | + ] |
| 43 | + |
| 44 | + calls = [] |
| 45 | + invalid = [] |
| 46 | + store = ModuleType("store") |
| 47 | + store.__path__ = [] |
| 48 | + store.renpy = SimpleNamespace(call=lambda *args: calls.append(args)) |
| 49 | + store.mas_submod_utils = SimpleNamespace( |
| 50 | + isSubmodInstalled=lambda name: web_music and name == "Netease Music" |
| 51 | + ) |
| 52 | + store.songs = SimpleNamespace( |
| 53 | + music_choices=[("Song", "song.ogg"), ("__none__", "legacy.ogg")], |
| 54 | + getPlayingMusicName=lambda: "Song", |
| 55 | + ) |
| 56 | + store.mas_play_song = lambda song: calls.append(("play_song", song)) |
| 57 | + entry = lambda name: SimpleNamespace(display_name=name, unlocked=True) |
| 58 | + store.mas_selspr = SimpleNamespace( |
| 59 | + CLOTH_SEL_MAP={"outfit": entry("Outfit"), "legacy": entry("__none__")}, |
| 60 | + HAIR_SEL_MAP={"hair": entry("Hair"), "legacy": entry("__none__")}, |
| 61 | + ACS_SEL_MAP={"hat": entry("Hat"), "legacy": entry("__none__")}, |
| 62 | + ) |
| 63 | + worn_hat = SimpleNamespace(name="hat") |
| 64 | + store.monika_chr = SimpleNamespace( |
| 65 | + clothes=SimpleNamespace(name="outfit"), |
| 66 | + hair=SimpleNamespace(name="hair"), |
| 67 | + get_acs=lambda: [worn_hat, SimpleNamespace(name="legacy")], |
| 68 | + ) |
| 69 | + store.mas_games = SimpleNamespace(game_db={ |
| 70 | + "valid": SimpleNamespace(prompt="Chess", eventlabel="game_chess"), |
| 71 | + "legacy": SimpleNamespace(prompt="__none__", eventlabel="game_legacy"), |
| 72 | + }) |
| 73 | + store.mas_isGameUnlocked = lambda name: name != "Hangman" and name != "上吊小人" |
| 74 | + store.mas_isMoniAff = lambda higher: True |
| 75 | + sunny = SimpleNamespace(prompt="Sunny", unlocked=True) |
| 76 | + store.mas_weather_def = sunny |
| 77 | + store.mas_current_weather = sunny |
| 78 | + store.mas_weather = ModuleType("store.mas_weather") |
| 79 | + store.mas_weather.WEATHER_MAP = { |
| 80 | + "def": sunny, |
| 81 | + "legacy": SimpleNamespace(prompt="__none__", unlocked=True), |
| 82 | + "rain": SimpleNamespace(prompt="Rain", unlocked=True), |
| 83 | + } |
| 84 | + monkeypatch.setitem(sys.modules, "store", store) |
| 85 | + monkeypatch.setitem(sys.modules, "store.mas_weather", store.mas_weather) |
| 86 | + |
| 87 | + namespace = dict(vars(maica_mtrigger)) |
| 88 | + namespace.update({ |
| 89 | + "store": store, |
| 90 | + "_": lambda text: text, |
| 91 | + "log_invalid_mtrigger": lambda *args, **kwargs: invalid.append((args, kwargs)), |
| 92 | + }) |
| 93 | + exec(compile(tree, str(source_path), "exec"), namespace) |
| 94 | + namespace["unlocked_games_dict"] = namespace["get_unlocked_games"]() |
| 95 | + |
| 96 | + manager = maica_mtrigger.MTriggerManager() |
| 97 | + for name, args in ( |
| 98 | + ("ClothesTrigger", (maica_mtrigger.common_switch_template, "clothes")), |
| 99 | + ("HairTrigger", (maica_mtrigger.common_switch_template, "hair")), |
| 100 | + ("MinigameTrigger", ()), |
| 101 | + ("WeatherTrigger", ()), |
| 102 | + ("MusicTrigger", ()), |
| 103 | + ("AccessoryTrigger", ()), |
| 104 | + ): |
| 105 | + manager.add_trigger(namespace[name](*args)) |
| 106 | + return SimpleNamespace( |
| 107 | + manager=manager, store=store, calls=calls, invalid=invalid, worn_hat=worn_hat |
| 108 | + ) |
| 109 | + |
| 110 | + |
| 111 | +@pytest.fixture |
| 112 | +def runtime(monkeypatch): |
| 113 | + return _builtin_trigger_runtime(monkeypatch) |
| 114 | + |
| 115 | + |
| 116 | +def test_builtin_switches_exclude_reserved_names_from_built_payload(runtime): |
| 117 | + built = {item["name"]: item["exprop"]["item_list"] |
| 118 | + for item in runtime.manager.build_data()} |
| 119 | + |
| 120 | + assert set(built) == {"clothes", "hair", "minigame", "weather", "music", "accessory"} |
| 121 | + assert all("__none__" not in choices for choices in built.values()) |
| 122 | + assert "wear|__none__" not in built["accessory"] |
| 123 | + assert "unwear|__none__" not in built["accessory"] |
| 124 | + assert {"玩家挑选", "Outfit"} <= set(built["clothes"]) |
| 125 | + assert {"玩家挑选", "Hair"} <= set(built["hair"]) |
| 126 | + assert {"玩家自行选择", "Chess"} <= set(built["minigame"]) |
| 127 | + assert {"Sunny", "Rain"} <= set(built["weather"]) |
| 128 | + assert {"玩家自行选择", "Song", "停止/静音"} <= set(built["music"]) |
| 129 | + assert {"wear|玩家挑选", "wear|Hat", "unwear|Hat"} <= set(built["accessory"]) |
| 130 | + rejected = {args[0] for args, kwargs in runtime.invalid |
| 131 | + if args[1] == "__none__" and "reserved" in kwargs.get("reason", "")} |
| 132 | + assert rejected == set(built) |
| 133 | + |
| 134 | + |
| 135 | +@pytest.mark.parametrize("name,choice,expected", [ |
| 136 | + ("clothes", "__none__", ("mtrigger_change_clothes", "mas_pick_a_clothes")), |
| 137 | + ("clothes", "玩家挑选", ("mtrigger_change_clothes", "mas_pick_a_clothes")), |
| 138 | + ("hair", "__none__", ("mtrigger_change_hair", "mas_pick_a_clothes")), |
| 139 | + ("hair", "玩家挑选", ("mtrigger_change_hair", "mas_pick_a_clothes")), |
| 140 | + ("minigame", "__none__", ("mtrigger_minigame", "mas_pick_a_game")), |
| 141 | + ("minigame", "玩家自行选择", ("mtrigger_minigame", "mas_pick_a_game")), |
| 142 | + ("weather", "__none__", None), |
| 143 | + ("music", "__none__", None), |
| 144 | + ("accessory", "__none__", None), |
| 145 | + ("accessory", "wear|Hat", ("mtrigger_change_acs", "hat")), |
| 146 | +]) |
| 147 | +def test_legacy_and_readable_choices_keep_their_callback_behavior(runtime, name, choice, expected): |
| 148 | + runtime.manager.triggered(name, {"choice": choice}) |
| 149 | + runtime.manager.run_trigger() |
| 150 | + assert runtime.calls == ([] if expected is None else [expected]) |
| 151 | + |
| 152 | + |
| 153 | +@pytest.mark.parametrize("name", ["clothes", "hair", "minigame", "weather", "music", "accessory"]) |
| 154 | +def test_null_choice_does_not_perform_an_action(runtime, name): |
| 155 | + runtime.manager.triggered(name, {"choice": None}) |
| 156 | + runtime.manager.run_trigger() |
| 157 | + assert runtime.calls == [] |
| 158 | + |
| 159 | + |
| 160 | +def test_accessory_unwear_remains_available(runtime): |
| 161 | + runtime.manager.triggered("accessory", {"choice": "unwear|Hat"}) |
| 162 | + runtime.manager.run_trigger() |
| 163 | + assert runtime.calls == [("mtrigger_unwear_acs", runtime.worn_hat)] |
| 164 | + |
| 165 | + |
| 166 | +def test_empty_weather_is_skipped_and_returns_when_data_recovers(runtime): |
| 167 | + legacy = SimpleNamespace(prompt="__none__", unlocked=True) |
| 168 | + runtime.store.mas_weather_def = legacy |
| 169 | + runtime.store.mas_weather.WEATHER_MAP = {"legacy": legacy} |
| 170 | + |
| 171 | + assert "weather" not in {item["name"] for item in runtime.manager.build_data()} |
| 172 | + assert runtime.manager.trigger_status("weather") is True |
| 173 | + |
| 174 | + sunny = SimpleNamespace(prompt="Sunny", unlocked=True) |
| 175 | + runtime.store.mas_weather_def = sunny |
| 176 | + runtime.store.mas_weather.WEATHER_MAP = {"def": sunny} |
| 177 | + built = {item["name"]: item for item in runtime.manager.build_data()} |
| 178 | + assert built["weather"]["exprop"]["item_list"] == ["Sunny"] |
| 179 | + runtime.manager.triggered("weather", {"choice": "Sunny"}) |
| 180 | + runtime.manager.run_trigger() |
| 181 | + assert runtime.calls == [("mtrigger_weather", sunny)] |
| 182 | + |
| 183 | + |
| 184 | +@pytest.mark.parametrize("payload,expected_label,expected_selection", [ |
| 185 | + ({"choice": "Song", "suggestion": None}, "mtrigger_music_auto", "Song"), |
| 186 | + ({"choice": "Song"}, "mtrigger_music_auto", "Song"), |
| 187 | + ({"choice": "Song", "suggestion": "Online song"}, "mtrigger_neteasemusic_search", "Online song"), |
| 188 | + ({"choice": None, "suggestion": "Online song"}, "mtrigger_neteasemusic_search", "Online song"), |
| 189 | + ({"choice": None, "suggestion": None}, None, None), |
| 190 | + ({"choice": "__none__", "suggestion": None}, None, None), |
| 191 | +]) |
| 192 | +def test_web_music_uses_choice_when_suggestion_is_null( |
| 193 | + monkeypatch, payload, expected_label, expected_selection |
| 194 | +): |
| 195 | + runtime = _builtin_trigger_runtime(monkeypatch, web_music=True) |
| 196 | + built = {item["name"]: item for item in runtime.manager.build_data()} |
| 197 | + assert built["music"]["exprop"]["suggestion"] is True |
| 198 | + runtime.manager.triggered("music", payload) |
| 199 | + runtime.manager.run_trigger() |
| 200 | + |
| 201 | + if expected_label is None: |
| 202 | + assert runtime.calls == [] |
| 203 | + else: |
| 204 | + assert len(runtime.calls) == 1 |
| 205 | + assert runtime.calls[0][0] == expected_label |
| 206 | + assert runtime.calls[0][-1] == expected_selection |
| 207 | + |
| 208 | + |
| 209 | +@pytest.mark.parametrize("prefer_suggestion,payload,expected", [ |
| 210 | + (True, {"choice": "Song", "suggestion": None}, "Song"), |
| 211 | + (True, {"choice": "Song", "suggestion": ""}, ""), |
| 212 | + (True, {"choice": "Song", "suggestion": False}, False), |
| 213 | + (False, {"choice": "Song", "suggestion": "Other"}, "Song"), |
| 214 | + (False, {"choice": None, "suggestion": "Other"}, "Other"), |
| 215 | +]) |
| 216 | +def test_suggestion_priority_preserves_non_null_values(prefer_suggestion, payload, expected): |
| 217 | + values = [] |
| 218 | + trigger = maica_mtrigger.MTriggerBase( |
| 219 | + maica_mtrigger.common_switch_template, |
| 220 | + "switch", |
| 221 | + callback=values.append, |
| 222 | + exprop=maica_mtrigger.MTriggerExprop( |
| 223 | + item_name_zh="music", item_list=["Song"], suggestion=True |
| 224 | + ), |
| 225 | + perf_suggestion=prefer_suggestion, |
| 226 | + ) |
| 227 | + trigger.triggered(payload) |
| 228 | + assert values == [expected] |
0 commit comments