Files
DroidDeck/tools/tests/test_steam_games.py
K11MCH1 fcbd45453f Steam imports: read only the linked manifest, and leave staged ones alone
seed_install read the app's manifest from every library before checking
that it was the linked DroidDeck install, so a manifest in another library
that is not UTF-8 raised past the caller and stopped routing for later
games. It now returns before reading anything but the linked manifest.

An untouched manifest from an older version was migrated even while
steamapps/downloading/<appid> existed; that migration now waits until
Steam is not staging the app, like the rewrite of a queued import.
2026-11-05 13:59:44 -05:00

689 lines
36 KiB
Python

import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
import zlib
from unittest.mock import Mock, patch
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
def load(name):
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
imports = load('droiddeck-steam-games')
shortcuts = load('droiddeck-steam-shortcuts')
library = load('droiddeck-steam-library')
class ImportsTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.steam = self.root / 'Steam'
self.acct = self.steam / 'userdata/123'
self.folder = self.root / 'Games/Example'
self.folder.mkdir(parents=True)
self.exe = self.folder / 'Example.exe'
self.exe.write_bytes(b'game')
self.game = dict(name='Example', exe=str(self.exe), folder=str(self.folder),
dir=str(self.folder), appid=0x87654321)
def snapshot(self, owned=True, account='123'):
imports.save_json(self.acct / 'config' / imports.STATE,
dict(account=account, owned={'42': owned},
candidates={str(self.folder): 42},
sources={str(self.folder): imports.source_stamp(self.game)}))
def test_owned_uses_real_id_and_registers_installed_files(self):
self.snapshot()
games, routes = imports.route(self.steam, self.acct, [self.game])
self.assertEqual([], games)
self.assertEqual({str(self.game['appid']): 42}, routes)
manifest = self.steam / 'steamapps/appmanifest_42.acf'
self.assertIn('"StateFlags" "4"', manifest.read_text())
self.assertIn('"Universe" "1"', manifest.read_text())
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
self.assertEqual(b'game', self.exe.read_bytes())
def test_store_installs_stay_shortcuts_even_when_owned_on_steam(self):
self.snapshot()
game = dict(self.game, store='amazon')
games, routes = imports.route(self.steam, self.acct, [game])
self.assertEqual([game], games)
self.assertEqual({}, routes)
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
def test_store_installs_imported_earlier_are_released(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
unrelated = self.steam / 'steamapps/appmanifest_7.acf'
unrelated.write_text('"AppState"\n{\n "appid" "7"\n "installdir" "Other"\n}\n')
games, routes = imports.route(self.steam, self.acct, [dict(self.game, store='epic')])
self.assertEqual({}, routes)
self.assertEqual(1, len(games))
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
self.assertTrue(unrelated.is_file())
self.assertEqual(b'game', self.exe.read_bytes())
def test_watch_never_looks_up_store_installs(self):
listing = self.root / 'games.json'
listing.write_text(json.dumps([dict(self.game, store='gog')]))
with patch.object(imports, 'identify') as identify:
imports.watch(str(self.steam), str(listing))
identify.assert_not_called()
def test_native_programs_stay_shortcuts_and_are_never_looked_up(self):
self.snapshot()
game = dict(self.game, native=True)
self.assertEqual(([game], {}), imports.route(self.steam, self.acct, [game]))
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
listing = self.root / 'games.json'
listing.write_text(json.dumps([game]))
with patch.object(imports, 'identify') as identify:
imports.watch(str(self.steam), str(listing))
identify.assert_not_called()
def test_migrates_only_unchanged_legacy_import_manifests(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
current = path.read_text()
legacy = (current.replace(' "AutoUpdateBehavior" "1"\n', '').replace(' "Universe" "1"\n', '')
.replace('"StateFlags" "4"', '"StateFlags" "2"'))
path.write_text(legacy)
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(current, path.read_text())
changed = legacy.replace('"StateFlags" "2"', '"StateFlags" "6"')
path.write_text(changed)
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(changed, path.read_text())
def test_unresolved_titles_are_retried_after_network_recovers(self):
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
from unittest.mock import Mock
idle = imports.downloads_idle
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \
patch.object(imports, 'request_restart'), \
patch.object(imports, 'ask_restart', return_value=True), \
patch.object(imports, 'time', Mock(monotonic=Mock(side_effect=[0, 301]),
sleep=Mock(side_effect=[None, RuntimeError('stop')]))), \
patch.object(imports.subprocess, 'run', return_value=result), \
patch('builtins.print'):
with self.assertRaisesRegex(RuntimeError, 'stop'):
imports.watch(self.steam, listing)
self.assertEqual(2, identify.call_count)
snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {})
self.assertTrue(snapshot['owned']['42'])
def run_watch(self, beats, routes=None, busy=False, answer=True, started=False):
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
if routes is not None:
imports.save_json(self.acct / 'config/.droiddeck-routes.json', routes)
if busy:
(self.steam / 'steamapps/downloading/7').mkdir(parents=True)
from unittest.mock import Mock
idle = imports.downloads_idle
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
with patch.object(imports, 'identify', return_value=42), \
patch.object(imports, 'game_running', return_value=False), \
patch.object(imports, 'request_restart') as restart, \
patch.object(imports, 'ask_restart', return_value=answer) as asked, \
patch.object(imports, 'downloads_idle', side_effect=lambda root: idle(root) and not (started and asked.called)), \
patch.object(imports, 'time', Mock(monotonic=Mock(side_effect=list(range(0, 30 * beats, 30))),
sleep=Mock(side_effect=[None] * (beats - 1) + [RuntimeError('stop')]))), \
patch.object(imports.subprocess, 'run', return_value=result), \
patch('builtins.print'):
with self.assertRaisesRegex(RuntimeError, 'stop'):
imports.watch(self.steam, listing)
self.asked = asked
return restart
def test_newly_owned_game_restarts_the_client_once(self):
self.assertEqual(1, self.run_watch(3).call_count)
def test_an_import_that_did_not_take_restarts_only_once_across_sessions(self):
self.assertEqual(1, self.run_watch(3).call_count)
self.assertEqual(0, self.run_watch(3).call_count)
self.assertEqual([42], imports.read_json(self.acct / 'config' / imports.TRIED, None))
def test_later_skips_the_restart_and_does_not_ask_again(self):
self.assertEqual(0, self.run_watch(3, answer=False).call_count)
self.assertEqual([['Example']], [c.args[0] for c in self.asked.call_args_list])
self.assertEqual(0, self.run_watch(3).call_count)
self.assertEqual(0, self.asked.call_count)
def test_a_download_started_while_asking_leaves_the_import_for_the_next_start(self):
self.assertEqual(0, self.run_watch(3, started=True).call_count)
self.assertEqual(1, self.asked.call_count)
def test_the_app_answers_the_restart_question(self):
home = self.root / 'home'
folder = home / '.cache/droiddeck'
replies = []
def sleep(_):
ask = json.loads((folder / 'steam-import.json').read_text())
replies.append(ask['games'])
(folder / 'steam-import.answer').write_text(json.dumps(dict(token=ask['token'], choice=choice)))
for choice, expected in (('now', True), ('later', False)):
with patch.dict(os.environ, HOME=str(home)):
self.assertEqual(expected, imports.ask_restart(['Example'], clock=iter(range(100)).__next__, sleep=sleep))
self.assertFalse((folder / 'steam-import.json').exists())
self.assertFalse((folder / 'steam-import.answer').exists())
self.assertEqual([['Example'], ['Example']], replies)
def test_no_answer_means_later(self):
with patch.dict(os.environ, HOME=str(self.root / 'home')):
self.assertFalse(imports.ask_restart(['Example'], timeout=5, clock=iter(range(100)).__next__, sleep=lambda _: None))
def test_a_tried_import_that_took_is_forgotten(self):
imports.save_json(self.acct / 'config' / imports.TRIED, [42])
self.assertEqual(0, self.run_watch(2, routes={'1': 42}).call_count)
self.assertEqual([], imports.read_json(self.acct / 'config' / imports.TRIED, None))
def test_imported_game_does_not_restart_the_client(self):
self.assertEqual(0, self.run_watch(2, routes={'1': 42}).call_count)
def test_restart_waits_for_downloads(self):
self.assertEqual(0, self.run_watch(2, busy=True).call_count)
def test_unowned_unknown_and_different_account_stay_shortcuts(self):
for owned, account in [(False, '123'), (True, '456')]:
self.snapshot(owned, account)
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
(self.acct / 'config' / imports.STATE).unlink()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assertFalse((self.steam / 'steamapps').exists())
def winnative_manifest(self, flags='4', installdir='Example'):
path = self.folder.parent / imports.WINNATIVE / 'appmanifest_42.acf'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n'
'\t"buildid"\t\t"7"\n\t"InstalledDepots"\n\t{\n\t\t"43"\n\t\t{\n\t\t\t"manifest"\t\t"99"\n'
'\t\t\t"size"\t\t"4"\n\t\t}\n\t}\n}\n' % (flags, installdir))
return path
def test_winnative_manifest_carries_installed_build(self):
source = self.winnative_manifest()
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
text = (self.steam / 'steamapps/appmanifest_42.acf').read_text()
self.assertEqual(source.read_text().replace('"Example"', '"DroidDeck-42"'), text)
def test_winnative_manifest_identifies_folder(self):
self.winnative_manifest()
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(42, imports.identify(self.game))
def test_incomplete_winnative_manifest_is_ignored(self):
for flags, installdir in [('1026', 'Example'), ('4', 'Other')]:
self.winnative_manifest(flags, installdir)
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
def test_depot_config_records_installed_depots(self):
config = self.folder / '.DepotDownloader/depot.config'
config.parent.mkdir()
config.write_text(json.dumps({'installedManifestIDs': {'43': 99}}))
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
(self.folder / '.download_complete').touch()
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
text = (self.steam / 'steamapps/appmanifest_42.acf').read_text()
self.assertIn('"InstalledDepots"\n {\n "43"\n {\n "manifest" "99"', text)
self.assertTrue(text.endswith(' }\n}\n'))
config.write_text(json.dumps({'installedManifestIDs': {'43': 0x7FFFFFFFFFFFFFFF}}))
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
def test_compressed_protobuf_depot_config_records_installed_depots(self):
config = self.folder / '.DepotDownloader/depot.config'
config.parent.mkdir()
(self.folder / '.download_complete').touch()
entry = bytes([0x08, 43, 0x10, 99])
packed = zlib.compressobj(wbits=-15)
config.write_bytes(packed.compress(bytes([0x0A, len(entry)]) + entry) + packed.flush())
self.assertIn('"43"\n {\n "manifest" "99"', imports.installed_build(self.folder, 42, 'DroidDeck-42'))
config.write_bytes(b'not a config')
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
(config.parent / 'completed').mkdir()
(config.parent / 'completed/43_98.manifest').write_bytes(b'x')
self.assertIn('"manifest" "98"', imports.installed_build(self.folder, 42, 'DroidDeck-42'))
config.write_text(json.dumps({'installedManifestIDs': {}}))
self.assertIn('"manifest" "98"', imports.installed_build(self.folder, 42, 'DroidDeck-42'))
def test_completed_manifests_record_installed_depots(self):
completed = self.folder / '.DepotDownloader/completed'
completed.mkdir(parents=True)
(self.folder / '.download_complete').touch()
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
(completed / '43_98.manifest').write_bytes(b'old')
(completed / '43_99.manifest').write_bytes(b'new')
(completed / '44_7.manifest').write_bytes(b'x')
os.utime(completed / '43_98.manifest', (1, 1))
text = imports.installed_build(self.folder, 42, 'DroidDeck-42')
self.assertIn('"43"\n {\n "manifest" "99"', text)
self.assertIn('"44"\n {\n "manifest" "7"', text)
self.assertNotIn('"98"', text)
def test_bare_import_manifest_gains_installed_build(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
self.winnative_manifest()
imports.route(self.steam, self.acct, [self.game])
self.assertIn('"buildid"\t\t"7"', (self.steam / 'steamapps/appmanifest_42.acf').read_text())
def steam_fresh_install(self, downloaded='0'):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"6"\n\t"installdir"\t\t"DroidDeck-42"\n'
'\t"buildid"\t\t"0"\n\t"BytesDownloaded"\t\t"%s"\n\t"BytesStaged"\t\t"0"\n'
'\t"InstalledDepots"\n\t{\n\t}\n}\n' % downloaded)
self.winnative_manifest()
return path
def test_queued_fresh_install_takes_installed_build(self):
path = self.steam_fresh_install()
imports.route(self.steam, self.acct, [self.game])
self.assertIn('"buildid"\t\t"7"', path.read_text())
def test_import_without_a_build_updates_only_when_launched(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
current = path.read_text()
self.assertIn(' "AutoUpdateBehavior" "1"\n', current)
path.write_text(current.replace(' "AutoUpdateBehavior" "1"\n', ''))
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(current, path.read_text())
def queued_bare_install(self, extra):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"6"\n\t"installdir"\t\t"DroidDeck-42"\n'
'\t"buildid"\t\t"0"\n\t"TargetBuildID"\t\t"9"\n%s\t"InstalledDepots"\n\t{\n\t}\n'
'\t"UserConfig"\n\t{\n\t}\n}\n' % extra)
return path
def test_queued_import_without_a_build_waits_for_a_launch(self):
path = self.queued_bare_install('\t"AutoUpdateBehavior"\t\t"0"\n')
before = path.read_text()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(before.replace('"AutoUpdateBehavior"\t\t"0"', '"AutoUpdateBehavior"\t\t"1"'), path.read_text())
path = self.queued_bare_install('')
before = path.read_text()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(before[:-2] + '\t"AutoUpdateBehavior"\t\t"1"\n}\n', path.read_text())
def test_queued_import_keeps_a_chosen_update_priority(self):
path = self.queued_bare_install('\t"AutoUpdateBehavior"\t\t"2"\n')
before = path.read_text()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(before, path.read_text())
def test_an_untouched_import_is_left_alone_while_steam_stages_it(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
older = path.read_text().replace(' "AutoUpdateBehavior" "1"\n', '')
path.write_text(older)
(self.steam / 'steamapps/downloading/42').mkdir(parents=True)
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(older, path.read_text())
def test_a_manifest_in_another_library_is_not_read(self):
other = self.root / 'Other'
(other / 'steamapps').mkdir(parents=True)
(other / 'steamapps/appmanifest_42.acf').write_bytes(b'"AppState"\n{\n "name" "\xff\xfe"\n}\n')
(self.steam / 'steamapps').mkdir(parents=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text('"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n}\n' % other)
self.snapshot()
games, routes = imports.route(self.steam, self.acct, [self.game])
self.assertEqual({str(self.game['appid']): 42}, routes)
self.assertEqual(b'"AppState"\n{\n "name" "\xff\xfe"\n}\n', (other / 'steamapps/appmanifest_42.acf').read_bytes())
def test_started_import_without_a_build_is_left_to_steam(self):
for extra, staging in [('\t"BytesDownloaded"\t\t"5"\n\t"AutoUpdateBehavior"\t\t"0"\n', False),
('\t"AutoUpdateBehavior"\t\t"0"\n', True)]:
path = self.queued_bare_install(extra)
if staging:
(self.steam / 'steamapps/downloading/42').mkdir(parents=True)
before = path.read_text()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(before, path.read_text())
def test_started_fresh_install_is_left_to_steam(self):
for downloaded, staging in [('5', False), ('0', True)]:
path = self.steam_fresh_install(downloaded)
if staging:
(self.steam / 'steamapps/downloading/42').mkdir(parents=True)
before = path.read_text()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(before, path.read_text())
def test_links_and_snapshots_from_the_old_mount_carry_over(self):
old = self.root / 'old-mount'
with patch.object(imports, 'LIBRARY', str(self.root / 'Games')), \
patch.object(imports, 'LEGACY_LIBRARY', str(old)):
legacy_game = dict(self.game, folder=str(old / 'Example'), exe=str(old / 'Example/Example.exe'))
stamp = imports.source_stamp(self.game)
imports.save_json(self.acct / 'config' / imports.STATE,
dict(account='123', owned={'42': True}, candidates={legacy_game['folder']: 42},
sources={legacy_game['folder']: [stamp[0], legacy_game['exe']] + stamp[2:]}))
link = self.steam / 'steamapps/common/DroidDeck-42'
link.parent.mkdir(parents=True)
link.symlink_to(legacy_game['folder'], target_is_directory=True)
games, routes = imports.route(self.steam, self.acct, [self.game])
self.assertEqual([], games)
self.assertEqual({str(self.game['appid']): 42}, routes)
self.assertEqual(str(self.folder), imports.os.readlink(link))
def test_existing_manifest_is_never_overwritten(self):
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.parent.mkdir(parents=True)
manifest.write_text('Steam owns this file')
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual('Steam owns this file', manifest.read_text())
def test_import_collision_falls_back_without_touching_files(self):
occupied = self.steam / 'steamapps/common/DroidDeck-42'
occupied.mkdir(parents=True)
self.snapshot()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assertTrue(occupied.is_dir())
self.assertFalse(occupied.is_symlink())
def test_removed_folder_does_not_import(self):
self.snapshot()
self.exe.unlink()
self.folder.rmdir()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
def test_absent_folder_sets_its_install_aside_until_it_returns(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.write_text(manifest.read_text().replace('}', ' "buildid" "99"\n}'))
state = manifest.read_text()
away = self.root / 'unplugged'
self.folder.rename(away)
with patch('builtins.print'):
self.assertEqual(([], {}), imports.route(self.steam, self.acct, []))
self.assertFalse(manifest.exists())
self.assertEqual(state, (self.steam / 'steamapps' / imports.PARKED / manifest.name).read_text())
away.rename(self.folder)
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertEqual(state, manifest.read_text())
self.assertFalse((self.steam / 'steamapps' / imports.PARKED / manifest.name).exists())
def test_steam_installs_are_never_set_aside(self):
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.parent.mkdir(parents=True)
manifest.write_text('"AppState"\n{\n "appid" "42"\n "installdir" "Example"\n}\n')
link = self.steam / 'steamapps/common/DroidDeck-42'
link.parent.mkdir(parents=True)
link.symlink_to(self.root / 'missing', target_is_directory=True)
imports.route(self.steam, self.acct, [])
self.assertTrue(manifest.is_file())
def test_install_in_another_library_is_used_as_is(self):
other = self.root / 'Games/PC'
(other / 'steamapps').mkdir(parents=True)
(other / 'steamapps/appmanifest_42.acf').write_text('Steam installed it here')
vdf = self.steam / 'steamapps/libraryfolders.vdf'
vdf.parent.mkdir(parents=True)
vdf.write_text('"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n}\n' % other)
self.snapshot()
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
def test_imported_game_stays_imported_when_ownership_is_unconfirmed(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
for owned, account in [(False, '123'), (True, '456')]:
self.snapshot(owned, account)
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file())
def test_unlisted_card_library_is_not_another_clients_install(self):
card = self.root / 'card'
(card / 'steamapps').mkdir(parents=True)
(card / 'steamapps/appmanifest_42.acf').write_text('the other client installed it here')
self.snapshot()
with patch.object(imports, 'LIBRARY', str(card)):
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file())
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
def test_replaced_game_does_not_inherit_cached_ownership(self):
self.snapshot()
self.exe.write_bytes(b'a different game')
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
def test_duplicate_owned_folders_share_one_steam_entry(self):
self.snapshot()
games, routes = imports.route(self.steam, self.acct, [self.game, self.game])
self.assertEqual([], games)
self.assertEqual(1, len(routes))
self.assertEqual(1, len(list((self.steam / 'steamapps').glob('*.acf'))))
def test_explicit_appid_does_not_need_network(self):
(self.folder / 'steam_appid.txt').write_text('42\n')
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(42, imports.identify(self.game))
def test_spacewar_appid_names_no_game(self):
(self.folder / 'steam_appid.txt').write_text('480\n')
with patch.object(imports.urllib.request, 'urlopen', side_effect=OSError):
self.assertIsNone(imports.identify(self.game))
def import_as(self, appid):
self.assertTrue(imports.seed_install(self.steam, self.game, appid))
self.assertTrue((self.steam / f'steamapps/appmanifest_{appid}.acf').is_file())
def snapshot_names(self, appid, owned):
imports.save_json(self.acct / 'config' / imports.STATE,
dict(account='123', owned={str(appid): owned},
candidates={str(self.folder): appid},
sources={str(self.folder): imports.source_stamp(self.game)}))
def assert_not_imported_as(self, appid):
self.assertFalse((self.steam / f'steamapps/appmanifest_{appid}.acf').exists())
self.assertFalse(os.path.lexists(self.steam / f'steamapps/common/DroidDeck-{appid}'))
def test_spacewar_import_is_released_and_never_made_again(self):
self.import_as(480)
self.snapshot_names(480, owned=True)
with patch('builtins.print'):
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assert_not_imported_as(480)
self.assertEqual(b'game', self.exe.read_bytes())
def test_import_follows_the_app_the_files_name_now(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
self.snapshot_names(43, owned=True)
with patch('builtins.print'):
self.assertEqual(([], {str(self.game['appid']): 43}), imports.route(self.steam, self.acct, [self.game]))
self.assert_not_imported_as(42)
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-43').resolve())
def test_unowned_new_appid_leaves_a_shortcut(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
self.snapshot_names(43, owned=False)
with patch('builtins.print'):
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assert_not_imported_as(42)
self.assert_not_imported_as(43)
def test_import_stays_while_the_new_identity_is_unknown(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
for candidates in ({}, {str(self.folder): 43}):
imports.save_json(self.acct / 'config' / imports.STATE,
dict(account='123', owned={'43': True}, candidates=candidates,
sources={str(self.folder): ['stale']}))
imports.route(self.steam, self.acct, [self.game])
self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file())
def test_manifest_identity_uses_install_directory(self):
folder = self.root / 'second/steamapps/common/Custom Name'
folder.mkdir(parents=True)
(folder.parent.parent / 'appmanifest_42.acf').write_text('"AppState" { "installdir" "Custom Name" }')
game = dict(self.game, folder=str(folder), exe=str(folder / 'game.exe'))
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(42, imports.identify(game))
def test_a_spacewar_manifest_names_no_game(self):
folder = self.root / 'second/steamapps/common/PEAK'
folder.mkdir(parents=True)
(folder.parent.parent / 'appmanifest_480.acf').write_text('"AppState" { "installdir" "PEAK" }')
(folder / 'steam_appid.txt').write_text('3527290\n')
game = dict(self.game, folder=str(folder), exe=str(folder / 'PEAK.exe'))
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(3527290, imports.identify(game))
def test_a_store_search_answering_spacewar_names_no_game(self):
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps(
{'items': [{'id': 480, 'name': 'Spacewar'}]}).encode()
self.assertIsNone(imports.identify(dict(self.game, name='Spacewar')))
def test_store_requires_unique_exact_title(self):
cases = [([{'id': 42, 'name': 'Example Deluxe'}], None),
([{'id': 42, 'name': 'Example'}, {'id': 43, 'name': 'Example'}], None),
([{'id': 42, 'name': 'EXAMPLE'}], 42)]
for items, expected in cases:
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': items}).encode()
self.assertEqual(expected, imports.identify(self.game))
def test_store_country_and_edition_suffixes(self):
for folder, store in [('FINAL FANTASY VII REMAKE', 'FINAL FANTASY VII REMAKE INTERGRADE'),
('FINAL FANTASY XV', 'FINAL FANTASY XV WINDOWS EDITION')]:
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [{'id': 42, 'name': store}]}).encode()
self.assertEqual(42, imports.identify(dict(self.game, name=folder)))
self.assertIn('cc=US&', request.call_args.args[0])
def test_edition_matching_rejects_ambiguous_titles_and_soundtracks(self):
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [
{'id': 42, 'name': 'Example WINDOWS EDITION'},
{'id': 43, 'name': 'Example INTERGRADE'},
{'id': 44, 'name': 'Example Soundtrack'},
]}).encode()
self.assertIsNone(imports.identify(self.game))
def test_corrupt_shortcuts_are_preserved(self):
path = self.acct / 'config/shortcuts.vdf'
path.parent.mkdir(parents=True)
original = b'corrupt'
path.write_bytes(original)
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
run = subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(listing)], capture_output=True)
self.assertEqual(1, run.returncode)
self.assertEqual(original, path.read_bytes())
def test_user_shortcuts_survive_owned_promotion(self):
self.snapshot()
path = self.acct / 'config/shortcuts.vdf'
manual = dict(shortcuts.shortcut(self.game), tags={'0': 'Personal'}, AppName='Manual')
path.write_bytes(shortcuts.write({'shortcuts': {'0': manual, '1': shortcuts.shortcut(self.game)}}))
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True)
result = shortcuts.parse(path.read_bytes())['shortcuts']
self.assertEqual([manual], list(result.values()))
self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text()))
class LibraryRenameTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.steam = Path(temp.name)
self.vdf = self.steam / 'steamapps/libraryfolders.vdf'
self.vdf.parent.mkdir()
def entry(self, index, path):
return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"contentid"\t\t"%d"\n\t}\n' % (index, path, 7 + index)
def test_old_mount_keeps_its_entry_under_the_new_path(self):
self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/root') + self.entry(1, '/mnt/bannerlator-sd') + '}\n')
with patch('builtins.print'):
library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd')
text = self.vdf.read_text()
self.assertIn('"path"\t\t"/mnt/droiddeck-sd"\n\t\t"contentid"\t\t"8"', text)
self.assertNotIn('bannerlator-sd', text)
def test_old_mount_is_dropped_when_new_path_is_registered(self):
self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/mnt/droiddeck-sd') + self.entry(1, '/mnt/bannerlator-sd') + '}\n')
with patch('builtins.print'):
library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd')
text = self.vdf.read_text()
self.assertEqual(1, text.count('"path"'))
self.assertIn('/mnt/droiddeck-sd', text)
class SteamProbeTest(unittest.TestCase):
def test_probe_reads_account_and_licenses_and_releases_handles(self):
import ctypes as C
from unittest.mock import Mock
objects, callbacks, calls = [], [], []
def interface(slots):
table = (C.c_void_p * 24)()
for slot, result, args, callback in slots:
fn = C.CFUNCTYPE(result, C.c_void_p, *args)(callback)
callbacks.append(fn)
table[slot] = C.cast(fn, C.c_void_p).value
obj = C.pointer(C.cast(table, C.POINTER(C.c_void_p)))
objects.extend([table, obj])
return C.cast(obj, C.c_void_p).value
user = interface([
(1, C.c_bool, [], lambda _: True),
(2, C.c_uint64, [], lambda _: imports.BASE_ID + 123),
])
apps = interface([(6, C.c_bool, [C.c_uint32], lambda _, appid: appid == 42)])
client = interface([
(0, C.c_int, [], lambda _: 7),
(1, C.c_bool, [C.c_int], lambda _, pipe: calls.append(('pipe', pipe)) or True),
(2, C.c_int, [C.c_int], lambda _, pipe: 9),
(4, None, [C.c_int, C.c_int], lambda _, pipe, handle: calls.append(('user', pipe, handle))),
(5, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: user if version == b'SteamUser021' else 0),
(15, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: apps if version == b'STEAMAPPS_INTERFACE_VERSION008' else 0),
])
library = Mock()
library.CreateInterface.return_value = client
with patch.object(imports.C, 'CDLL', return_value=library):
result = imports.probe('/Steam', [42, 43])
self.assertEqual('123', result['account'])
self.assertEqual({'42': True, '43': False}, result['owned'])
self.assertEqual([('user', 7, 9), ('pipe', 7)], calls)
library.CreateInterface.assert_called_once_with(b'SteamClient020', None)
if __name__ == '__main__':
unittest.main()