Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7804893054 | |||
| bf3ac4af1a |
@@ -5,6 +5,17 @@ All notable changes to RigDoctor are recorded here. Format follows
|
||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||
release tag (so the auto-updater, D18, can compare versions).
|
||||
|
||||
## [0.23.0] - 2026-05-22
|
||||
### Added
|
||||
- **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**,
|
||||
**always-on** (a background service records continuously), and **game-launch** (auto-records
|
||||
while a Steam game runs). Set it from **Settings → Recording trigger** or
|
||||
`rigdoctor service mode <manual|always-on|game-launch>`; `rigdoctor service status` shows it.
|
||||
`core/service.py` writes/enables the user units.
|
||||
- **Zero-config game-launch watcher** (`core/watcher.py`, `rigdoctor watch`) — polls Steam's
|
||||
RunningAppID and brackets a focused capture around the running game (the D12 fallback for users
|
||||
who don't add the `wrap` launch option; the wrapper stays the precise primary path).
|
||||
|
||||
## [0.22.0] - 2026-05-22
|
||||
### Added
|
||||
- **M6 breadth.** Environment checks now also report **GPU PowerMizer** mode (NVIDIA, X — flags
|
||||
|
||||
+5
-3
@@ -61,9 +61,11 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
||||
watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user`
|
||||
service.
|
||||
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
|
||||
(`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`**
|
||||
(no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection
|
||||
config + `systemd --user` service enable + trigger-mode pick.
|
||||
(`rigdoctor install`, GUI Settings); **user-local `install.sh` + self-extracting `.run`**
|
||||
(no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger
|
||||
modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI
|
||||
Settings "Recording trigger") incl. the zero-config **game-launch watcher**
|
||||
(`core/watcher.py`, `rigdoctor watch`). *Pending:* module-selection config during install.
|
||||
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
|
||||
|
||||
## Phase 5 — Breadth (later)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.22.0"
|
||||
__version__ = "0.23.0"
|
||||
|
||||
@@ -416,6 +416,34 @@ def cmd_wrap(args) -> int:
|
||||
return wrap.run(args.command)
|
||||
|
||||
|
||||
def cmd_watch(args) -> int:
|
||||
from .core import watcher
|
||||
|
||||
interval = args.interval or load_config().get("interval", 1.0)
|
||||
print("Watching for a running Steam game (Ctrl-C to stop)…")
|
||||
return watcher.watch(interval=max(2.0, interval))
|
||||
|
||||
|
||||
def cmd_service(args) -> int:
|
||||
from .core import service
|
||||
|
||||
sub = args.service_cmd or "status"
|
||||
if sub == "mode":
|
||||
ok, msg = service.apply_mode(args.mode)
|
||||
print(f"Trigger mode set to '{args.mode}'.")
|
||||
if not ok and msg:
|
||||
print(f" note: {msg}")
|
||||
return 0 if ok or not service.available() else 1
|
||||
|
||||
info = service.status()
|
||||
print(f"Trigger mode: {info['mode']}")
|
||||
print(f"systemd --user: {'available' if info['available'] else 'not available'}")
|
||||
if info["available"]:
|
||||
print(f" recorder service: {'active' if info.get('recorder_active') else 'inactive'}")
|
||||
print(f" watcher service: {'active' if info.get('watch_active') else 'inactive'}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_gameenv(args) -> int:
|
||||
from dataclasses import asdict
|
||||
|
||||
@@ -618,6 +646,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
wrap_p.add_argument("command", nargs=argparse.REMAINDER,
|
||||
help="the game command — use `rigdoctor wrap %%command%%` in Steam")
|
||||
wrap_p.set_defaults(func=cmd_wrap)
|
||||
|
||||
watch_p = sub.add_parser("watch", help="auto-capture while a Steam game runs (game-launch trigger)")
|
||||
watch_p.add_argument("-n", "--interval", type=float, default=None, help="poll interval (s)")
|
||||
watch_p.set_defaults(func=cmd_watch)
|
||||
|
||||
svc_p = sub.add_parser("service", help="crash-logger trigger mode + systemd --user service (M9/D6)")
|
||||
svc_sub = svc_p.add_subparsers(dest="service_cmd")
|
||||
svc_sub.add_parser("status", help="show the trigger mode and service state").set_defaults(func=cmd_service)
|
||||
mode_p = svc_sub.add_parser("mode", help="set the trigger mode")
|
||||
mode_p.add_argument("mode", choices=("manual", "always-on", "game-launch"))
|
||||
mode_p.set_defaults(func=cmd_service)
|
||||
svc_p.set_defaults(func=cmd_service, service_cmd=None)
|
||||
return p
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ DEFAULTS: dict = {
|
||||
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
|
||||
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
|
||||
"steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet
|
||||
"trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""`systemd --user` services for the crash logger + game watcher (M9 / D6 trigger modes).
|
||||
|
||||
Three trigger modes (D6): **manual** (no service — start/stop by hand), **always-on** (a user
|
||||
service samples continuously, bounded by log rotation), and **game-launch** (a watcher service
|
||||
auto-brackets a capture around each game). No root: everything is a `systemd --user` unit in
|
||||
``~/.config/systemd/user``. Degrades gracefully when systemd isn't available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .. import config
|
||||
|
||||
UNIT_DIR = Path(os.path.expanduser("~/.config/systemd/user"))
|
||||
RECORDER_UNIT = "rigdoctor-recorder.service"
|
||||
WATCH_UNIT = "rigdoctor-watch.service"
|
||||
MODES = ("manual", "always-on", "game-launch")
|
||||
|
||||
_UNITS = {
|
||||
RECORDER_UNIT: ("RigDoctor crash-capture recorder (always-on)", ["record", "run"]),
|
||||
WATCH_UNIT: ("RigDoctor game-launch watcher", ["watch"]),
|
||||
}
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return shutil.which("systemctl") is not None
|
||||
|
||||
|
||||
def _rigdoctor_bin() -> str:
|
||||
exe = Path(sys.executable).with_name("rigdoctor") # next to the venv python
|
||||
if exe.exists():
|
||||
return str(exe)
|
||||
return shutil.which("rigdoctor") or "rigdoctor"
|
||||
|
||||
|
||||
def _systemctl(*args: str) -> tuple[int, str]:
|
||||
try:
|
||||
proc = subprocess.run(["systemctl", "--user", *args],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
return proc.returncode, (proc.stdout + proc.stderr).strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return 1, str(exc)
|
||||
|
||||
|
||||
def unit_text(description: str, args: list[str]) -> str:
|
||||
exec_cmd = " ".join([_rigdoctor_bin(), *args])
|
||||
return (
|
||||
"[Unit]\n"
|
||||
f"Description={description}\n\n"
|
||||
"[Service]\n"
|
||||
"Type=simple\n"
|
||||
f"ExecStart={exec_cmd}\n"
|
||||
"Restart=on-failure\n"
|
||||
"RestartSec=5\n\n"
|
||||
"[Install]\n"
|
||||
"WantedBy=default.target\n"
|
||||
)
|
||||
|
||||
|
||||
def install_units() -> None:
|
||||
"""Write/refresh both unit files and reload systemd (idempotent)."""
|
||||
UNIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for name, (desc, args) in _UNITS.items():
|
||||
(UNIT_DIR / name).write_text(unit_text(desc, args))
|
||||
_systemctl("daemon-reload")
|
||||
|
||||
|
||||
def is_active(name: str) -> bool:
|
||||
return _systemctl("is-active", name)[0] == 0
|
||||
|
||||
|
||||
def is_enabled(name: str) -> bool:
|
||||
return _systemctl("is-enabled", name)[0] == 0
|
||||
|
||||
|
||||
def _enable(name: str) -> tuple[int, str]:
|
||||
return _systemctl("enable", "--now", name)
|
||||
|
||||
|
||||
def _disable(name: str) -> tuple[int, str]:
|
||||
return _systemctl("disable", "--now", name)
|
||||
|
||||
|
||||
def apply_mode(mode: str) -> tuple[bool, str]:
|
||||
"""Reconcile the user services to `mode` and persist it. Returns (ok, message)."""
|
||||
if mode not in MODES:
|
||||
return False, f"Unknown trigger mode: {mode}"
|
||||
if not available():
|
||||
config.update_config(trigger_mode=mode)
|
||||
return False, "systemd --user isn't available — mode saved, but no service was changed."
|
||||
install_units()
|
||||
if mode == "always-on":
|
||||
_disable(WATCH_UNIT)
|
||||
rc, out = _enable(RECORDER_UNIT)
|
||||
elif mode == "game-launch":
|
||||
_disable(RECORDER_UNIT)
|
||||
rc, out = _enable(WATCH_UNIT)
|
||||
else: # manual
|
||||
_disable(RECORDER_UNIT)
|
||||
_disable(WATCH_UNIT)
|
||||
rc, out = 0, ""
|
||||
config.update_config(trigger_mode=mode)
|
||||
return rc == 0, out
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Current trigger mode (config) + live service states (best-effort)."""
|
||||
cfg = config.load_config()
|
||||
info = {"available": available(), "mode": cfg.get("trigger_mode", "manual")}
|
||||
if info["available"]:
|
||||
info["recorder_active"] = is_active(RECORDER_UNIT)
|
||||
info["watch_active"] = is_active(WATCH_UNIT)
|
||||
return info
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Zero-config game-launch watcher (D12 fallback): poll Steam's RunningAppID and
|
||||
auto-bracket a focused capture around the running game.
|
||||
|
||||
For users who won't add the `rigdoctor wrap %command%` launch option. Less precise than the
|
||||
wrapper (it depends on Steam writing RunningAppID to registry.vdf, and only covers Steam), so
|
||||
the wrapper stays the primary mechanism. Stdlib only; safe to run as a `systemd --user` service
|
||||
(the game-launch trigger mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import reccontrol, steam
|
||||
from .steam import _parse_vdf
|
||||
|
||||
_REGISTRY_CANDIDATES = ("~/.steam/registry.vdf", "~/.steam/steam/registry.vdf")
|
||||
|
||||
|
||||
def _registry_path() -> Path | None:
|
||||
for cand in _REGISTRY_CANDIDATES:
|
||||
p = Path(os.path.expanduser(cand))
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _find_key(data: dict, key: str):
|
||||
"""Recursively find a (case-insensitive) scalar key in nested VDF dicts."""
|
||||
target = key.lower()
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict):
|
||||
found = _find_key(v, key)
|
||||
if found is not None:
|
||||
return found
|
||||
elif k.lower() == target:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def running_appid() -> int:
|
||||
"""The Steam appid currently running (0 if none / unknown)."""
|
||||
path = _registry_path()
|
||||
if path is None:
|
||||
return 0
|
||||
try:
|
||||
data = _parse_vdf(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except OSError:
|
||||
return 0
|
||||
raw = _find_key(data, "RunningAppID")
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def transition(prev: int, current: int) -> str | None:
|
||||
"""'start' when a game begins, 'stop' when it ends, else None."""
|
||||
if current and not prev:
|
||||
return "start"
|
||||
if prev and not current:
|
||||
return "stop"
|
||||
return None
|
||||
|
||||
|
||||
def _name_for(appid: int) -> str:
|
||||
target = str(appid)
|
||||
for g in steam.cached_games() or steam.scan_games(steam.selected_library_paths()):
|
||||
if g.appid == target:
|
||||
return g.name
|
||||
return f"Steam app {appid}"
|
||||
|
||||
|
||||
def watch(interval: float = 5.0) -> int:
|
||||
"""Poll for a running Steam game and bracket a capture around it. Blocks until signalled."""
|
||||
from . import diagnostic
|
||||
|
||||
stop = {"flag": False}
|
||||
|
||||
def _on_signal(_sig, _frame):
|
||||
stop["flag"] = True
|
||||
|
||||
signal.signal(signal.SIGTERM, _on_signal)
|
||||
signal.signal(signal.SIGINT, _on_signal)
|
||||
|
||||
prev = 0
|
||||
started = False
|
||||
while not stop["flag"]:
|
||||
current = running_appid()
|
||||
action = transition(prev, current)
|
||||
if action == "start" and not reccontrol.running_pid():
|
||||
started = diagnostic.start(game=_name_for(current)) is not None
|
||||
elif action == "stop" and started:
|
||||
reccontrol.stop_background()
|
||||
started = False
|
||||
prev = current
|
||||
# Sleep in small slices so a stop signal is handled promptly.
|
||||
slept = 0.0
|
||||
while slept < interval and not stop["flag"]:
|
||||
time.sleep(min(0.25, interval - slept))
|
||||
slept += 0.25
|
||||
if started:
|
||||
reccontrol.stop_background()
|
||||
return 0
|
||||
@@ -9,6 +9,7 @@ from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
@@ -24,7 +25,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .. import config
|
||||
from ..core import alerts, installer, sysenv, uninstall, updates
|
||||
from ..core import alerts, installer, service, sysenv, uninstall, updates
|
||||
from .theme import GOOD, MUTED, WARN
|
||||
|
||||
|
||||
@@ -52,6 +53,7 @@ _BACKEND_DESC = {
|
||||
class SetupPage(QWidget):
|
||||
_installed = Signal(int, str)
|
||||
_upd_state = Signal(object)
|
||||
_mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change
|
||||
changed = Signal() # alert settings saved — main window re-applies them live
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -59,6 +61,7 @@ class SetupPage(QWidget):
|
||||
self.setObjectName("Page")
|
||||
self._installed.connect(self._on_installed)
|
||||
self._upd_state.connect(self._on_upd_state)
|
||||
self._mode_applied.connect(self._on_mode_applied)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(20, 18, 20, 18)
|
||||
@@ -123,6 +126,35 @@ class SetupPage(QWidget):
|
||||
alerts_layout.addLayout(alerts_buttons)
|
||||
root.addWidget(alerts_card)
|
||||
|
||||
# Recording trigger (M9 / D6): when the crash logger runs.
|
||||
trig_card, trig_layout = _panel("Recording trigger")
|
||||
trig_desc = QLabel(
|
||||
"When the crash logger runs (uses a systemd --user service):\n"
|
||||
"• Manual — you start/stop it yourself.\n"
|
||||
"• Always-on — a background service records continuously.\n"
|
||||
"• Game-launch — auto-records while a Steam game is running."
|
||||
)
|
||||
trig_desc.setObjectName("Muted")
|
||||
trig_desc.setWordWrap(True)
|
||||
trig_layout.addWidget(trig_desc)
|
||||
trig_row = QHBoxLayout()
|
||||
self._trigger = QComboBox()
|
||||
self._trigger.addItems(list(service.MODES))
|
||||
apply_trigger = QPushButton("Apply")
|
||||
apply_trigger.setObjectName("PrimaryButton")
|
||||
apply_trigger.clicked.connect(self._apply_trigger)
|
||||
trig_row.addWidget(self._trigger, 1)
|
||||
trig_row.addWidget(apply_trigger)
|
||||
trig_layout.addLayout(trig_row)
|
||||
self._trigger_status = QLabel("")
|
||||
self._trigger_status.setObjectName("Muted")
|
||||
self._trigger_status.setWordWrap(True)
|
||||
trig_layout.addWidget(self._trigger_status)
|
||||
if not service.available():
|
||||
apply_trigger.setEnabled(False)
|
||||
self._trigger_status.setText("systemd --user isn't available on this system.")
|
||||
root.addWidget(trig_card)
|
||||
|
||||
# Account access (M13/M12): one Gitea token gates updates and session sharing.
|
||||
upd_card, upd_layout = _panel("Account access")
|
||||
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
||||
@@ -167,8 +199,26 @@ class SetupPage(QWidget):
|
||||
|
||||
self._refresh()
|
||||
self._load_alerts()
|
||||
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
|
||||
self._refresh_update_status()
|
||||
|
||||
# --- recording trigger (M9) -----------------------------------------------
|
||||
def _apply_trigger(self) -> None:
|
||||
mode = self._trigger.currentText()
|
||||
self._trigger_status.setText(f"Applying “{mode}”… (may take a moment)")
|
||||
threading.Thread(target=self._work_trigger, args=(mode,), daemon=True).start()
|
||||
|
||||
def _work_trigger(self, mode: str) -> None:
|
||||
ok, msg = service.apply_mode(mode)
|
||||
self._mode_applied.emit((mode, ok, msg))
|
||||
|
||||
def _on_mode_applied(self, result) -> None:
|
||||
mode, ok, msg = result
|
||||
if ok:
|
||||
self._trigger_status.setText(f"Recording trigger set to “{mode}”.")
|
||||
else:
|
||||
self._trigger_status.setText(f"“{mode}” saved. {msg}")
|
||||
|
||||
# --- alerts (M8) ----------------------------------------------------------
|
||||
@staticmethod
|
||||
def _spin() -> QDoubleSpinBox:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for the M9 systemd --user trigger-mode service manager."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core import service
|
||||
|
||||
|
||||
class UnitTextTests(unittest.TestCase):
|
||||
def test_unit_text_has_required_sections(self):
|
||||
txt = service.unit_text("RigDoctor recorder", ["record", "run"])
|
||||
self.assertIn("[Unit]", txt)
|
||||
self.assertIn("[Service]", txt)
|
||||
self.assertIn("ExecStart=", txt)
|
||||
self.assertIn("record run", txt)
|
||||
self.assertIn("WantedBy=default.target", txt)
|
||||
|
||||
|
||||
class ApplyModeTests(unittest.TestCase):
|
||||
def test_unknown_mode_rejected(self):
|
||||
ok, msg = service.apply_mode("turbo")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Unknown", msg)
|
||||
|
||||
def test_no_systemd_saves_mode_but_reports(self):
|
||||
with mock.patch.object(service, "available", return_value=False), \
|
||||
mock.patch.object(service.config, "update_config") as update:
|
||||
ok, msg = service.apply_mode("always-on")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("available", msg.lower())
|
||||
update.assert_called_once_with(trigger_mode="always-on")
|
||||
|
||||
def test_always_on_enables_recorder_disables_watch(self):
|
||||
calls = []
|
||||
with mock.patch.object(service, "available", return_value=True), \
|
||||
mock.patch.object(service, "install_units"), \
|
||||
mock.patch.object(service, "_enable", side_effect=lambda n: calls.append(("enable", n)) or (0, "")), \
|
||||
mock.patch.object(service, "_disable", side_effect=lambda n: calls.append(("disable", n)) or (0, "")), \
|
||||
mock.patch.object(service.config, "update_config"):
|
||||
ok, _ = service.apply_mode("always-on")
|
||||
self.assertTrue(ok)
|
||||
self.assertIn(("enable", service.RECORDER_UNIT), calls)
|
||||
self.assertIn(("disable", service.WATCH_UNIT), calls)
|
||||
|
||||
def test_manual_disables_both(self):
|
||||
disabled = []
|
||||
with mock.patch.object(service, "available", return_value=True), \
|
||||
mock.patch.object(service, "install_units"), \
|
||||
mock.patch.object(service, "_enable", return_value=(0, "")), \
|
||||
mock.patch.object(service, "_disable", side_effect=lambda n: disabled.append(n) or (0, "")), \
|
||||
mock.patch.object(service.config, "update_config"):
|
||||
ok, _ = service.apply_mode("manual")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(set(disabled), {service.RECORDER_UNIT, service.WATCH_UNIT})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for the M9/D12 game-launch watcher (RunningAppID parse + transitions)."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core import watcher
|
||||
|
||||
_REGISTRY = """"Registry"
|
||||
{
|
||||
\t"HKCU"
|
||||
\t{
|
||||
\t\t"Software"
|
||||
\t\t{
|
||||
\t\t\t"Valve"
|
||||
\t\t\t{
|
||||
\t\t\t\t"Steam"
|
||||
\t\t\t\t{
|
||||
\t\t\t\t\t"RunningAppID"\t\t"%s"
|
||||
\t\t\t\t}
|
||||
\t\t\t}
|
||||
\t\t}
|
||||
\t}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TransitionTests(unittest.TestCase):
|
||||
def test_transitions(self):
|
||||
self.assertEqual(watcher.transition(0, 570), "start")
|
||||
self.assertEqual(watcher.transition(570, 0), "stop")
|
||||
self.assertIsNone(watcher.transition(570, 570))
|
||||
self.assertIsNone(watcher.transition(0, 0))
|
||||
|
||||
|
||||
class FindKeyTests(unittest.TestCase):
|
||||
def test_case_insensitive_nested(self):
|
||||
data = {"Registry": {"HKCU": {"steam": {"runningappid": "42"}}}}
|
||||
self.assertEqual(watcher._find_key(data, "RunningAppID"), "42")
|
||||
|
||||
def test_missing(self):
|
||||
self.assertIsNone(watcher._find_key({"a": {"b": "c"}}, "RunningAppID"))
|
||||
|
||||
|
||||
class RunningAppIdTests(unittest.TestCase):
|
||||
def _with_registry(self, content):
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "registry.vdf"
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def test_reads_running_appid(self):
|
||||
path = self._with_registry(_REGISTRY % "570")
|
||||
with mock.patch.object(watcher, "_registry_path", return_value=path):
|
||||
self.assertEqual(watcher.running_appid(), 570)
|
||||
|
||||
def test_zero_when_idle(self):
|
||||
path = self._with_registry(_REGISTRY % "0")
|
||||
with mock.patch.object(watcher, "_registry_path", return_value=path):
|
||||
self.assertEqual(watcher.running_appid(), 0)
|
||||
|
||||
def test_zero_when_no_registry(self):
|
||||
with mock.patch.object(watcher, "_registry_path", return_value=None):
|
||||
self.assertEqual(watcher.running_appid(), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user