Add profile-aware plugin TUI

This commit is contained in:
pleb
2026-07-01 11:44:03 -07:00
parent 13b1840ba0
commit 69f9dbd9b1
10 changed files with 876 additions and 265 deletions
+224 -46
View File
@@ -4,22 +4,27 @@ import json
import tempfile
import unittest
import os
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from zipfile import ZipFile
from rich.text import Text
from textual.coordinate import Coordinate
from textual.widgets import DataTable
from plugin_helper.bootstrap import _run_ipa
from plugin_helper.beatmods import by_version_id, normalize_mods
from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.config import load_profiles, profile_by_id, resolve_runtime_config
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state
from plugin_helper.tui import InstallationChoice, PluginHelperTui
from plugin_helper.updates import check_updates
from plugin_helper.userdata import (
backup_userdata,
@@ -119,58 +124,61 @@ class PluginHelperTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "ambiguous"):
get_instance([windows, local], "1.44.1")
def test_menu_selects_instance_and_action(self) -> None:
def test_profile_config_loads_and_resolves_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "instances" / "1.40.8"
state = root / "state"
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
config = root / "plugin-helper.local.toml"
config.write_text(
"""
[[profiles]]
id = "linux"
label = "Linux"
instances_root = "~/BSInstances"
state_dir = ".state"
answers = iter(["1", "3", "q"])
output = StringIO()
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
status = run(
[
"--instances-root",
str(root / "instances"),
"--state-dir",
str(state),
"menu",
]
)
[[profiles]]
id = "windows"
label = "Windows"
instances_root = "mounted/BSInstances"
state_dir = ".state-windows"
""".lstrip(),
encoding="utf-8",
)
self.assertEqual(status, 0)
self.assertIn("Counts files currently present", output.getvalue())
profiles, loaded_path, loaded = load_profiles(config, root=root)
def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None:
self.assertTrue(loaded)
self.assertEqual(loaded_path, config)
self.assertEqual(profile_by_id(profiles, "linux").instances_root, Path("~/BSInstances").expanduser())
self.assertEqual(profile_by_id(profiles, "windows").instances_root, root / "mounted" / "BSInstances")
self.assertEqual(profile_by_id(profiles, "windows").state_dir, root / ".state-windows")
def test_profile_runtime_explicit_overrides(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
first_root = root / "a-root"
second_root = root / "z-root"
first = first_root / "1.44.1"
second = second_root / "1.44.1"
state = root / "state"
(first / "Beat Saber_Data").mkdir(parents=True)
(second / "Beat Saber_Data").mkdir(parents=True)
(second / "Plugins").mkdir()
(second / "Plugins" / "Example.dll").write_bytes(b"dll")
config = root / "plugin-helper.local.toml"
config.write_text(
"""
[[profiles]]
id = "linux"
label = "Linux"
instances_root = "profile-root"
state_dir = ".state"
""".lstrip(),
encoding="utf-8",
)
answers = iter(["2", "3", "q"])
output = StringIO()
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
status = run(
[
"--instances-root",
os.pathsep.join([str(first_root), str(second_root)]),
"--state-dir",
str(state),
"menu",
]
)
runtime = resolve_runtime_config(
instances_root_value=str(root / "explicit-root"),
state_dir_value="explicit-state",
profile_id="linux",
config_path=config,
root=root,
)
self.assertEqual(status, 0)
self.assertIn("1.44.1: 1 files", output.getvalue())
self.assertEqual(runtime.instances_roots, [root / "explicit-root"])
self.assertEqual(runtime.state_root, root / "explicit-state")
self.assertEqual(runtime.selected_profile.id, "linux")
def test_run_ipa_timeout_returns_control(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -343,7 +351,7 @@ sha256 = "{sha256_file(asset)}"
save_installed_state(state, "1.40.8", disabled)
with patch("plugin_helper.cli.repo_root", return_value=work):
with patch("plugin_helper.operations.repo_root", return_value=work):
status = run(
[
"--instances-root",
@@ -971,5 +979,175 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo"
instance_root = root / "instances"
instance = instance_root / "1.40.8"
state = root / "state"
(repo / "registry").mkdir(parents=True)
(repo / "locks").mkdir()
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
(repo / "registry" / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(repo / "locks" / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "{sha256_file(asset)}"
""".lstrip(),
encoding="utf-8",
)
target = instance / "Plugins" / "Example.dll"
if disabled:
plugins = {}
disabled_plugins = {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"disabledAt": "2026-06-14T17:20:00Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
}
else:
target.write_bytes(b"changed dll" if hash_mismatch else b"managed dll")
plugins = {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
}
disabled_plugins = {}
save_installed_state(
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
)
choice = InstallationChoice(
profile_id="test",
profile_label="Test Profile",
instance_name="1.40.8",
instance_path=instance,
state_root=state,
)
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
choices = [
InstallationChoice(
profile_id="linux",
profile_label="Linux",
instance_name="1.44.1",
instance_path=Path("/tmp/linux/1.44.1"),
state_root=Path("/tmp/state-linux"),
),
InstallationChoice(
profile_id="windows",
profile_label="Windows",
instance_name="1.44.1",
instance_path=Path("/tmp/windows/1.44.1"),
state_root=Path("/tmp/state-windows"),
),
]
app = PluginHelperTui(choices=choices, repo_root=Path("/tmp/repo"))
async with app.run_test():
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations")
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("enter")
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
table = app.query_one(DataTable)
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
await pilot.press("space")
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
async def test_space_enables_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot:
await pilot.press("enter")
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space")
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
async def test_disable_all_disables_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("d")
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
self.assertIn("Disabled 1 plugins", app.status_message)
async def test_enable_all_enables_disabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("e")
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
self.assertIn("Enabled 1 plugins", 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)
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("space")
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
updated = load_installed_state(state, "1.40.8")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message)
if __name__ == "__main__":
unittest.main()