- PR1: gamescope detection at boot — X11 backend, scale 1, kiosk fullscreen; tray + miniplayer suppressed (click-offset fix). - PR2: full keyboard navigation for the kiosk (3 zones, focus ring, scroll-into-view; typing never navigates). - PR3: native gamepad via gilrs — D-pad/stick/A/B to synthetic Slint keys with hysteresis, repeat and debounce; B suppresses Escape while a text field is focused (closes the Steam OSK). - PR4: Steam OSK via the steam://open/keyboard deeplink on text focus. - PR5: scripts/qbz-add-to-steam (stdlib-only, 19/19 unit tests) with artwork assets + pure-gamepad controller layout. Spec: qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md km
251 lines
11 KiB
Python
251 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for scripts/qbz-add-to-steam (binary VDF parser/serializer,
|
|
shortcut dedupe, appid formula, configset patching). Stdlib unittest only."""
|
|
|
|
import importlib.util
|
|
import struct
|
|
import unittest
|
|
import zlib
|
|
from importlib.machinery import SourceFileLoader
|
|
from pathlib import Path
|
|
|
|
SCRIPT = Path(__file__).resolve().parent / "qbz-add-to-steam"
|
|
_loader = SourceFileLoader("qbz_add_to_steam", str(SCRIPT))
|
|
spec = importlib.util.spec_from_loader("qbz_add_to_steam", _loader)
|
|
qats = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(qats)
|
|
|
|
|
|
def make_fixture():
|
|
"""A real-world-shaped shortcuts.vdf: two entries (one non-QBZ with
|
|
quirky/unknown fields), nested tags map, non-ASCII strings."""
|
|
other_entry = [
|
|
qats.fld_u32("appid", 0x81234567),
|
|
qats.fld_str("appname", "Cool Game — déjà vu"),
|
|
qats.fld_str("Exe", '"/opt/cool game/run.sh"'),
|
|
qats.fld_str("StartDir", '"/opt/cool game/"'),
|
|
qats.fld_str("icon", ""),
|
|
qats.fld_str("ShortcutPath", ""),
|
|
qats.fld_str("LaunchOptions", "gamemoderun %command%"),
|
|
qats.fld_u32("IsHidden", 0),
|
|
qats.fld_u32("AllowDesktopConfig", 1),
|
|
qats.fld_u32("AllowOverlay", 1),
|
|
qats.fld_u32("OpenVR", 0),
|
|
qats.fld_u32("Devkit", 0),
|
|
qats.fld_str("DevkitGameID", ""),
|
|
qats.fld_u32("DevkitOverrideAppID", 0),
|
|
qats.fld_u32("LastPlayTime", 1753000000),
|
|
qats.fld_str("FlatpakAppID", ""),
|
|
qats.fld_str("sortas", "cool game"), # newer Steam field
|
|
qats.fld_map("tags", [qats.fld_str("0", "favorite"),
|
|
qats.fld_str("1", "deck")]),
|
|
qats.fld_str("SomeFutureField", "w\xe9ird\xff"), # unknown field
|
|
]
|
|
qbz_entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%",
|
|
qats.shortcut_appid("/usr/bin/qbz"))
|
|
fields = [qats.fld_map("shortcuts", [
|
|
qats.fld_map("0", other_entry),
|
|
qats.fld_map("1", qbz_entry),
|
|
])]
|
|
return fields
|
|
|
|
|
|
class TestRoundTrip(unittest.TestCase):
|
|
def test_roundtrip_byte_identical(self):
|
|
fields = make_fixture()
|
|
blob = qats.serialize_vdf(fields, True)
|
|
parsed, ended = qats.parse_vdf(blob)
|
|
self.assertTrue(ended)
|
|
self.assertEqual(qats.serialize_vdf(parsed, ended), blob)
|
|
|
|
def test_roundtrip_preserves_trailing_end_marker(self):
|
|
fields = make_fixture()
|
|
with_root_end = qats.serialize_vdf(fields, True)
|
|
without_root_end = qats.serialize_vdf(fields, False)
|
|
self.assertEqual(with_root_end, without_root_end + b"\x08")
|
|
p1, e1 = qats.parse_vdf(with_root_end)
|
|
p2, e2 = qats.parse_vdf(without_root_end)
|
|
self.assertTrue(e1)
|
|
self.assertFalse(e2)
|
|
self.assertEqual(p1, p2)
|
|
|
|
def test_real_file_shape(self):
|
|
# header is \x00shortcuts\x00, file ends with \x08\x08
|
|
blob = qats.serialize_vdf(make_fixture(), True)
|
|
self.assertTrue(blob.startswith(b"\x00shortcuts\x00"))
|
|
self.assertTrue(blob.endswith(b"\x08\x08"))
|
|
|
|
def test_parse_types(self):
|
|
fields, _ = qats.parse_vdf(qats.serialize_vdf(make_fixture(), True))
|
|
top = qats.field_get(fields, "shortcuts")
|
|
self.assertIsNotNone(top)
|
|
self.assertEqual(top[0], qats.T_MAP)
|
|
e0 = top[2][0][2]
|
|
appid = qats.field_get(e0, "appid")
|
|
self.assertEqual(appid[0], qats.T_U32)
|
|
self.assertEqual(struct.unpack("<I", appid[2])[0], 0x81234567)
|
|
tags = qats.field_get(e0, "tags")
|
|
self.assertEqual(tags[0], qats.T_MAP)
|
|
self.assertEqual(qats.field_get(tags[2], "0")[2], b"favorite")
|
|
|
|
def test_garbage_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
qats.parse_vdf(b"\x00shortcuts\x00\x07bogus\x00")
|
|
|
|
|
|
class TestDedupe(unittest.TestCase):
|
|
def test_update_in_place_never_duplicate(self):
|
|
fields = make_fixture()
|
|
blob0 = qats.serialize_vdf(fields, True)
|
|
parsed, ended = qats.parse_vdf(blob0)
|
|
new_fields, action = qats.upsert_entry(
|
|
parsed, "/usr/bin/qbz", "env NEW=1 %command%",
|
|
qats.shortcut_appid("/usr/bin/qbz"))
|
|
self.assertEqual(action, "updated")
|
|
top = qats.field_get(new_fields, "shortcuts")
|
|
# still exactly two entries, QBZ still at key "1"
|
|
self.assertEqual(len(top[2]), 2)
|
|
self.assertEqual(top[2][1][1], b"1")
|
|
lo = qats.field_get(top[2][1][2], "LaunchOptions")
|
|
self.assertEqual(lo[2], b"env NEW=1 %command%")
|
|
# the non-QBZ entry survives byte-identically: re-serialize and diff
|
|
blob1 = qats.serialize_vdf(new_fields, ended)
|
|
entry0_start = blob0.index(b"\x000\x00")
|
|
entry0_end = blob0.index(b"\x001\x00")
|
|
self.assertEqual(blob0[entry0_start:entry0_end],
|
|
blob1[entry0_start:entry0_end])
|
|
|
|
def test_add_when_missing(self):
|
|
fields = make_fixture()
|
|
parsed, ended = qats.parse_vdf(qats.serialize_vdf(fields, True))
|
|
removed = qats.remove_entry(parsed, "/usr/bin/qbz")
|
|
self.assertTrue(removed)
|
|
new_fields, action = qats.upsert_entry(
|
|
parsed, "/usr/bin/qbz", "env X=1 %command%",
|
|
qats.shortcut_appid("/usr/bin/qbz"))
|
|
self.assertEqual(action, "added")
|
|
top = qats.field_get(new_fields, "shortcuts")
|
|
self.assertEqual(len(top[2]), 2)
|
|
# appended reusing the freed index (indices stay contiguous)
|
|
self.assertEqual(top[2][-1][1], b"1")
|
|
|
|
def test_dedupe_on_exe_and_name(self):
|
|
# an entry with the same Exe but a different appname must NOT be
|
|
# replaced — a new entry is appended instead
|
|
fields = make_fixture()
|
|
top = qats.field_get(fields, "shortcuts")
|
|
# drop the real QBZ entry, keep only a "twin": same Exe, other name
|
|
top[2][:] = [e for e in top[2] if e[1] != b"1"]
|
|
twin = qats.build_entry("/usr/bin/qbz", "%command%", 0x81234568)
|
|
for i, f in enumerate(twin):
|
|
if f[1] == b"appname":
|
|
twin[i] = qats.fld_str("appname", "QBZ (alt)")
|
|
top[2].append(qats.fld_map("1", twin))
|
|
parsed, _ = qats.parse_vdf(qats.serialize_vdf(fields, True))
|
|
new_fields, action = qats.upsert_entry(
|
|
parsed, "/usr/bin/qbz", "env X=1 %command%",
|
|
qats.shortcut_appid("/usr/bin/qbz"))
|
|
self.assertEqual(action, "added")
|
|
top = qats.field_get(new_fields, "shortcuts")
|
|
self.assertEqual(len(top[2]), 3)
|
|
# the twin entry is untouched
|
|
self.assertEqual(qats.field_get(top[2][1][2], "appname")[2],
|
|
"QBZ (alt)".encode("utf-8"))
|
|
|
|
def test_remove_missing_is_noop(self):
|
|
fields = make_fixture()
|
|
blob0 = qats.serialize_vdf(fields, True)
|
|
parsed, _ = qats.parse_vdf(blob0)
|
|
self.assertFalse(qats.remove_entry(parsed, "/opt/other"))
|
|
self.assertEqual(qats.serialize_vdf(parsed, True), blob0)
|
|
|
|
|
|
class TestAppId(unittest.TestCase):
|
|
def test_formula(self):
|
|
exe = "/usr/bin/qbz"
|
|
expected = (zlib.crc32(('"' + exe + '"QBZ').encode("utf-8"))
|
|
| 0x80000000) & 0xFFFFFFFF
|
|
self.assertEqual(qats.shortcut_appid(exe), expected)
|
|
|
|
def test_known_vector(self):
|
|
# pinned so a regression in quoting/encoding is caught
|
|
self.assertEqual(qats.shortcut_appid("/usr/bin/qbz"), 3124044104)
|
|
|
|
def test_fits_u32_and_top_bit(self):
|
|
appid = qats.shortcut_appid("/usr/bin/qbz")
|
|
self.assertTrue(0 <= appid <= 0xFFFFFFFF)
|
|
self.assertTrue(appid & 0x80000000)
|
|
struct.pack("<I", appid) # must not raise
|
|
|
|
def test_launch_url(self):
|
|
appid = 3124044104
|
|
url = qats.launch_url(appid)
|
|
self.assertEqual(url, "steam://rungameid/%d"
|
|
% ((appid << 32) | 0x02000000))
|
|
|
|
|
|
class TestEntryFields(unittest.TestCase):
|
|
def test_standard_field_order(self):
|
|
entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%", 123)
|
|
keys = [k.decode() for _, k, _ in entry]
|
|
self.assertEqual(keys, [
|
|
"appid", "appname", "Exe", "StartDir", "icon", "ShortcutPath",
|
|
"LaunchOptions", "IsHidden", "AllowDesktopConfig", "AllowOverlay",
|
|
"OpenVR", "Devkit", "DevkitGameID", "DevkitOverrideAppID",
|
|
"LastPlayTime", "FlatpakAppID", "tags",
|
|
])
|
|
|
|
def test_entry_values(self):
|
|
appid = qats.shortcut_appid("/usr/bin/qbz")
|
|
entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%", appid)
|
|
self.assertEqual(qats.field_get(entry, "appname")[2], b"QBZ")
|
|
self.assertEqual(qats.field_get(entry, "Exe")[2], b'"/usr/bin/qbz"')
|
|
self.assertEqual(qats.field_get(entry, "StartDir")[2], b'"/usr/bin/"')
|
|
self.assertEqual(qats.field_get(entry, "icon")[2],
|
|
b"/usr/share/icons/hicolor/512x512/apps/"
|
|
b"com.blitzfc.qbz.png")
|
|
self.assertEqual(struct.unpack(
|
|
"<I", qats.field_get(entry, "appid")[2])[0], appid)
|
|
self.assertEqual(struct.unpack(
|
|
"<I", qats.field_get(entry, "AllowDesktopConfig")[2])[0], 1)
|
|
self.assertEqual(struct.unpack(
|
|
"<I", qats.field_get(entry, "AllowOverlay")[2])[0], 1)
|
|
self.assertEqual(struct.unpack(
|
|
"<I", qats.field_get(entry, "IsHidden")[2])[0], 0)
|
|
self.assertEqual(struct.unpack(
|
|
"<I", qats.field_get(entry, "OpenVR")[2])[0], 0)
|
|
|
|
|
|
class TestConfigsetPatch(unittest.TestCase):
|
|
EXISTING = ('"controller_config"\n{\n\t"12345"\n\t{\n'
|
|
'\t\t"template"\t\t"other.vdf"\n\t}\n}\n')
|
|
|
|
def test_insert_into_existing(self):
|
|
out = qats._patch_configset_text(self.EXISTING, 3124044104, False)
|
|
self.assertIn('"3124044104"', out)
|
|
self.assertIn(qats.LAYOUT_NAME, out)
|
|
self.assertIn('"12345"', out) # pre-existing entry preserved
|
|
self.assertEqual(out.count("{"), out.count("}"))
|
|
|
|
def test_replace_existing_entry(self):
|
|
first = qats._patch_configset_text(self.EXISTING, 3124044104, False)
|
|
second = qats._patch_configset_text(first, 3124044104, False)
|
|
self.assertEqual(second.count('"3124044104"'), 1) # idempotent
|
|
self.assertIn('"12345"', second)
|
|
|
|
def test_remove_entry(self):
|
|
first = qats._patch_configset_text(self.EXISTING, 3124044104, False)
|
|
out = qats._patch_configset_text(first, 3124044104, True)
|
|
self.assertNotIn('"3124044104"', out)
|
|
self.assertIn('"12345"', out)
|
|
|
|
def test_bad_format_returns_none(self):
|
|
self.assertIsNone(qats._patch_configset_text(
|
|
'"something_else"\n{\n}\n', 1, False))
|
|
self.assertIsNone(qats._patch_configset_text(
|
|
'"controller_config"\n{\n', 1, False)) # unbalanced braces
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|