diff --git a/README.md b/README.md index 5cff6f8..be37b2b 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ currently enabled plugins (except `bsipa`) and `e` to enable all currently unchecked plugins. With a single Beat Saber installation, the menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed. +Use `s` to save the set of currently enabled plugins as that instance's +known-good set, and `g` to restore it later: plugins outside the saved set are +disabled and any saved plugins that are currently disabled/missing are +re-enabled from local assets. This is handy after a newly added mod pulls in +extra dependencies you don't actually want — press `g` to snap back to +the combination you know works. Only one known-good set is kept per instance; +saving again overwrites it. The equivalent automation commands are +`save-known-good` and `restore-known-good`. + The individual subcommands are mostly for automation and debugging. If you use them, pass `--state-dir` directly only when you intentionally want to override the configured state directory for one command. diff --git a/docs/SMOKETEST.md b/docs/SMOKETEST.md index 70076a3..3310539 100644 --- a/docs/SMOKETEST.md +++ b/docs/SMOKETEST.md @@ -88,6 +88,13 @@ For a batch install, check for: - there are no repeated unhandled exceptions from newly installed plugins. - the game reaches the main menu. +Several `ERROR` and `WARNING` lines are benign on a healthy 1.44.1 stack (older +manifest warnings, BeatLeader optional-interop probes, brief +`AudioTimeSyncController` resume noise, custom-map `Leaderboard id ... not +found`, transient KeyRemapper controller reconnects, and similar). See +[`docs/notes/install-and-verify-plugins-1.44.1.md`](notes/install-and-verify-plugins-1.44.1.md#known-ipa-log-noise) +before treating every red log line as a plugin failure. + After the first successful launch, `plugin-helper bootstrap-check --instance ` should pass. Ordinary plugin plans for lockfiles that include BSIPA depend on that recorded bootstrap state and the latest IPA log. diff --git a/docs/notes/install-and-verify-plugins-1.44.1.md b/docs/notes/install-and-verify-plugins-1.44.1.md index 8e67cb7..16a9943 100644 --- a/docs/notes/install-and-verify-plugins-1.44.1.md +++ b/docs/notes/install-and-verify-plugins-1.44.1.md @@ -83,6 +83,44 @@ Suggested log checks: - custom songs still enumerate - playlist and downloader UI still opens when relevant +Treat the patterns in [Known IPA log noise](#known-ipa-log-noise) as non-blocking +unless they appear together with a startup failure, missing plugin load line, or +broken in-game behavior. + +## Known IPA Log Noise + +These messages show up in healthy 1.44.1 Windows VR sessions and do not, by +themselves, block continuing the install pass. They were confirmed on +`Logs/_latest.log` from 2026-07-05 after FailButton and the current gameplay +stack loaded successfully. + +| Pattern | Example | Why it is usually harmless | +| --- | --- | --- | +| Older manifest game version | `[IPA/Loader] Mod developed for game version 1.42.0, so it may not work properly.` | IPA warns when a plugin manifest predates 1.44.1. Common across the stack; only actionable if the plugin misbehaves in VR. | +| BeatLeader optional interop missing | `[BeatLeader] Plugin ScoreSaber not found, ScoreSaberInterop will not be loaded` | BeatLeader probes for optional companion mods. Expected when those mods are not installed. | +| ScoreSaber Harmony stub when ScoreSaber absent | `[Harmony] Could not find type named ScoreSaber.Core.ReplaySystem.HarmonyPatches.PatchHandleHMDUnmounted` | Harmless when ScoreSaber is not in the instance. | +| Vanilla custom-map leaderboard lookup | `[UnityEngine] Leaderboard id for custom_level_ not found` | Unity/official leaderboard plumbing on custom songs. BeatLeader API calls can still succeed in the same session. | +| Brief audio resume desync | `[UnityEngine] AudioTimeSyncController: audio should be playing ... isPlaying=False` | Short burst when resuming from pause or squat gate; not the same class of failure as HitsoundTweaks' `InvalidProgramException` on an affinity patch. | +| Transient XR controller drop | `[KeyRemapper/UnityXRInputManager] Left controller not found.` | Headset/controller reconnect noise. Controllers typically reappear a line or two later. | +| SiraUtil duplicate expose | `[SiraUtil] Could not expose NoteCutSoundEffectManager. It is already binded.` | Seen when multiple installers touch the same gameplay binding. | +| BSML duplicate type handlers | `[BeatSaberMarkupLanguage] Registering type handler BeatLeader.UI.BSML_Addons...` | BeatLeader registers BSML extensions that overlap core handlers. Cosmetic warning only so far. | +| BeatLeader SteamVR console | `[BeatLeader] SteamVR console connection failed: Unable to connect to the remote server` | Optional SteamVR console integration; unrelated to BeatLeader login/API traffic. | +| SongCore WIP cache folder | `[SongCore] Folder: '...CustomWIPLevels\Cache' is missing Info.dat file!` | Empty or placeholder WIP cache directory. | +| Settings migration noise | `[SettingsIO] Decode: Unknown property 'quality.depth_texture'` | Stale graphics setting key in saved settings. | +| DLC promo assets | `[DlcPromoPanelModel] No PromoPanel assets discovered` | Normal when promo panel content is absent. | +| SquatToBegin lifecycle notice | `[IPA/Loader] Plugin SquatToBegin has no methods marked [OnStart] or [OnEnable]` | Upstream plugin shape; gate still arms from gameplay scene setup. | +| IPA feature deprecation | `[IPA/Loader/Features] Encountered old features used` / `No such feature SiraLocalizer.LocalizedPlugin` | Loader metadata drift; not a load failure. | + +Still treat these as real problems when investigating a failed smoketest: + +- `Mono.Cecil.AssemblyResolutionException`, `TypeLoadException`, or missing + assembly errors for an installed plugin +- Harmony `Patching exception` or repeated `NullReferenceException` stacks from + a newly installed plugin (LeaderboardCore `PanelView_SetIsLoaded` is a known + pre-existing warning on this stack; revisit only if in-game leaderboards break) +- Plugin absent from the IPA load list after install +- Startup never reaches `[MainSystemInit] Initialization was completed` + ## Status Legend - todo: not attempted @@ -170,7 +208,7 @@ stable. | Plugin | Upstream | Status | Source/version | Verification notes | | --- | --- | --- | --- | --- | -| ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | verified with warning | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loaded ScoreSaber 3.3.27 and synchronized its clock. Warning: FPFC/no-VR smoke logged `openxr_loader not found` while trying to get HMD info. | +| ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | verified with warning | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loads 3.3.27, but auth fails on 1.44.1: `/api/v2/game/auth` returns 403 `Official build does not match upload version hash` because upstream only registers upload-trust hashes through 1.44.0. | | BeatLeader + LeaderboardCore | [github](https://github.com/BeatLeader/beatleader-mod) | verified with warning | GitHub `BeatLeader/beatleader-mod` tag `v0.10.0`, asset `BeatLeader-0.10.0-bs1.42+.zip`; GitHub release digest matched downloaded asset; archive bundles `Plugins/LeaderboardCore.dll`, superseding the standalone BeatMods CDN trial | IPA loaded BeatLeader 0.10.0 and bundled LeaderboardCore 1.7.0, made BeatLeader API requests, and patched 13 ScoreSaber installers. Warnings: optional interop plugins missing; FPFC/no-VR smoke logged OpenXR session not running; bundled LeaderboardCore logged a Harmony patch error for `PanelView_SetIsLoaded`. | | AccSaber | [github](https://github.com/not-dexter/accsaber-reloaded-plugin) | failed compatibility trial | GitHub `not-dexter/accsaber-reloaded-plugin` tag `v1.1.3`, asset `1.40.8.zip`; GitHub release digest matched downloaded asset; latest release has no 1.44.x asset | IPA loaded AccSaber 1.1.3, but the smoke log hit `TypeLoadException: Invalid type AccSaber.Managers.AccSaberStore` for `AccSaberMissionScreen`. Removed from the live instance after the failed trial. | | SongRankedBadge | [github](https://github.com/qe201020335/SongRankedBadge) | verified with warning | GitHub `qe201020335/SongRankedBadge` tag `v1.0.6`, asset `SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip`; BeatMods version id 2267, zipHash `c6944b8a4b00b0c0bb1d44f273b3bb18` | IPA loaded SongRankedBadge 1.0.6, resolved SongDetailsCache, loaded song details, and initialized. Warning: manifest targets Beat Saber 1.40.0. | diff --git a/registry/plugins/scoresaber.toml b/registry/plugins/scoresaber.toml index 49ef53d..4e1790e 100644 --- a/registry/plugins/scoresaber.toml +++ b/registry/plugins/scoresaber.toml @@ -8,5 +8,6 @@ dependencies = [ { id = "beatsabermarkuplanguage", constraint = ">=1.14.1" }, { id = "sirautil", constraint = ">=3.3.1" }, { id = "songcore", constraint = ">=3.16.0" }, + { id = "protobuf-net", constraint = ">=3.0.102" }, { id = "bsipa", constraint = ">=4.3.7" }, ] diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index e22cc25..9b4544c 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -14,7 +14,7 @@ from .github import fetch_releases from .installer import apply_plan, disable_plugin, uninstall_plugin from .instances import get_instance, list_instances from .models import load_lockfile, load_registry -from .operations import enable_disabled_plugin +from .operations import enable_disabled_plugin, restore_known_good_set, save_known_good_set from .planner import create_plan from .reports import installed_plugins_report, print_installed_plugins from .scanner import scan_instance @@ -197,6 +197,24 @@ def build_parser() -> argparse.ArgumentParser: enable.add_argument("--lockfile") enable.add_argument("plugin") + save_known_good = subcommands.add_parser( + "save-known-good", + help="Record the currently enabled plugins as the known-good set for this instance", + parents=[_common_parent()], + ) + save_known_good.add_argument("--instance", required=True) + save_known_good.add_argument("--json", action="store_true", help="Print full JSON output") + + restore_known_good = subcommands.add_parser( + "restore-known-good", + help="Disable plugins outside the saved known-good set and re-enable any that are missing", + parents=[_common_parent()], + ) + restore_known_good.add_argument("--instance", required=True) + restore_known_good.add_argument("--registry", default="registry/plugins") + restore_known_good.add_argument("--lockfile") + restore_known_good.add_argument("--json", action="store_true", help="Print full JSON output") + backup = subcommands.add_parser( "backup-userdata", help="Copy UserData and Windows AppData into the adjacent backups repo", @@ -520,6 +538,38 @@ def run(argv: list[str] | None = None) -> int: print(f"State: {result['statePath']}") return 0 + if args.command == "save-known-good": + result = save_known_good_set(instance=args.instance, state_root=st_root) + if args.json: + _json(result) + else: + print(f"Saved known-good set for {args.instance}: {len(result['pluginIds'])} enabled plugins.") + for plugin_id in result["pluginIds"]: + print(f" {plugin_id}") + return 0 + + if args.command == "restore-known-good": + instance = get_instance(inst_roots, args.instance) + progress = (lambda message: print(f" {message}", flush=True)) + result = restore_known_good_set( + instance=args.instance, + instance_path=instance.path, + state_root=st_root, + registry=args.registry, + lockfile=args.lockfile, + progress=progress, + ) + if args.json: + _json(result) + else: + print(f"Disabled: {len(result['disabled'])}") + print(f"Enabled: {len(result['enabled'])}") + for item in result["disableErrors"]: + print(f" disable error {item['plugin']}: {item['error']}") + for item in result["enableErrors"]: + print(f" enable error {item['plugin']}: {item['error']}") + return 2 if result["disableErrors"] or result["enableErrors"] else 0 + if args.command == "backup-userdata": instance = get_instance(inst_roots, args.instance) root = repo_root() diff --git a/src/plugin_helper/operations.py b/src/plugin_helper/operations.py index 0969019..ae28d8c 100644 --- a/src/plugin_helper/operations.py +++ b/src/plugin_helper/operations.py @@ -6,10 +6,10 @@ from typing import Any from .bootstrap import ensure_healthy_bootstrap from .config import repo_root -from .installer import apply_plan +from .installer import apply_plan, disable_plugin from .models import Lockfile, Registry, load_lockfile, load_registry from .planner import create_plan -from .state import load_installed_state +from .state import load_installed_state, load_known_good_state, save_known_good_state def _resolve_paths( @@ -161,3 +161,72 @@ def enable_disabled_plugins( enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)}) return {"enabled": enabled, "errors": errors} + + +def save_known_good_set(*, instance: str, state_root: Path) -> dict[str, Any]: + """Record the currently enabled plugin ids as the known-good set for this instance.""" + installed_state = load_installed_state(state_root, instance) + plugin_ids = sorted(installed_state.get("plugins", {})) + known_good = { + "instance": instance, + "beatSaberVersion": installed_state.get("beatSaberVersion"), + "pluginIds": plugin_ids, + } + save_known_good_state(state_root, instance, known_good) + return known_good + + +def restore_known_good_set( + *, + instance: str, + instance_path: Path, + state_root: Path, + registry: str = "registry/plugins", + lockfile: str | None = None, + repo: Path | None = None, + progress: Callable[[str], None] | None = None, +) -> dict[str, Any]: + """Disable plugins outside the saved known-good set and re-enable the ones that are missing.""" + known_good = load_known_good_state(state_root, instance) + known_good_ids = set(known_good.get("pluginIds", [])) + if not known_good: + raise KeyError(f"no known-good set is saved for this instance: {instance}") + + installed_state = load_installed_state(state_root, instance) + currently_enabled = set(installed_state.get("plugins", {})) + + to_disable = sorted(currently_enabled - known_good_ids) + disabled: list[str] = [] + disable_errors: list[dict[str, str]] = [] + for plugin_id in to_disable: + try: + result = disable_plugin(instance, instance_path, state_root, plugin_id, False) + if result["stateUpdated"]: + disabled.append(plugin_id) + else: + skipped = "; ".join(f"{item['path']} {item['reason']}" for item in result["skipped"]) + disable_errors.append({"plugin": plugin_id, "error": skipped or "no files changed"}) + except Exception as exc: + disable_errors.append({"plugin": plugin_id, "error": str(exc)}) + + installed_state = load_installed_state(state_root, instance) + currently_enabled = set(installed_state.get("plugins", {})) + to_enable = sorted(known_good_ids - currently_enabled) + enable_result = enable_disabled_plugins( + instance=instance, + instance_path=instance_path, + state_root=state_root, + plugin_ids=to_enable, + registry=registry, + lockfile=lockfile, + repo=repo, + progress=progress, + ) + + return { + "knownGood": known_good, + "disabled": disabled, + "disableErrors": disable_errors, + "enabled": [item["plugin"] for item in enable_result["enabled"]], + "enableErrors": enable_result["errors"], + } diff --git a/src/plugin_helper/state.py b/src/plugin_helper/state.py index b08bb01..2bcab81 100644 --- a/src/plugin_helper/state.py +++ b/src/plugin_helper/state.py @@ -64,3 +64,17 @@ def backups_dir(state_root: Path, instance: str) -> Path: path = instance_state_dir(state_root, instance) / "backups" path.mkdir(parents=True, exist_ok=True) return path + + +def known_good_state_path(state_root: Path, instance: str) -> Path: + return instance_state_dir(state_root, instance) / "known-good.json" + + +def load_known_good_state(state_root: Path, instance: str) -> dict[str, Any]: + return read_json(known_good_state_path(state_root, instance), {}) + + +def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: + state.setdefault("instance", instance) + state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + atomic_write_json(known_good_state_path(state_root, instance), state) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index d491e81..085a4d9 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -13,9 +13,14 @@ from textual.widgets import DataTable, Footer, Header, Static from .bsipa import BSIPA_PLUGIN_ID from .installer import disable_plugin from .models import load_lockfile, load_registry -from .operations import enable_disabled_plugin, enable_disabled_plugins +from .operations import ( + enable_disabled_plugin, + enable_disabled_plugins, + restore_known_good_set, + save_known_good_set, +) from .reports import installed_plugins_report -from .state import load_installed_state +from .state import load_installed_state, load_known_good_state @dataclass(frozen=True) @@ -44,6 +49,8 @@ class PluginHelperTui(App[int]): Binding("space", "toggle_plugin", "Toggle", priority=True), Binding("d", "disable_all_plugins", "Disable all", priority=True), Binding("e", "enable_all_plugins", "Enable all", priority=True), + Binding("s", "save_known_good", "Save known-good", priority=True), + Binding("g", "restore_known_good", "Restore known-good", priority=True), Binding("r", "refresh", "Refresh"), Binding("b", "back", "Back"), Binding("q", "quit", "Quit"), @@ -229,6 +236,53 @@ class PluginHelperTui(App[int]): self._set_bulk_status("Enabled", changed, errors) self._show_plugins(preserve_status=True) + def action_save_known_good(self) -> None: + if self._busy or self.mode != "plugins" or self.selected_installation is None: + return + target = self.selected_installation + known_good = save_known_good_set(instance=target.instance_name, state_root=target.state_root) + count = len(known_good["pluginIds"]) + self._set_status(f"Saved known-good set: {count} enabled plugins.") + + async def action_restore_known_good(self) -> None: + if self._busy or self.mode != "plugins" or self.selected_installation is None: + return + target = self.selected_installation + if not load_known_good_state(target.state_root, target.instance_name): + self._set_status("No known-good set saved yet. Press s to save the current selection.") + return + self._busy = True + self._set_status("Restoring known-good set...") + try: + result = await asyncio.to_thread( + restore_known_good_set, + instance=target.instance_name, + instance_path=target.instance_path, + state_root=target.state_root, + repo=self.repo_root, + progress=self._operation_progress, + ) + except Exception as exc: + self._set_status(f"Could not restore known-good set: {exc}") + return + finally: + self._busy = False + errors = [f"{item['plugin']}: {item['error']}" for item in result["disableErrors"]] + [ + f"{item['plugin']}: {item['error']}" for item in result["enableErrors"] + ] + if errors: + preview = "; ".join(errors[:3]) + suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else "" + self._set_status( + f"Restored known-good set: disabled {len(result['disabled'])}, " + f"enabled {len(result['enabled'])}; {len(errors)} failed: {preview}{suffix}" + ) + else: + self._set_status( + f"Restored known-good set: disabled {len(result['disabled'])}, enabled {len(result['enabled'])}." + ) + self._show_plugins(preserve_status=True) + def _operation_progress(self, message: str) -> None: self.call_from_thread(self._set_status, message) @@ -294,7 +348,8 @@ class PluginHelperTui(App[int]): if self.plugin_rows: back_hint = "" if len(self.choices) == 1 else " b returns to installations." self._set_status( - f"Space toggles selected. d disables all. e enables all.{back_hint}" + "Space toggles selected. d disables all. e enables all. " + f"s saves known-good. g restores known-good.{back_hint}" ) else: self._set_status("No version-locked plugins for this installation.") diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index a0cb670..6c5111a 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -861,6 +861,97 @@ sha256 = "{sha256_file(asset)}" self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated["disabledPlugins"]) + def test_save_and_restore_known_good_set(self) -> None: + from plugin_helper.operations import restore_known_good_set, save_known_good_set + from plugin_helper.state import load_known_good_state, save_installed_state + + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance_root = work / "instances" + instance = instance_root / "1.40.8" + state = work / "state" + registry_dir = work / "registry" + locks_dir = work / "locks" + registry_dir.mkdir() + locks_dir.mkdir() + instance.mkdir(parents=True) + (instance / "Beat Saber_Data").mkdir() + (instance / "Plugins").mkdir() + + plugins_state: dict[str, dict] = {} + registry_entries: list[str] = [] + lock_entries: list[str] = [] + for plugin_id, name, filename in ( + ("alpha", "Alpha", "Alpha.dll"), + ("beta", "Beta", "Beta.dll"), + ("gamma", "Gamma", "Gamma.dll"), + ): + asset = plugin_downloads_dir(state, "1.40.8", plugin_id) / filename + asset.write_bytes(f"{plugin_id} dll".encode()) + registry_entries.append( + f""" +[[plugins]] +id = "{plugin_id}" +name = "{name}" +repo = "owner/{plugin_id}" +asset_patterns = ["*.dll"] +install_strategy = "dll-to-plugins" +""".lstrip() + ) + lock_entries.append( + f""" +[[plugins]] +id = "{plugin_id}" +repo = "owner/{plugin_id}" +tag = "v1.0.0" +asset = "{filename}" +sha256 = "{sha256_file(asset)}" +""".lstrip() + ) + if plugin_id in ("alpha", "beta"): + (instance / "Plugins" / filename).write_bytes(f"{plugin_id} dll".encode()) + plugins_state[plugin_id] = { + "installedAt": "2026-06-14T17:18:40Z", + "files": [{"path": f"Plugins/{filename}", "sha256": sha256_file(asset), "size": asset.stat().st_size}], + } + + (registry_dir / "plugins.toml").write_text("".join(registry_entries), encoding="utf-8") + (locks_dir / "1.40.8.lock.toml").write_text( + f""" +beat_saber_version = "1.40.8" +instance = "1.40.8" +{"".join(lock_entries)} +""".lstrip(), + encoding="utf-8", + ) + save_installed_state(state, "1.40.8", {"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}}) + + known_good = save_known_good_set(instance="1.40.8", state_root=state) + self.assertEqual(known_good["pluginIds"], ["alpha", "beta"]) + self.assertEqual(load_known_good_state(state, "1.40.8")["pluginIds"], ["alpha", "beta"]) + + # Simulate the user disabling alpha and a new mod pulling in gamma as a dependency. + disable_plugin("1.40.8", instance, state, "alpha", False) + with patch("plugin_helper.operations.repo_root", return_value=work): + enable_disabled_plugin( + instance="1.40.8", + instance_path=instance, + state_root=state, + plugin_id="gamma", + repo=work, + ) + + result = restore_known_good_set(instance="1.40.8", instance_path=instance, state_root=state, repo=work) + + self.assertEqual(result["disabled"], ["gamma"]) + self.assertEqual(result["enabled"], ["alpha"]) + self.assertEqual(result["disableErrors"], []) + self.assertEqual(result["enableErrors"], []) + + updated = load_installed_state(state, "1.40.8") + self.assertEqual(set(updated["plugins"]), {"alpha", "beta"}) + self.assertIn("gamma", updated["disabledPlugins"]) + def test_zip_to_pending_targets_ipa_pending(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) @@ -1892,6 +1983,51 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): self.assertNotIn("example", updated["disabledPlugins"]) self.assertIn("Enabled 1 plugins", app.status_message) + async def test_save_known_good_records_currently_enabled_plugins(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, _instance, state = _make_two_plugin_tui_fixture(Path(tmp)) + + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + + from plugin_helper.state import load_known_good_state + + known_good = load_known_good_state(state, "1.40.8") + self.assertEqual(known_good["pluginIds"], ["alpha", "beta"]) + self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message) + + async def test_restore_known_good_reverts_manual_changes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_two_plugin_tui_fixture(Path(tmp)) + + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + # Manually disable beta after saving the known-good set. + await pilot.press("down") + await pilot.press("space") + await pilot.pause() + self.assertFalse((instance / "Plugins" / "Beta.dll").exists()) + + await pilot.press("g") + await pilot.pause() + self.assertIn("Restored known-good set", app.status_message) + + self.assertTrue((instance / "Plugins" / "Beta.dll").exists()) + updated = load_installed_state(state, "1.40.8") + self.assertEqual(set(updated["plugins"]), {"alpha", "beta"}) + + async def test_restore_known_good_without_saved_set_reports_status(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp)) + + async with app.run_test() as pilot: + await pilot.press("g") + await pilot.pause() + + self.assertIn("No known-good set saved yet", app.status_message) + async def test_space_reports_hash_mismatch_without_state_update(self) -> None: with tempfile.TemporaryDirectory() as tmp: app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)