mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-15 16:00:33 +02:00
1417 lines
59 KiB
Python
1417 lines
59 KiB
Python
import sys
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
WINDOWS_GUI = pytest.mark.skipif(sys.platform != 'win32', reason='Windows Tk packaging smoke test')
|
|
|
|
|
|
def _disabled(widget):
|
|
return str(widget.cget('state')) == 'disabled'
|
|
|
|
|
|
def _drain_events(app):
|
|
"""Functional assertions wait for all queued events, even when Tk yields."""
|
|
while not app.events.empty():
|
|
app._drain()
|
|
|
|
|
|
def _display_text(widget):
|
|
"""Read visible captions, including StringVar-backed result labels."""
|
|
values = []
|
|
for option in ('text', 'textvariable'):
|
|
if option in widget.keys():
|
|
value = widget.cget(option)
|
|
if option == 'textvariable' and value:
|
|
value = ('<masked>' if 'show' in widget.keys() and widget.cget('show')
|
|
else widget.getvar(value))
|
|
values.append(str(value))
|
|
for child in widget.winfo_children():
|
|
values.extend(_display_text(child))
|
|
return values
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def app_window(tmp_path_factory):
|
|
from halo_frame_installer import gui
|
|
# Tk applications use one root/interpreter; reset its state between scenarios.
|
|
instance = gui.App(tmp_path_factory.mktemp('gui') / 'state')
|
|
instance.withdraw()
|
|
instance.update_idletasks()
|
|
try:
|
|
yield instance
|
|
finally:
|
|
for callback in instance.tk.call('after', 'info'):
|
|
instance.after_cancel(callback)
|
|
instance.destroy()
|
|
|
|
|
|
@pytest.fixture
|
|
def app(app_window, monkeypatch):
|
|
from halo_frame_installer import gui
|
|
dialogs = []
|
|
for name in ('showinfo', 'showerror'):
|
|
monkeypatch.setattr(gui.messagebox, name,
|
|
lambda title, message, **kwargs: dialogs.append((title, message)))
|
|
monkeypatch.setattr(gui.messagebox, 'askyesno', lambda *args, **kwargs: True)
|
|
|
|
def forbidden_network(*args, **kwargs):
|
|
raise AssertionError('The GUI verification must not open an SSH connection.')
|
|
|
|
monkeypatch.setattr(gui.SSHConnection, 'connect', forbidden_network)
|
|
instance = app_window
|
|
for callback in instance.tk.call('after', 'info'):
|
|
instance.after_cancel(callback)
|
|
instance.events.queue.clear()
|
|
instance.last_result = None
|
|
instance.source.set('')
|
|
instance.host.set('frame')
|
|
instance.port.set('22')
|
|
instance.transport.set('network')
|
|
instance.adb_path.set('')
|
|
instance.usb_serial.set('')
|
|
instance.fingerprints = {}
|
|
instance.password.set('')
|
|
instance.password_to_redact = ''
|
|
instance.authorized.set(False)
|
|
instance.steam_closed.set(False)
|
|
instance.keep_saves.set(True)
|
|
instance.mode.set('install')
|
|
instance.max_page = 0
|
|
instance.installing = False
|
|
instance.uninstall_committing = False
|
|
instance.operation = None
|
|
instance.cancel.clear()
|
|
if instance.activity_open:
|
|
instance._toggle_activity()
|
|
if instance.advanced_open:
|
|
instance._toggle_advanced()
|
|
instance._set_busy(False)
|
|
instance._show_page(0)
|
|
instance.progress_state.reset()
|
|
instance._display_progress()
|
|
instance.log_lines.clear()
|
|
instance.log.configure(state='normal')
|
|
instance.log.delete('1.0', 'end')
|
|
instance.log.configure(state='disabled')
|
|
instance.withdraw()
|
|
instance.update_idletasks()
|
|
instance._test_dialogs = dialogs
|
|
try:
|
|
yield instance
|
|
finally:
|
|
for callback in instance.tk.call('after', 'info'):
|
|
instance.after_cancel(callback)
|
|
instance.password.set('')
|
|
instance.password_to_redact = ''
|
|
|
|
|
|
class _ImmediateThread:
|
|
"""Run an injected offline installer in the calling Tk thread."""
|
|
def __init__(self, target, **kwargs):
|
|
self.target = target
|
|
|
|
def start(self):
|
|
self.target()
|
|
|
|
def test_control_guide_matches_expected_layout():
|
|
from halo_frame_installer.gui import CONTROLS
|
|
for action in ('Jump', 'Melee', 'Reload / use', 'Change weapon',
|
|
'LB Change grenade', 'RB Flashlight', 'Both grips Recenter'):
|
|
assert action in CONTROLS
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_community_mod_title_and_content_disclosure_are_visible_and_accurate(app):
|
|
import tkinter as tk
|
|
from halo_frame_installer.gui import APP_TITLE, CONTENT_DISCLOSURE, ARTWORK_CREDIT
|
|
assert app.title() == APP_TITLE == 'Halo • Community Steam Frame VR Mod Installer'
|
|
visible = '\n'.join(_display_text(app))
|
|
assert 'STEAM FRAME VR MOD' in visible and 'COMMUNITY MOD INSTALLER' in visible
|
|
assert CONTENT_DISCLOSURE in app.data_disclosure.cget('text')
|
|
assert 'my original Xbox game data with this mod' in app.data_authorization.cget('text')
|
|
app._show_help()
|
|
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
|
try:
|
|
assert len(dialogs) == 1
|
|
text = '\n'.join(_display_text(dialogs[0]))
|
|
assert CONTENT_DISCLOSURE in text and ARTWORK_CREDIT in text
|
|
assert 'public source using your authorized Xbox data' in text
|
|
for claim in ('copyright-free', 'copyright free', 'unstolen', 'vimm', 'archive.org'):
|
|
assert claim not in text.lower()
|
|
finally:
|
|
for dialog in dialogs:
|
|
dialog.destroy()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_long_build_failure_has_readable_dialog_and_complete_redacted_log(app):
|
|
app.password_to_redact = 'offline-secret'
|
|
details = 'Native build failed. The existing game was kept.\n' + ('compiler diagnostic offline-secret\n' * 200)
|
|
app.events.put(('error', details))
|
|
_drain_events(app)
|
|
_, message = app._test_dialogs[-1]
|
|
assert message.startswith('Native build failed. The existing game was kept.')
|
|
assert len(message) < 600 and 'Show activity' in message and 'Save log' in message
|
|
assert 'offline-secret' not in message
|
|
assert '\n'.join(app.log_lines).count('compiler diagnostic [redacted]') == 200
|
|
|
|
@WINDOWS_GUI
|
|
def test_gui_offline_smoke():
|
|
resources = Path(__file__).resolve().parents[1] / 'resources'
|
|
code = ('import json, sys; from pathlib import Path; '
|
|
'resources = Path(sys.argv[1]); '
|
|
'sys.path.insert(0, str(resources.parent / "src")); '
|
|
'from halo_frame_installer.selftest import smoke_test; '
|
|
'print(json.dumps(smoke_test(resources)))')
|
|
report = subprocess.run([sys.executable, '-c', code, str(resources)],
|
|
capture_output=True, text=True, check=True)
|
|
result = json.loads(report.stdout)
|
|
assert result['ok'] and result['guiInitialized']
|
|
from halo_frame_installer import __version__
|
|
assert result['version'] == __version__
|
|
assert result['guiPagesInitialized'] == ['gameData', 'connection', 'install']
|
|
assert result['steamInfoAutomaticOnInstall'] and result['steamDescriptionUsesNotes']
|
|
assert result['networkConnections'] == 0
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_pages_preserve_inputs_and_mode(app):
|
|
app.source.set('X:/my-original-xbox-image.iso')
|
|
app.host.set('192.0.2.24')
|
|
app.port.set('2222')
|
|
app.password.set('offline-secret')
|
|
app.authorized.set(True)
|
|
app.steam_closed.set(True)
|
|
app.mode.set('repair')
|
|
for index in (1, 2, 0, 2, 1, 0):
|
|
app._show_page(index)
|
|
app.update_idletasks()
|
|
assert app.current_page == index
|
|
assert (app.source.get(), app.host.get(), app.port.get(), app.password.get(),
|
|
app.authorized.get(), app.steam_closed.get(), app.mode.get()) == (
|
|
'X:/my-original-xbox-image.iso', '192.0.2.24', '2222',
|
|
'offline-secret', True, True, 'repair')
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_usb_and_network_choices_preserve_inputs_across_pages(app):
|
|
app.host.set('192.0.2.24')
|
|
app.port.set('2222')
|
|
app.adb_path.set('C:/Platform Tools/adb.exe')
|
|
app.usb_serial.set('FRAME-SERIAL')
|
|
app.password.set('offline-secret')
|
|
app.transport.set('usb')
|
|
app._show_page(1)
|
|
app.update_idletasks()
|
|
assert app.usb_panel.winfo_manager() == 'pack'
|
|
assert not app.network_panel.winfo_manager()
|
|
assert app.usb_advanced.winfo_manager() == 'pack'
|
|
assert not app.network_advanced.winfo_manager()
|
|
assert app.summary_frame.get() == 'USB-C cable · FRAME-SERIAL'
|
|
for index in (2, 0, 1):
|
|
app._show_page(index)
|
|
assert app.transport.get() == 'usb'
|
|
assert app.adb_path.get() == 'C:/Platform Tools/adb.exe'
|
|
assert app.usb_serial.get() == 'FRAME-SERIAL'
|
|
assert app.password.get() == 'offline-secret'
|
|
app.transport.set('network')
|
|
assert app.network_panel.winfo_manager() == 'pack'
|
|
assert not app.usb_panel.winfo_manager()
|
|
assert app.network_advanced.winfo_manager() == 'pack'
|
|
assert not app.usb_advanced.winfo_manager()
|
|
assert (app.host.get(), app.port.get()) == ('192.0.2.24', '2222')
|
|
assert app.summary_frame.get() == '192.0.2.24'
|
|
assert 'Wi-Fi and Ethernet' in app.connection_prepare_note.cget('text')
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_usb_settings_ignore_hidden_network_inputs_and_use_device_fingerprint(app):
|
|
app.host.set('invalid hidden address')
|
|
app.port.set('invalid-port')
|
|
app.password.set('offline-secret')
|
|
app.transport.set('usb')
|
|
app.usb_serial.set(' FRAME-SERIAL ')
|
|
app.adb_path.set(' C:/Platform Tools/adb.exe ')
|
|
app.fingerprints = {'usb:FRAME-SERIAL': 'SHA256:usb', '127.0.0.1:2222': 'SHA256:wrong'}
|
|
settings = app._settings()
|
|
assert settings.transport == 'usb'
|
|
assert settings.port == 22
|
|
assert settings.usb_serial == 'FRAME-SERIAL'
|
|
assert settings.adb_path == 'C:/Platform Tools/adb.exe'
|
|
assert settings.host_identity == 'usb:FRAME-SERIAL'
|
|
assert settings.known_host_fingerprint == 'SHA256:usb'
|
|
assert settings.known_host_fingerprints == app.fingerprints
|
|
assert settings.known_host_fingerprints is not app.fingerprints
|
|
assert settings.close_steam_for_shortcut is False
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_automatic_usb_selection_supplies_saved_device_fingerprints(app):
|
|
app.password.set('offline-secret')
|
|
app.transport.set('usb')
|
|
app.fingerprints = {'usb:FRAME-SERIAL': 'SHA256:usb'}
|
|
settings = app._settings()
|
|
assert settings.usb_serial is None
|
|
assert settings.adb_path is None
|
|
assert settings.known_host_fingerprint is None
|
|
assert settings.known_host_fingerprints == {'usb:FRAME-SERIAL': 'SHA256:usb'}
|
|
assert app.summary_frame.get() == 'USB-C cable · Detect connected Frame'
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_trusted_usb_fingerprint_persists_by_serial_without_forwarded_port(app):
|
|
import threading
|
|
ready, answer = threading.Event(), []
|
|
app.events.put(('fingerprint', 'usb:FRAME-SERIAL', 'ssh-ed25519', 'SHA256:usb', 22, ready, answer))
|
|
_drain_events(app)
|
|
assert ready.is_set() and answer == [True]
|
|
assert app.fingerprints == {'usb:FRAME-SERIAL': 'SHA256:usb'}
|
|
assert json.loads(app.state_file.read_text(encoding='utf-8')) == app.fingerprints
|
|
app.events.put(('connected', 'usb:FRAME-SERIAL', 'SHA256:usb'))
|
|
_drain_events(app)
|
|
assert app.fingerprints == {'usb:FRAME-SERIAL': 'SHA256:usb'}
|
|
assert app._load_fingerprints() == app.fingerprints
|
|
assert '127.0.0.1' not in app.state_file.read_text(encoding='utf-8')
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_network_settings_keep_address_port_and_existing_trust_identity(app):
|
|
app.host.set('192.0.2.24')
|
|
app.port.set('2222')
|
|
app.password.set('offline-secret')
|
|
app.fingerprints = {'192.0.2.24:2222': 'SHA256:network'}
|
|
settings = app._settings()
|
|
assert settings.transport == 'network'
|
|
assert settings.host_identity == '192.0.2.24:2222'
|
|
assert settings.known_host_fingerprint == 'SHA256:network'
|
|
app.events.put(('connected', settings.host_identity, 'SHA256:network'))
|
|
_drain_events(app)
|
|
assert app.fingerprints == {'192.0.2.24:2222': 'SHA256:network'}
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_connection_worker_remembers_resolved_usb_identity_without_opening_network(app, monkeypatch):
|
|
from halo_frame_installer import gui
|
|
closed = []
|
|
|
|
class OfflineConnection:
|
|
host_fingerprint = 'SHA256:usb'
|
|
|
|
def __init__(self, settings):
|
|
self.settings = settings
|
|
|
|
def connect(self):
|
|
assert self.settings.transport == 'usb'
|
|
self.settings.usb_serial = 'FRAME-SERIAL'
|
|
|
|
def run(self, argv, **kwargs):
|
|
assert argv == ['uname', '-m']
|
|
return 'aarch64'
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(gui, 'SSHConnection', OfflineConnection)
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
app.transport.set('usb')
|
|
app.password.set('offline-secret')
|
|
app._test_connection()
|
|
_drain_events(app)
|
|
assert closed == [True]
|
|
assert app.fingerprints == {'usb:FRAME-SERIAL': 'SHA256:usb'}
|
|
assert not app.busy
|
|
assert 'Connected to your Frame' in app.connection_status.get()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('connection_fails', [False, True])
|
|
def test_connection_cleanup_failure_restores_controls_clears_password_and_never_reports_success(app, monkeypatch, connection_fails):
|
|
from halo_frame_installer import gui
|
|
from halo_frame_installer.ssh import SSHError
|
|
captured = []
|
|
actions = []
|
|
|
|
class OfflineConnection:
|
|
host_fingerprint = 'SHA256:usb'
|
|
|
|
def __init__(self, settings):
|
|
self.settings = settings
|
|
captured.append(settings)
|
|
|
|
def connect(self):
|
|
self.settings.usb_serial = 'FRAME-SERIAL'
|
|
if connection_fails:
|
|
raise SSHError('Offline connection failed.')
|
|
|
|
def run(self, argv, **kwargs):
|
|
return 'aarch64'
|
|
|
|
def close(self):
|
|
actions.append('close')
|
|
raise SSHError('The USB forward could not be removed: offline-secret')
|
|
|
|
monkeypatch.setattr(gui, 'SSHConnection', OfflineConnection)
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
app.transport.set('usb')
|
|
app.password.set('offline-secret')
|
|
app._test_connection()
|
|
assert actions == ['close']
|
|
assert captured[0].password == ''
|
|
queued = list(app.events.queue)
|
|
assert not any(event[0] == 'connected' for event in queued)
|
|
assert queued[-1] == ('idle',)
|
|
_drain_events(app)
|
|
assert not app.busy
|
|
assert not app.password.get()
|
|
assert not _disabled(app.primary_button)
|
|
assert all(not _disabled(choice) for choice in app.transport_choices)
|
|
assert app.fingerprints == {}
|
|
assert 'needs attention' in app.connection_status.get()
|
|
assert 'SSH connection verified' not in '\n'.join(app.log_lines)
|
|
log = '\n'.join(app.log_lines)
|
|
assert 'USB forward could not be removed' in log
|
|
assert 'offline-secret' not in log
|
|
assert 'disconnecting' in log
|
|
assert app._test_dialogs
|
|
if connection_fails:
|
|
assert 'Offline connection failed' in log
|
|
else:
|
|
assert any('disconnecting' in message for _, message in app._test_dialogs)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_adb_browse_retains_selection_and_is_disabled_during_setup(app, monkeypatch):
|
|
from halo_frame_installer import gui
|
|
calls = []
|
|
monkeypatch.setattr(gui.filedialog, 'askopenfilename',
|
|
lambda **kwargs: calls.append(kwargs) or 'C:/Platform Tools/adb.exe')
|
|
app.transport.set('usb')
|
|
app._choose_adb()
|
|
assert app.adb_path.get() == 'C:/Platform Tools/adb.exe'
|
|
app._set_busy(True)
|
|
assert all(_disabled(choice) for choice in app.transport_choices)
|
|
assert all(_disabled(entry) for entry in app.entries)
|
|
app._choose_adb()
|
|
assert len(calls) == 1
|
|
for choice in app.transport_choices:
|
|
choice._select()
|
|
assert app.transport.get() == 'usb'
|
|
app._set_busy(False)
|
|
assert all(not _disabled(choice) for choice in app.transport_choices)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_usb_help_describes_cable_password_internet_and_official_tools(app):
|
|
import tkinter as tk
|
|
app.transport.set('usb')
|
|
text = '\n'.join(_display_text(app))
|
|
for phrase in ('USB tools are included', 'data-capable USB-C cable',
|
|
'USB debugging prompt', 'SSH password', 'internet access'):
|
|
assert phrase in text
|
|
app._show_help()
|
|
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
|
try:
|
|
assert len(dialogs) == 1
|
|
text = '\n'.join(_display_text(dialogs[0]))
|
|
assert 'Developer Mode enabled' in text
|
|
assert 'Wi-Fi or Ethernet network' in text
|
|
from halo_frame_installer.gui import PLATFORM_TOOLS_URL
|
|
assert PLATFORM_TOOLS_URL == 'https://developer.android.com/tools/releases/platform-tools'
|
|
finally:
|
|
for dialog in dialogs:
|
|
dialog.destroy()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_continue_requires_authorized_data_but_allows_repair_without_image(app):
|
|
app._nav_to(2)
|
|
assert app.current_page == 0
|
|
assert _disabled(app.nav_buttons[2])
|
|
app.mode.set('install')
|
|
app._continue()
|
|
assert app.current_page == 0
|
|
assert app._test_dialogs
|
|
app.mode.set('repair')
|
|
app._continue()
|
|
assert app.current_page == 0 # Existing data still requires authorization.
|
|
app.authorized.set(True)
|
|
app._continue()
|
|
assert app.current_page == 1
|
|
assert not app.source.get()
|
|
app.password.set('offline-secret')
|
|
app.port.set('invalid-port')
|
|
app.steam_closed.set(True)
|
|
app._continue()
|
|
assert app.current_page == 1
|
|
app.port.set('22')
|
|
app.steam_closed.set(False)
|
|
app._continue()
|
|
assert app.current_page == 1
|
|
app.steam_closed.set(True)
|
|
app._continue()
|
|
assert app.current_page == 2
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_busy_disables_navigation_and_inputs_until_idle(app):
|
|
app.source.set('X:/my-original-xbox-image.iso')
|
|
app.authorized.set(True)
|
|
app.password.set('offline-secret')
|
|
app.steam_closed.set(True)
|
|
app._show_page(1)
|
|
app._set_busy(True)
|
|
for control in (*app.nav_buttons, app.back_button, app.primary_button, *app.entries):
|
|
assert _disabled(control)
|
|
assert not _disabled(app.cancel_button)
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert not app.busy
|
|
assert not _disabled(app.primary_button)
|
|
assert not _disabled(app.back_button)
|
|
assert all(not _disabled(control) for control in app.nav_buttons[:2])
|
|
assert _disabled(app.nav_buttons[2]) # The next page remains gated by Continue.
|
|
assert _disabled(app.cancel_button)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_repair_uses_existing_maps_and_clears_password(app, monkeypatch):
|
|
from halo_frame_installer import gui, install
|
|
calls = []
|
|
|
|
def fake_run(settings, maps_dir, progress, cancel):
|
|
calls.append((maps_dir, settings.adopt_existing_native, settings.password))
|
|
progress('repair', 'Reinstalling program files', 64)
|
|
return install.InstallResult('/home/steamos/Games/HaloCENativeVR', True,
|
|
{'status': 'added', 'appid': 123}, 'SHA256:offline', repaired=True,
|
|
backup_path='/home/steamos/Games/HaloCENativeVR.backup-offline')
|
|
|
|
monkeypatch.setattr(install, 'run', fake_run)
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
monkeypatch.setattr(gui, 'inspect_image', lambda *args, **kwargs: pytest.fail('Repair inspected a local image.'))
|
|
monkeypatch.setattr(gui, 'inspect_maps', lambda *args, **kwargs: pytest.fail('Repair inspected a local maps folder.'))
|
|
app.mode.set('repair')
|
|
app.authorized.set(True)
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app._show_page(2)
|
|
app._install(repair=True)
|
|
_drain_events(app)
|
|
assert calls == [(None, True, 'offline-secret')]
|
|
assert app.last_result.repaired
|
|
assert float(app.progress['value']) == 100
|
|
assert 'repaired' in app.status.get().lower()
|
|
assert not app.password.get()
|
|
assert not app.busy
|
|
assert any('backup-offline' in line for line in app.log_lines)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_progress_and_errors_redact_password_on_every_display(app):
|
|
secret = 'offline-secret'
|
|
app.password_to_redact = secret
|
|
app.password.set(secret)
|
|
app._show_page(2)
|
|
app._set_busy(True)
|
|
app.events.put(('progress', 'build ' + secret, 'Compiler diagnostic ' + secret, 38))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 0 # Unknown phases cannot corrupt overall progress.
|
|
assert secret not in app.status.get()
|
|
assert secret not in '\n'.join(app.log_lines)
|
|
assert secret not in '\n'.join(_display_text(app))
|
|
app.events.put(('error', 'Connection failed: ' + secret))
|
|
app.events.put(('clear_password',))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert not app.busy
|
|
assert not app.password.get()
|
|
assert any('[redacted]' in line for line in app.log_lines)
|
|
assert secret not in '\n'.join(message for title, message in app._test_dialogs)
|
|
assert secret not in '\n'.join(_display_text(app))
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_manual_steam_completion_keeps_actionable_instructions_redacted(app):
|
|
from halo_frame_installer.install import InstallResult
|
|
secret = 'offline-secret'
|
|
app.password_to_redact = secret
|
|
app._show_page(2)
|
|
app._set_busy(True)
|
|
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'manual', 'reason': 'Steam is still running. ' + secret,
|
|
'instructions': 'Use Steam Add a Non-Steam Game, then click Add to Steam again.'}, None)
|
|
app.events.put(('complete', result))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert app.last_result.requires_manual_steam_step
|
|
assert float(app.progress['value']) == 99
|
|
assert 'Add to Steam again' in app.status.get()
|
|
assert not _disabled(app.retry_button)
|
|
assert secret not in app.status.get()
|
|
assert secret not in '\n'.join(app.log_lines)
|
|
assert secret not in '\n'.join(_display_text(app))
|
|
assert secret not in '\n'.join(message for title, message in app._test_dialogs)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_minimum_window_keeps_primary_navigation_in_bounds(app):
|
|
width, height = app.minsize()
|
|
app.geometry(f'{width}x{height}')
|
|
app.deiconify()
|
|
for index in range(3):
|
|
app._show_page(index)
|
|
app.update() # Process native window mapping after deiconify on Windows.
|
|
for control in (*app.nav_buttons, app.primary_button, app.back_button):
|
|
assert control.winfo_ismapped()
|
|
left = control.winfo_rootx() - app.winfo_rootx()
|
|
top = control.winfo_rooty() - app.winfo_rooty()
|
|
assert left >= 0 and top >= 0
|
|
assert left + control.winfo_width() <= app.winfo_width()
|
|
assert top + control.winfo_height() <= app.winfo_height()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_real_phase_progress_stays_determinate_and_details_do_not_replace_status(app):
|
|
app.installing = True
|
|
app.password_to_redact = 'offline-secret'
|
|
app._show_page(2)
|
|
app._begin_setup_progress()
|
|
app.events.put(('progress', 'reuse', 'All retained maps verified.', 100))
|
|
app.events.put(('progress', 'build', 'Pulling the build container.', None))
|
|
app.events.put(('progress', 'compile', 'Completed 40 of 100 build tasks.', 40))
|
|
app.events.put(('progress', 'detail', 'compile: [40/100] Building offline-secret.cpp', None))
|
|
_drain_events(app)
|
|
assert str(app.progress.cget('mode')) == 'determinate'
|
|
assert float(app.progress['value']) == pytest.approx(79.8)
|
|
assert app.progress_label.get() == '79%'
|
|
assert app.phase.get() == 'Compiling Halo and VR support'
|
|
assert app.status.get() == 'Completed 40 of 100 build tasks.'
|
|
assert app.log_lines[-1] == 'compile: [40/100] Building [redacted].cpp'
|
|
assert app.activity_open
|
|
assert not app.install_summary.winfo_manager()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_error_and_cancel_keep_progress_and_new_attempt_resets_it(app):
|
|
app._begin_setup_progress()
|
|
app.events.put(('progress', 'compile', 'Building Halo.', 50))
|
|
_drain_events(app)
|
|
held = float(app.progress['value'])
|
|
app._cancel_setup()
|
|
app.events.put(('progress', 'compile', 'Late queued build report.', 100))
|
|
app.events.put(('error', 'Setup cancelled. Existing saves were kept.'))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == held
|
|
assert str(app.progress.cget('mode')) == 'determinate'
|
|
app._begin_setup_progress()
|
|
assert float(app.progress['value']) == 0
|
|
assert app.progress_label.get() == '0%'
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_box_art_pending_stays_at_99_with_retry_action(app):
|
|
from halo_frame_installer.install import InstallResult
|
|
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added', 'artwork': {'status': 'manual',
|
|
'reason': 'Steam artwork directory is busy.'}}, None)
|
|
app.events.put(('complete', result))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert 'Steam info pending' in app.phase.get()
|
|
assert not _disabled(app.retry_button)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_disconnect_phase_does_not_finish_before_the_installer_result(app):
|
|
from halo_frame_installer.install import InstallResult
|
|
app.events.put(('progress', 'disconnect', 'Closing the setup SSH connection.', None))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert app.phase.get() == 'Closing the setup connection'
|
|
app.events.put(('progress', 'disconnected', 'Setup has disconnected from your Frame.', None))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert app.phase.get() == 'Disconnected from your Frame'
|
|
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added'}, None)
|
|
app.events.put(('complete', result))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 100
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_detail_burst_is_retained_and_drain_yields_to_the_window(app):
|
|
for index in range(250):
|
|
app.events.put(('progress', 'detail', f'compile: task {index}', None))
|
|
app._drain()
|
|
assert not app.events.empty() # One turn drains at most 100 events.
|
|
while not app.events.empty():
|
|
app._drain()
|
|
assert app.log_lines == [f'compile: task {index}' for index in range(250)]
|
|
assert float(app.progress['value']) == 0
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_installer_worker_does_not_throttle_adjacent_build_details(app, monkeypatch):
|
|
from halo_frame_installer import gui, install
|
|
|
|
def fake_run(settings, maps_dir, progress, cancel):
|
|
progress('compile', 'Compiling the native game.', 50)
|
|
for index in range(250):
|
|
progress('detail', f'compile: task {index}', None)
|
|
return install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added'}, None)
|
|
|
|
monkeypatch.setattr(install, 'run', fake_run)
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
app.authorized.set(True)
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app._install(repair=True)
|
|
while not app.events.empty():
|
|
app._drain()
|
|
assert [line for line in app.log_lines if line.startswith('compile: task ')] == [
|
|
f'compile: task {index}' for index in range(250)]
|
|
assert float(app.progress['value']) == 100
|
|
assert not app.password.get()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_library_retry_resets_the_progress_before_starting_worker(app, monkeypatch):
|
|
from halo_frame_installer import gui
|
|
|
|
class HoldingThread:
|
|
def __init__(self, **kwargs):
|
|
pass
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(gui.threading, 'Thread', HoldingThread)
|
|
app.progress_state.finish(pending=True)
|
|
app._display_progress()
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app._retry_steam()
|
|
assert float(app.progress['value']) == 0
|
|
assert app.progress_label.get() == '0%'
|
|
assert app.activity_open and app.busy
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_minimum_window_keeps_running_progress_and_scrollable_activity_visible(app):
|
|
width, height = app.minsize()
|
|
app.geometry(f'{width}x{height}')
|
|
app.deiconify()
|
|
app.installing = True
|
|
app._show_page(2)
|
|
app._begin_setup_progress()
|
|
app._set_busy(True)
|
|
app.update()
|
|
for control in (app.progress, app.phase_label, app.log, app.activity_scrollbar, app.cancel_button):
|
|
assert control.winfo_ismapped()
|
|
left = control.winfo_rootx() - app.winfo_rootx()
|
|
top = control.winfo_rooty() - app.winfo_rooty()
|
|
assert left >= 0 and top >= 0
|
|
assert left + control.winfo_width() <= app.winfo_width()
|
|
assert top + control.winfo_height() <= app.winfo_height()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_uninstall_navigation_needs_ssh_but_no_iso_or_game_data_authorization(app):
|
|
app.mode.set('uninstall')
|
|
assert app.keep_saves.get()
|
|
assert not app.source.get() and not app.authorized.get()
|
|
assert app.uninstall_card.winfo_manager()
|
|
assert not app.data_panel.winfo_manager() and not app.data_consent.winfo_manager()
|
|
app._continue()
|
|
assert app.current_page == 1
|
|
app._continue()
|
|
assert app.current_page == 1 # An SSH password is still required.
|
|
app.password.set('offline-secret')
|
|
app._continue()
|
|
assert app.current_page == 1 # Closing games is still required.
|
|
app.steam_closed.set(True)
|
|
app._continue()
|
|
assert app.current_page == 2
|
|
assert app.primary_button.cget('text') == 'Uninstall Halo VR'
|
|
assert app.summary_source.get() == '~/Games/HaloCENativeVR'
|
|
assert 'backup' in app.summary_action.get()
|
|
app.keep_saves.set(False)
|
|
assert 'Remove saves' in app.summary_action.get()
|
|
app.mode.set('install')
|
|
app._show_page(0)
|
|
app._continue()
|
|
assert app.current_page == 0 # The install data/authorization gate returns.
|
|
assert app.data_panel.winfo_manager() and app.data_consent.winfo_manager()
|
|
assert not app.uninstall_card.winfo_manager()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('keep_saves', [True, False])
|
|
def test_uninstall_worker_uses_no_game_data_and_clears_credentials(app, monkeypatch, keep_saves):
|
|
from halo_frame_installer import gui, install
|
|
calls = []
|
|
confirmations = []
|
|
|
|
def fake_uninstall(self, settings, progress, cancel, *, keep_saves):
|
|
calls.append((settings, settings.password, keep_saves))
|
|
progress('uninstall', 'Removing native Halo.', 50)
|
|
progress('disconnect', 'Closing the setup SSH connection.', None)
|
|
progress('disconnected', 'Setup has disconnected from your Frame.', None)
|
|
return install.UninstallResult('/home/steamos/Games/HaloCENativeVR', True, False,
|
|
'/home/steamos/Games/HaloCENativeVR-saves-offline' if keep_saves else None,
|
|
{'status': 'removed'}, None)
|
|
|
|
monkeypatch.setattr(install.Installer, 'uninstall', fake_uninstall)
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
monkeypatch.setattr(gui.messagebox, 'askyesno',
|
|
lambda title, message, **kwargs: confirmations.append(message) or True)
|
|
for name in ('inspect_image', 'inspect_maps', 'extract_image', 'copy_maps'):
|
|
monkeypatch.setattr(gui, name, lambda *args, **kwargs: pytest.fail('Uninstall accessed game data.'))
|
|
monkeypatch.setattr(app.music, 'load', lambda *args: pytest.fail('Uninstall loaded game music.'))
|
|
app.mode.set('uninstall')
|
|
app.keep_saves.set(keep_saves)
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app._uninstall()
|
|
_drain_events(app)
|
|
settings, password, saved = calls[0]
|
|
assert password == 'offline-secret' and saved is keep_saves
|
|
assert settings.close_steam_for_shortcut is False
|
|
assert not settings.password and not app.password.get()
|
|
assert len(confirmations) == 1
|
|
assert '~/Games/HaloCENativeVR' in confirmations[0] and 'Steam library entry' in confirmations[0]
|
|
assert ('backed up' if keep_saves else 'will also be removed') in confirmations[0]
|
|
assert float(app.progress['value']) == 100
|
|
assert app.phase.get() == 'Halo VR uninstalled'
|
|
assert app.last_result is None and not app.retry_button.winfo_manager()
|
|
assert not app.busy and not app.uninstall_committing
|
|
assert app._test_dialogs[-1][0] == 'Uninstall complete'
|
|
assert 'Open Steam and launch' not in app.status.get()
|
|
assert ('Campaign save backup:' in app.status.get()) is keep_saves
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_uninstall_confirmation_can_decline_without_starting_worker(app, monkeypatch):
|
|
from halo_frame_installer import gui, install
|
|
monkeypatch.setattr(gui.messagebox, 'askyesno', lambda *args, **kwargs: False)
|
|
monkeypatch.setattr(install.Installer, 'uninstall', lambda *args, **kwargs: pytest.fail('Uninstall was not confirmed.'))
|
|
app.mode.set('uninstall')
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app._uninstall()
|
|
assert not app.busy and not app.installing
|
|
assert not any(event[0] in ('progress', 'uninstall_complete', 'error')
|
|
for event in list(app.events.queue))
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_missing_native_game_has_an_idempotent_uninstall_message(app):
|
|
from halo_frame_installer.install import UninstallResult
|
|
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', False, True,
|
|
None, {'status': 'already-absent'}, None)
|
|
app.events.put(('uninstall_complete', result))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 100
|
|
assert 'already absent' in app.status.get()
|
|
assert 'launch' not in app.status.get().lower()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_uninstall_can_cancel_before_removal_but_not_during_commit(app, monkeypatch):
|
|
from halo_frame_installer import gui
|
|
app.mode.set('uninstall')
|
|
app.operation = 'uninstall'
|
|
app.installing = True
|
|
app._set_busy(True)
|
|
app._cancel_setup()
|
|
assert app.cancel.is_set()
|
|
app.cancel.clear()
|
|
app.events.put(('progress', 'uninstall', 'Finishing the removal transaction.', None))
|
|
_drain_events(app)
|
|
assert app.uninstall_committing and _disabled(app.cancel_button)
|
|
monkeypatch.setattr(gui.messagebox, 'askyesno', lambda *args, **kwargs: pytest.fail('Removal cannot be interrupted.'))
|
|
app._cancel_setup()
|
|
app._close()
|
|
assert not app.cancel.is_set()
|
|
assert 'Finishing removal' in app.status.get()
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert not app.uninstall_committing and not app.busy
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_uninstall_choices_and_active_layout_fit_minimum_window(app):
|
|
app.geometry('900x620')
|
|
app.deiconify()
|
|
app.mode.set('uninstall')
|
|
app._show_page(0)
|
|
app.update()
|
|
uninstall_choice = app.mode_choices[-1]
|
|
assert uninstall_choice.value == 'uninstall' and uninstall_choice.winfo_ismapped()
|
|
for control in (uninstall_choice, app.primary_button):
|
|
left = control.winfo_rootx() - app.winfo_rootx()
|
|
top = control.winfo_rooty() - app.winfo_rooty()
|
|
assert left >= 0 and top >= 0
|
|
assert left + control.winfo_width() <= app.winfo_width()
|
|
assert top + control.winfo_height() <= app.winfo_height()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_menu_music_is_enabled_without_a_stop_or_mute_button(app):
|
|
assert not app.music.muted
|
|
assert not hasattr(app, 'music_button') and not hasattr(app, '_toggle_music')
|
|
visible = '\n'.join(_display_text(app))
|
|
assert 'Music: OFF' not in visible and 'Stop music' not in visible
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_default_window_shows_all_operations_and_game_data_authorization(app):
|
|
app.geometry('1040x720')
|
|
app.deiconify()
|
|
app._show_page(0)
|
|
app.update()
|
|
canvas = app.pages[0].canvas
|
|
top = canvas.winfo_rooty()
|
|
bottom = top + canvas.winfo_height()
|
|
for control in (*app.mode_choices, app.data_authorization):
|
|
assert control.winfo_ismapped()
|
|
assert top <= control.winfo_rooty()
|
|
assert control.winfo_rooty() + control.winfo_height() <= bottom
|
|
assert len({control.winfo_rooty() for control in app.mode_choices}) == 1
|
|
assert 'uninstall' in app.page_subtitle.cget('text').lower()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_revision_guidance_names_validated_rev_2_and_checked_cache_builds(app):
|
|
import tkinter as tk
|
|
assert 'USA Rev 2 validated' in app.asset_status.get()
|
|
assert 'checked automatically' in app.asset_status.get()
|
|
app._show_help()
|
|
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
|
try:
|
|
assert len(dialogs) == 1
|
|
text = '\n'.join(_display_text(dialogs[0]))
|
|
for build in ('01.10.12.2276', '01.08.15.1749', '01.01.14.2342'):
|
|
assert build in text
|
|
assert 'NTSC:' in text and 'PAL:' in text
|
|
assert 'PC and Xbox 360 data are not supported' in text
|
|
finally:
|
|
for dialog in dialogs:
|
|
dialog.destroy()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('mode', ['install', 'repair', 'uninstall'])
|
|
@pytest.mark.parametrize('games_closed', [False, True])
|
|
def test_game_closed_acknowledgment_never_authorizes_steam_shutdown(app, mode, games_closed):
|
|
app.mode.set(mode)
|
|
app.steam_closed.set(games_closed)
|
|
app.password.set('offline-secret')
|
|
assert app._settings().close_steam_for_shortcut is False
|
|
assert 'Steam Home and SteamVR stay running' in app.connection_steam_note.cget('text')
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('operation', ['continue', 'install', 'retry', 'uninstall'])
|
|
def test_closed_game_gate_only_requests_closing_games(app, operation):
|
|
app.password.set('offline-secret')
|
|
app.authorized.set(True)
|
|
app.steam_closed.set(False)
|
|
if operation == 'continue':
|
|
app._show_page(1)
|
|
app._continue()
|
|
elif operation == 'install':
|
|
app._install(repair=True)
|
|
elif operation == 'retry':
|
|
app._retry_steam()
|
|
else:
|
|
app.mode.set('uninstall')
|
|
app._uninstall()
|
|
message = app._test_dialogs[-1][1]
|
|
assert 'Save and close running games' in message
|
|
assert 'Keep Steam Home and SteamVR running' in message
|
|
assert 'restart Steam' not in message and 'Quit Steam' not in message
|
|
assert not app.busy
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('kind', ['add', 'artwork', 'remove'])
|
|
def test_manual_library_fallback_uses_steam_ui_without_teardown_instructions(app, kind):
|
|
from halo_frame_installer.install import InstallResult, UninstallResult
|
|
game = '/home/steamos/Games/HaloCENativeVR'
|
|
if kind == 'remove':
|
|
result = UninstallResult(game, False, False, None,
|
|
{'status': 'manual', 'reason': 'Steam is active.'}, None)
|
|
event = 'uninstall_complete'
|
|
expected = 'Remove Non-Steam Game'
|
|
else:
|
|
steam = ({'status': 'manual', 'reason': 'Steam is active.'} if kind == 'add' else
|
|
{'status': 'added', 'artwork': {'status': 'manual', 'reason': 'Artwork is pending.'}})
|
|
result = InstallResult(game, False, steam, None)
|
|
event = 'complete'
|
|
expected = 'Add a Non-Steam Game' if kind == 'add' else 'custom artwork'
|
|
app.events.put((event, result))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert expected in app.status.get()
|
|
assert 'Quit Steam' not in app.status.get() and 'restart Steam' not in app.status.get()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('mode', ['install', 'repair'])
|
|
def test_install_and_repair_describe_automatic_steam_info_without_separate_choice(app, mode):
|
|
import tkinter as tk
|
|
app.mode.set(mode)
|
|
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
|
|
assert not hasattr(app, 'library_card')
|
|
assert 'Steam artwork and game Notes are added automatically' in app.summary_note.get()
|
|
assert app.data_panel.winfo_manager() and app.data_consent.winfo_manager()
|
|
app._show_page(2)
|
|
assert app.primary_button.cget('text') == ('Install Halo VR' if mode == 'install' else 'Repair Halo VR')
|
|
app._show_help()
|
|
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
|
try:
|
|
text = '\n'.join(_display_text(dialogs[0]))
|
|
assert 'Install and repair automatically add Steam artwork and a game description in Steam Notes' in text
|
|
assert 'Steam info updates' not in text
|
|
finally:
|
|
for dialog in dialogs:
|
|
dialog.destroy()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('transport', ['network', 'usb'])
|
|
def test_pending_steam_retry_does_not_read_game_data_or_build_and_clears_credentials(app, monkeypatch, transport):
|
|
from halo_frame_installer import gui, install
|
|
calls = []
|
|
|
|
def fake_library(self, settings, progress, cancel):
|
|
calls.append((settings, settings.password))
|
|
assert settings.transport == transport
|
|
assert not settings.close_steam_for_shortcut
|
|
progress('steam', 'Updating library info.', 100)
|
|
progress('disconnected', 'Setup has disconnected from your Frame.', None)
|
|
return install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
|
|
'notes': {'status': 'added', 'title': 'About Halo', 'content': 'Campaign and controls.', 'message': 'Added.'}}, None)
|
|
|
|
monkeypatch.setattr(install.Installer, 'add_to_steam', fake_library)
|
|
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Steam info rebuilt the game.'))
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
for name in ('inspect_image', 'inspect_maps', 'extract_image', 'copy_maps'):
|
|
monkeypatch.setattr(gui, name, lambda *args, **kwargs: pytest.fail('Steam info accessed game data.'))
|
|
monkeypatch.setattr(app.music, 'load', lambda *args: pytest.fail('Steam info read game music.'))
|
|
app.transport.set(transport)
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app.last_result = install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added', 'notes': {'status': 'manual', 'title': 'About Halo', 'content': 'Campaign.'}}, None)
|
|
app._show_page(2)
|
|
app._set_busy(False)
|
|
assert app.retry_button.winfo_manager() and not _disabled(app.retry_button)
|
|
app.retry_button.invoke()
|
|
_drain_events(app)
|
|
assert len(calls) == 1
|
|
settings, password = calls[0]
|
|
assert password == 'offline-secret'
|
|
assert not settings.password and not app.password.get()
|
|
assert not app.busy and not app.installing
|
|
assert float(app.progress['value']) == 100
|
|
assert app.phase.get() == 'Steam info updated'
|
|
assert 'native game and saves were kept' in app.status.get()
|
|
assert 'installed.' not in app.status.get()
|
|
assert app._test_dialogs[-1][0] == 'Steam info updated'
|
|
assert not app.retry_button.winfo_manager()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('pending', ['icon', 'notes', 'artwork'])
|
|
def test_steam_info_partial_results_stay_actionable_without_claiming_complete_update(app, pending):
|
|
from halo_frame_installer.install import InstallResult
|
|
app.operation = 'library'
|
|
app.password_to_redact = 'offline-secret'
|
|
steam = {'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
|
|
'notes': {'status': 'added', 'title': 'About Halo', 'content': 'Campaign and controls.'}}
|
|
if pending == 'icon':
|
|
steam['artwork']['icon'] = {'status': 'manual', 'reason': 'Icon not persisted. offline-secret'}
|
|
elif pending == 'notes':
|
|
steam['notes'].update(status='manual', message='Open Steam Notes. offline-secret')
|
|
else:
|
|
steam['artwork']['status'] = 'manual'
|
|
steam['artwork']['reason'] = 'Banner unavailable. offline-secret'
|
|
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False, steam, None)))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert app.phase.get() == 'Ready to play · Steam info pending'
|
|
assert 'some Steam library info needs one more step' in app.status.get()
|
|
assert 'has been updated' not in app.status.get()
|
|
assert not _disabled(app.retry_button)
|
|
assert 'offline-secret' not in app.status.get()
|
|
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
|
assert app._test_dialogs[-1][0] == 'Steam info needs attention'
|
|
assert bool(app.notes_button.winfo_manager()) is (pending == 'notes')
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_manual_steam_description_is_copyable_and_never_overrides_about_game(app):
|
|
import tkinter as tk
|
|
from halo_frame_installer.gui import Button
|
|
from halo_frame_installer.install import InstallResult
|
|
app.operation = 'library'
|
|
app.password_to_redact = 'offline-secret'
|
|
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added', 'notes': {'status': 'manual', 'title': 'Halo CE VR — About & controls',
|
|
'content': 'Explore Halo.\nA: Jump. B: Melee. offline-secret', 'message': 'Steam Notes needs a manual step.'}}, None)))
|
|
app.events.put(('clear_password',))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert app.notes_button.winfo_manager() and not _disabled(app.notes_button)
|
|
assert 'description in Steam Notes' in app.status.get()
|
|
app._show_steam_note()
|
|
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
|
try:
|
|
assert len(dialogs) == 1
|
|
visible = '\n'.join(_display_text(dialogs[0]))
|
|
assert 'Add this description in Steam Notes' in visible
|
|
assert 'About this game' not in visible
|
|
def descendants(widget):
|
|
for child in widget.winfo_children():
|
|
yield child
|
|
yield from descendants(child)
|
|
fields = [widget for widget in descendants(dialogs[0]) if isinstance(widget, tk.Text)]
|
|
assert len(fields) == 1
|
|
assert fields[0].get('1.0', 'end-1c') == 'Explore Halo.\nA: Jump. B: Melee. [redacted]'
|
|
copy = next(widget for widget in descendants(dialogs[0])
|
|
if isinstance(widget, Button) and widget.cget('text') == 'Copy description')
|
|
copy.invoke()
|
|
assert app.clipboard_get() == 'Explore Halo.\nA: Jump. B: Melee. [redacted]'
|
|
app.clipboard_clear()
|
|
finally:
|
|
for dialog in dialogs:
|
|
dialog.destroy()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('icon_status', ['existing', 'custom', 'unchanged', 'preserved'])
|
|
def test_preserved_steam_info_is_successful_without_manual_pending(app, icon_status):
|
|
from halo_frame_installer.install import InstallResult
|
|
app.operation = 'library'
|
|
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added', 'artwork': {'status': 'unchanged', 'icon': {'status': icon_status}},
|
|
'notes': {'status': 'custom', 'title': 'Halo CE VR — About & controls', 'content': 'Player notes.'}}, None)))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 100
|
|
assert app.phase.get() == 'Steam info updated'
|
|
assert 'existing custom game note was kept' in app.status.get()
|
|
assert not app.notes_button.winfo_manager()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_all_operation_tile_captions_fit_minimum_width(app):
|
|
app.geometry('900x620')
|
|
app.deiconify()
|
|
app._show_page(0)
|
|
app.update()
|
|
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
|
|
for choice in app.mode_choices:
|
|
for item in choice.find_all():
|
|
if choice.type(item) == 'text':
|
|
left, top, right, bottom = choice.bbox(item)
|
|
assert 0 <= left < right <= choice.winfo_width()
|
|
assert 0 <= top < bottom <= choice.winfo_height()
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_iso_inspection_runs_off_tk_thread_and_window_remains_responsive(app, monkeypatch, tmp_path):
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from halo_frame_installer import gui
|
|
real_thread = threading.Thread
|
|
started, release = threading.Event(), threading.Event()
|
|
workers, inspected_threads, music_loads = [], [], []
|
|
|
|
def inspect(path, *, cancel_event):
|
|
inspected_threads.append(threading.get_ident())
|
|
assert path == tmp_path / 'Halo.iso'
|
|
assert not cancel_event.is_set()
|
|
started.set()
|
|
assert release.wait(5)
|
|
return SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276')
|
|
|
|
def schedule(**kwargs):
|
|
thread = real_thread(**kwargs)
|
|
workers.append(thread)
|
|
return thread
|
|
|
|
monkeypatch.setattr(gui.threading, 'Thread', schedule)
|
|
monkeypatch.setattr(gui, 'inspect_image', inspect)
|
|
monkeypatch.setattr(app.music, 'load', music_loads.append)
|
|
try:
|
|
app._inspect(tmp_path / 'Halo.iso')
|
|
assert started.wait(2)
|
|
assert app.busy and not app.source.get()
|
|
assert inspected_threads == [workers[0].ident]
|
|
assert workers[0].ident != threading.get_ident()
|
|
heartbeat = []
|
|
app.after(0, lambda: heartbeat.append(True))
|
|
app.update()
|
|
assert heartbeat == [True] # Tk processed a timer while validation was blocked.
|
|
assert music_loads == [None] # The old preview stops before reading the new ISO.
|
|
finally:
|
|
release.set()
|
|
for worker in workers:
|
|
worker.join(3)
|
|
_drain_events(app)
|
|
assert not app.busy
|
|
assert app.source.get() == str(tmp_path / 'Halo.iso')
|
|
assert 'Validated 24 original Xbox maps' in app.asset_status.get()
|
|
assert music_loads == [None, tmp_path / 'Halo.iso']
|
|
|
|
|
|
@pytest.fixture
|
|
def queued_inspection(app, monkeypatch):
|
|
from types import SimpleNamespace
|
|
from halo_frame_installer import gui
|
|
workers, music_loads = [], []
|
|
|
|
class DeferredThread:
|
|
def __init__(self, target, **kwargs):
|
|
self.target = target
|
|
workers.append(self)
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(gui.threading, 'Thread', DeferredThread)
|
|
monkeypatch.setattr(app.music, 'load', music_loads.append)
|
|
return SimpleNamespace(workers=workers, music_loads=music_loads,
|
|
info=SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276'))
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('old_error', [False, True])
|
|
@pytest.mark.parametrize('old_finishes_first', [False, True])
|
|
def test_late_inspection_results_do_not_replace_new_selection_or_clear_its_busy_state(app, monkeypatch, tmp_path, queued_inspection, old_error, old_finishes_first):
|
|
from halo_frame_installer import gui
|
|
old, new = tmp_path / 'old.iso', tmp_path / 'new.iso'
|
|
cancellations = {}
|
|
|
|
def inspect(path, *, cancel_event):
|
|
cancellations[path] = cancel_event
|
|
if path == old and old_error:
|
|
raise ValueError('The old image was invalid.')
|
|
return queued_inspection.info
|
|
|
|
monkeypatch.setattr(gui, 'inspect_image', inspect)
|
|
app._inspect(old)
|
|
old_cancel = app._inspection_cancel
|
|
app._inspect(new)
|
|
assert old_cancel.is_set()
|
|
if old_finishes_first:
|
|
queued_inspection.workers[0].target()
|
|
_drain_events(app)
|
|
assert app.busy and not app.source.get()
|
|
assert app.asset_status.get() == 'Checking original Xbox game data…'
|
|
queued_inspection.workers[1].target()
|
|
_drain_events(app)
|
|
if not old_finishes_first:
|
|
queued_inspection.workers[0].target()
|
|
_drain_events(app)
|
|
assert app.source.get() == str(new)
|
|
assert app.status.get() == 'Game data validated. Connect your Frame next.'
|
|
assert not app.busy
|
|
assert cancellations[old].is_set() and not cancellations[new].is_set()
|
|
assert queued_inspection.music_loads == [None, None, new]
|
|
assert not app._test_dialogs
|
|
assert 'old image' not in '\n'.join(app.log_lines)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_clearing_source_cancels_inspection_and_ignores_its_late_success(app, monkeypatch, tmp_path, queued_inspection):
|
|
from halo_frame_installer import gui
|
|
monkeypatch.setattr(gui, 'inspect_image', lambda path, **kwargs: queued_inspection.info)
|
|
app._inspect(tmp_path / 'Halo.iso')
|
|
cancel = app._inspection_cancel
|
|
app.source.set('')
|
|
assert cancel.is_set() and not app.busy
|
|
queued_inspection.workers[0].target()
|
|
_drain_events(app)
|
|
assert not app.source.get()
|
|
assert app.asset_status.get() == gui.XBOX_REVISION_NOTE
|
|
assert queued_inspection.music_loads == [None, None]
|
|
assert not app._test_dialogs
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('folder', [False, True])
|
|
def test_cancelled_source_inspection_keeps_previous_selection_and_does_not_start_music(app, monkeypatch, tmp_path, queued_inspection, folder):
|
|
from halo_frame_installer import gui
|
|
selected = tmp_path / ('maps' if folder else 'Halo.iso')
|
|
if folder:
|
|
selected.mkdir()
|
|
calls = []
|
|
|
|
def inspect(path, *, cancel_event):
|
|
calls.append((path, cancel_event.is_set()))
|
|
# Even a reader finishing after cancellation cannot accept its result.
|
|
return queued_inspection.info
|
|
|
|
monkeypatch.setattr(gui, 'inspect_maps' if folder else 'inspect_image', inspect)
|
|
app.source.set('previous.iso')
|
|
app._inspect(selected)
|
|
app._cancel_setup()
|
|
queued_inspection.workers[0].target()
|
|
_drain_events(app)
|
|
assert calls == [(selected, True)]
|
|
assert app.source.get() == 'previous.iso'
|
|
assert not app.busy
|
|
assert app.phase.get() == 'Game data check cancelled'
|
|
assert 'previous selection was kept' in app.status.get()
|
|
assert queued_inspection.music_loads == [None]
|
|
assert not app._test_dialogs
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_current_inspection_failure_preserves_selection_and_readable_redacted_error(app, monkeypatch, tmp_path, queued_inspection):
|
|
from halo_frame_installer import gui
|
|
def invalid(path, *, cancel_event):
|
|
raise ValueError('Invalid original Xbox image. offline-secret\nHeader validation failed.')
|
|
monkeypatch.setattr(gui, 'inspect_image', invalid)
|
|
app.source.set('previous.iso')
|
|
app.password_to_redact = 'offline-secret'
|
|
app._inspect(tmp_path / 'invalid.iso')
|
|
queued_inspection.workers[0].target()
|
|
_drain_events(app)
|
|
assert not app.busy
|
|
assert app.source.get() == 'previous.iso'
|
|
assert "Couldn't validate this data" in app.asset_status.get()
|
|
assert len(app._test_dialogs) == 1
|
|
assert 'Header validation failed.' in '\n'.join(app.log_lines)
|
|
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
|
assert 'offline-secret' not in app._test_dialogs[0][1]
|
|
assert queued_inspection.music_loads == [None]
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('folder', [False, True])
|
|
def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_credentials(app, monkeypatch, tmp_path, folder):
|
|
from halo_frame_installer import gui, install
|
|
selected = tmp_path / ('maps' if folder else 'Halo.iso')
|
|
if folder:
|
|
selected.mkdir()
|
|
name = 'inspect_maps' if folder else 'inspect_image'
|
|
inspect_source = getattr(gui, name)
|
|
inspections, settings_used = [], []
|
|
get_settings = app._settings
|
|
|
|
def remember_settings():
|
|
settings = get_settings()
|
|
settings_used.append(settings)
|
|
return settings
|
|
|
|
def cancel_validation(path, *, cancel_event):
|
|
assert cancel_event is app.cancel
|
|
inspections.append(path)
|
|
# Model Cancel arriving as the worker starts its second validation.
|
|
cancel_event.set()
|
|
return inspect_source(path, cancel_event=cancel_event)
|
|
|
|
monkeypatch.setattr(app, '_settings', remember_settings)
|
|
monkeypatch.setattr(gui, name, cancel_validation)
|
|
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
|
for action in ('extract_image', 'copy_maps'):
|
|
monkeypatch.setattr(gui, action, lambda *args, **kwargs: pytest.fail('Cancelled validation extracted maps.'))
|
|
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Cancelled validation began installation.'))
|
|
app.source.set(str(selected))
|
|
app.authorized.set(True)
|
|
app.steam_closed.set(True)
|
|
app.password.set('offline-secret')
|
|
app._install()
|
|
_drain_events(app)
|
|
assert inspections == [selected]
|
|
assert app.cancel.is_set()
|
|
assert not app.busy and not app.installing
|
|
assert not app.password.get() and not settings_used[0].password
|
|
assert not app.password_to_redact
|
|
assert app.source.get() == str(selected)
|
|
assert 'cancelled' in '\n'.join(app.log_lines).lower()
|
|
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
|
assert float(app.progress['value']) == 0
|
|
assert not _disabled(app.primary_button)
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('operation', ['install', 'library'])
|
|
def test_completed_game_operation_reports_pending_connection_cleanup(app, operation):
|
|
from halo_frame_installer.install import InstallResult
|
|
app.operation = operation
|
|
app.password_to_redact = 'offline-secret'
|
|
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
|
{'status': 'added', 'notes': {'status': 'added'}}, None,
|
|
cleanup_warning='Unplug the USB cable; cleanup is pending. offline-secret')
|
|
app.events.put(('complete', result))
|
|
app.events.put(('clear_password',))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert app.phase.get() == 'Connection cleanup needs attention'
|
|
assert 'game operation finished' in app.status.get()
|
|
assert 'Unplug the USB cable' in app.status.get()
|
|
assert not app.retry_button.winfo_manager()
|
|
assert 'offline-secret' not in app.status.get()
|
|
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
|
assert app._test_dialogs[-1][0] == 'Connection cleanup needs attention'
|
|
assert not app.busy and not app.installing
|
|
|
|
|
|
@WINDOWS_GUI
|
|
@pytest.mark.parametrize('pending', [False, True])
|
|
def test_uninstall_recovery_reports_retained_files_and_earlier_save_backups(app, pending):
|
|
from halo_frame_installer.install import UninstallResult
|
|
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a'*32
|
|
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unverified'
|
|
app.password_to_redact = 'offline-secret'
|
|
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', not pending, False, None,
|
|
{'status': 'removed'}, None, removal_pending=pending,
|
|
removal_warning='Earlier uninstall files ' + ('were retained' if pending else 'were removed') + '. offline-secret',
|
|
recovered_save_backup_paths=(backup,), retained_quarantine_paths=(retained,) if pending else ())
|
|
app.events.put(('uninstall_complete', result))
|
|
app.events.put(('clear_password',))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == (99 if pending else 100)
|
|
assert 'Earlier campaign save backup: ' + backup in app.status.get()
|
|
assert 'already absent' not in app.status.get()
|
|
assert 'offline-secret' not in app.status.get()
|
|
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
|
if pending:
|
|
assert 'Preserved uninstall folder: ' + retained in app.status.get()
|
|
assert 'Removal is not complete' in app.status.get()
|
|
assert 'has been uninstalled' not in app.status.get()
|
|
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
|
|
else:
|
|
assert 'has been uninstalled' in app.status.get()
|
|
assert app._test_dialogs[-1][0] == 'Uninstall complete'
|
|
|
|
|
|
@WINDOWS_GUI
|
|
def test_uninstalled_game_reports_pending_connection_cleanup(app):
|
|
from halo_frame_installer.install import UninstallResult
|
|
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', True, False, None,
|
|
{'status': 'removed'}, None, cleanup_warning='Unplug the USB cable before retrying setup.')
|
|
app.events.put(('uninstall_complete', result))
|
|
app.events.put(('idle',))
|
|
_drain_events(app)
|
|
assert float(app.progress['value']) == 99
|
|
assert 'has been uninstalled' in app.status.get()
|
|
assert 'Connection cleanup needs attention' in app.status.get()
|
|
assert 'Unplug the USB cable' in app.status.get()
|
|
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
|