The stats overlay was the visible half of a general problem: nine of the client's settings had a row here and twenty didn't, so a Deck that never sees a desktop could not reach its own decoder, chroma, HDR, audio layout, echo cancellation, touch or mouse model, scroll direction, auto-wake, or either audio endpoint. Everything the store holds is here now — except the two things a plugin backend genuinely cannot answer, named in `backend.ts` so the next reader doesn't go looking: which physical pad is player 1 (SDL's live device list lives in the client process, and no CLI enumerates it) and the session's remembered window size, which is not a preference. Thirty rows is too many to scroll past on a thumbstick, so they are split across a `SidebarNavigation` — the left-rail-of-categories layout SteamOS's own Settings uses, and the one Deck users already know. Every page fits on screen without scrolling, which is the point: the rail is the index, so nothing is more than one hop away. The categories, their order and the wording of the rows are the console settings screen's — it is the other settings editor reachable without leaving Gaming Mode, and two different orders for one store is how people stop trusting either. It shows them as one steppable list because it has no pointer and no room for a rail; here they become the rail's pages. The six pages take one shared settings object rather than each holding state, so a change on one is visible on the others the moment you switch. Three more rules: - A dependent setting is INDENTED under what it depends on and DISABLED, never hidden: mic device and echo cancellation under the microphone, controller type under forwarding. The console dims those rows for the same reason, and a row that vanishes as you toggle the one above it is a moving target for a thumbstick. The device row at the foot of Audio is rendered even while it reads, for that reason. - A picker with nothing to pick doesn't appear: the GPU row shows up only where the enumeration found more than one adapter, so it is absent on a Deck and present on a Bazzite desktop with a dGPU. - A setting that behaves differently HERE says so in its own description rather than being dropped. Capture system shortcuts holds nothing back under gamescope; fullscreen-on-stream can't lose to a launch that always passes `--fullscreen`; the client's library toggle isn't this plugin's browser. Each says which. The device pickers are real, not stubs: `list_devices` reads `--list-adapters` and `--list-audio` off the SESSION binary, the same two enumerations the GTK shell shells out for because it links no Vulkan itself. It is cached for the life of the backend (that call inits Vulkan and PipeWire) with an explicit Refresh for the headset you just plugged in, and a failure — a client too old to ship the session binary — leaves the pickers on Automatic and says so instead of claiming you have no devices. `_parse_audio_endpoints` is split out and unit-tested with the malformed lines that must never reach a picker. Two smaller honesty fixes fall out of building it. A Dropdown can only display a value that is one of its options, and this store has four other writers — so a stored value the table doesn't list is carried as its own entry rather than rendering blank or, worse, showing a different value than the stream will use. And a stored audio endpoint that isn't currently connected keeps a "(not connected)" entry, the way the Linux picker keeps "(not detected)", instead of silently re-pointing the next stream at the default. The Settings tab's wrapper deliberately stops being a scroll area: a SidebarNavigation given an indefinite height to fill collapses its rail, so the pane hands it the full height and keeps its hands off the overflow, and the footer inset moves inside the pages. The docs claimed nine things about this plugin that are no longer true, and three about the console home that stopped being true when its own row set grew on 2026-07-31 (4:4:4, echo cancellation, auto-wake and the library toggle are all there in `screens/settings.rs`). Both corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
6.6 KiB
Python
179 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
|
|
|
|
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
|
|
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
|
|
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
|
|
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
|
|
|
|
python3 clients/decky/scripts/test-backend.py
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
# ---- stub the decky module before importing main.py ------------------------------------
|
|
decky = types.ModuleType("decky")
|
|
decky.DECKY_USER_HOME = "/tmp/pf-test-home"
|
|
decky.DECKY_PLUGIN_DIR = "/tmp/pf-test-plugin"
|
|
|
|
|
|
class _Log:
|
|
def __getattr__(self, _name):
|
|
return lambda *a, **k: None
|
|
|
|
|
|
decky.logger = _Log()
|
|
sys.modules["decky"] = decky
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
import main # noqa: E402 (the plugin backend)
|
|
|
|
failures = 0
|
|
|
|
|
|
def check(name: str, cond: bool):
|
|
global failures
|
|
print(("ok " if cond else "FAIL") + " " + name)
|
|
if not cond:
|
|
failures += 1
|
|
|
|
|
|
# ---- _parse_library_tsv -----------------------------------------------------------------
|
|
tsv = (
|
|
"steam:570\tsteam\tDota 2\n"
|
|
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
|
|
"2 game(s)\n" # the count trailer has no tabs — self-skips
|
|
)
|
|
games = main._parse_library_tsv(tsv)
|
|
check("tsv: two games parsed", len(games) == 2)
|
|
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
|
|
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
|
|
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
|
|
|
|
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
|
|
check(
|
|
"err: not-paired",
|
|
main._classify_library_error(
|
|
"library: The host didn't recognize this device. Pair with the host first — the "
|
|
"library is authorized by this device's certificate (no token needed)."
|
|
)
|
|
== "not-paired",
|
|
)
|
|
check(
|
|
"err: pin-mismatch",
|
|
main._classify_library_error(
|
|
"library: The host's certificate doesn't match the pinned fingerprint. "
|
|
"Re-pair with a PIN to re-establish trust."
|
|
)
|
|
== "pin-mismatch",
|
|
)
|
|
check(
|
|
"err: unreachable",
|
|
main._classify_library_error(
|
|
"library: Couldn't reach the host's management API: connection refused. Check the "
|
|
"host is updated and reachable."
|
|
)
|
|
== "unreachable",
|
|
)
|
|
check(
|
|
"err: http",
|
|
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
|
|
)
|
|
check(
|
|
"err: outdated client (GTK init noise)",
|
|
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
|
|
== "client-outdated",
|
|
)
|
|
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
|
|
|
|
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
|
|
avahi = (
|
|
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
|
|
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
|
|
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
|
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
|
|
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
|
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
|
|
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
|
|
)
|
|
hosts = main._parse_avahi_browse(avahi)
|
|
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
|
|
lr = next(h for h in hosts if h["name"] == "living-room")
|
|
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
|
|
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
|
|
check("avahi: id parsed", lr["id"] == "abc123")
|
|
bare = next(h for h in hosts if h["name"] == "bare-host")
|
|
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
|
|
check("avahi: id absent -> empty", bare["id"] == "")
|
|
|
|
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
|
|
import asyncio # noqa: E402
|
|
import shutil # noqa: E402
|
|
|
|
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
|
plugin = main.Plugin()
|
|
pin = {
|
|
"game_id": "steam:570",
|
|
"title": "Dota 2",
|
|
"store": "steam",
|
|
"host_fp": "AABBCC",
|
|
"host_id": "abc123",
|
|
"host_name": "living-room",
|
|
"host": "192.168.1.42",
|
|
"port": 9777,
|
|
"mgmt": 47990,
|
|
"added_at": 1780000000,
|
|
}
|
|
dupe = dict(pin, title="Dota 2 again")
|
|
junk = {"title": "no game id"}
|
|
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
|
|
check("pins: write ok", res.get("ok") is True)
|
|
got = asyncio.run(plugin.get_pins())["pins"]
|
|
check("pins: dedup + junk dropped", len(got) == 1)
|
|
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
|
|
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
|
|
cfg = main._client_config_dir()
|
|
cfg.mkdir(parents=True, exist_ok=True)
|
|
(cfg / "client-known-hosts.json").write_text(
|
|
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
|
|
'"fp_hex": "aabbcc", "paired": true}]}'
|
|
)
|
|
got = asyncio.run(plugin.get_pins())["pins"]
|
|
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
|
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
|
|
|
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
|
sinks, sources = main._parse_audio_endpoints(
|
|
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
|
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
|
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
|
)
|
|
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
|
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
|
])
|
|
check("audio: sources parsed", len(sources) == 1)
|
|
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
|
|
|
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
|
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
|
# falls back to the name so no entry renders unlabelled.
|
|
sinks, sources = main._parse_audio_endpoints(
|
|
"sink\t\tNo node name\n"
|
|
"sink\tonly-two-columns\n"
|
|
"monitor\tsome.monitor\tNot a sink or source\n"
|
|
"source\tbare.node\t\n"
|
|
"\n"
|
|
)
|
|
check("audio: junk lines dropped", sinks == [])
|
|
check("audio: blank description falls back to the node name", sources == [
|
|
{"name": "bare.node", "description": "bare.node"}
|
|
])
|
|
|
|
print()
|
|
if failures:
|
|
print(f"{failures} check(s) FAILED")
|
|
sys.exit(1)
|
|
print("all checks passed")
|