Add profile-aware plugin TUI
This commit is contained in:
+224
-46
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user