Compare commits
6 Commits
d405bf7caf
...
v0.39.0
| Author | SHA1 | Date | |
|---|---|---|---|
| fb468e83c2 | |||
|
b006fa6b8d
|
|||
| b20e8dfc3a | |||
| 9fe9a6576f | |||
|
07bc722209
|
|||
|
81c7757546
|
@@ -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.39.0] - 2026-05-22
|
||||
### Added
|
||||
- **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its
|
||||
resolution and current/max refresh — e.g. `DP-1 · Samsung LC34G55T → 3440x1440 @ 165 Hz`. Reads
|
||||
GNOME's Mutter `DisplayConfig` over D-Bus (works on X11 *and* Wayland), falling back to `xrandr`
|
||||
on other X11 desktops.
|
||||
- **System Health flags monitors below their max refresh.** If a monitor supports a higher refresh
|
||||
at its current resolution (e.g. a 165 Hz panel set to 60 Hz — an easily-missed gaming setting),
|
||||
Health reports it with the fix (raise it in Display settings). Max is computed at the *current*
|
||||
resolution, so it never suggests dropping resolution.
|
||||
|
||||
## [0.38.0] - 2026-05-22
|
||||
### Added
|
||||
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
|
||||
@@ -12,6 +23,11 @@ release tag (so the auto-updater, D18, can compare versions).
|
||||
(`current/max_link_speed` + width). If a drive negotiates below its capability (a slower M.2
|
||||
slot, lane-sharing, or a downtrain) it's flagged: `PCIe Gen3 x4 (capable of Gen4 x4)`. So you
|
||||
can confirm a Gen4 SSD is actually in a Gen4 slot. (SATA disks show no PCIe link.)
|
||||
- **System Health flags downtrained NVMe links.** A new check warns when an NVMe drive negotiates
|
||||
fewer PCIe lanes than it supports (almost always motherboard **lane-sharing** — a GPU/second
|
||||
card or another M.2 stealing lanes) and notes speed-only reductions as info (a slower slot or
|
||||
idle ASPM). The GPU is deliberately excluded — NVIDIA drops its PCIe gen/width at idle, so a
|
||||
snapshot would false-alarm.
|
||||
|
||||
## [0.37.1] - 2026-05-22
|
||||
### Fixed
|
||||
|
||||
@@ -51,30 +51,20 @@ apt pulls the GUI dependencies (PySide6, pyte) automatically:
|
||||
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
|
||||
```
|
||||
|
||||
**Or add the apt repository** for `apt install` + automatic updates. The registry is public and
|
||||
GPG-signed — no token needed; just add the signing key and a deb822 source:
|
||||
**Or add the apt repository** for `apt install` + automatic updates (the registry is public and
|
||||
GPG-signed — no token needed):
|
||||
|
||||
```bash
|
||||
# signing key → dearmored into the keyring
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-jessey.gpg
|
||||
|
||||
# the source (modern deb822 format, GPG-verified, all-arch)
|
||||
sudo tee /etc/apt/sources.list.d/rigdoctor.sources >/dev/null <<'EOF'
|
||||
Types: deb
|
||||
URIs: https://git.jesseyvanofferen.com/api/packages/jessey/debian
|
||||
Suites: stable
|
||||
Components: main
|
||||
Architectures: all
|
||||
Signed-By: /etc/apt/keyrings/gitea-jessey.gpg
|
||||
EOF
|
||||
|
||||
sudo apt update && sudo apt install rigdoctor
|
||||
sudo curl https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key -o /etc/apt/keyrings/gitea-jessey.asc
|
||||
echo "deb [arch=all signed-by=/etc/apt/keyrings/gitea-jessey.asc] https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" | sudo tee /etc/apt/sources.list.d/gitea.list
|
||||
sudo apt update
|
||||
sudo apt install rigdoctor
|
||||
```
|
||||
|
||||
Then `sudo apt upgrade` keeps it current.
|
||||
|
||||
Then `sudo apt upgrade` keeps it current.
|
||||
|
||||
### Any distro — self-extracting `.run` (no root)
|
||||
|
||||
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.38.0"
|
||||
version = "0.39.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.38.0"
|
||||
__version__ = "0.39.0"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Connected displays (M5): resolution + current/max refresh per monitor.
|
||||
|
||||
GNOME exposes the authoritative data over D-Bus (Mutter `DisplayConfig.GetCurrentState`),
|
||||
which works on both X11 and Wayland — read via `busctl --json`. Plain X11 desktops fall back
|
||||
to `xrandr`. Other Wayland compositors (sway/KDE) aren't covered yet and degrade to empty.
|
||||
Stdlib only; every probe fails soft. Max refresh is computed at the *current* resolution, so
|
||||
"can go faster" never suggests dropping resolution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
# A few common PNP monitor-vendor IDs → friendly names (best-effort; unknown codes pass through).
|
||||
_PNP = {
|
||||
"SAM": "Samsung", "DEL": "Dell", "GSM": "LG", "LGD": "LG", "AUS": "ASUS", "ACR": "Acer",
|
||||
"BNQ": "BenQ", "MSI": "MSI", "AOC": "AOC", "VSC": "ViewSonic", "HWP": "HP", "HPN": "HP",
|
||||
"PHL": "Philips", "GBT": "Gigabyte", "APP": "Apple", "DGC": "Dell",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Monitor:
|
||||
connector: str # e.g. "DP-1"
|
||||
name: str # e.g. "Samsung LC34G55T" ("" if unknown, e.g. xrandr)
|
||||
width: int
|
||||
height: int
|
||||
refresh: float # current Hz
|
||||
max_refresh: float # max Hz available at the current resolution
|
||||
|
||||
@property
|
||||
def can_go_faster(self) -> bool:
|
||||
"""True if a meaningfully higher refresh is available at the current resolution."""
|
||||
return self.max_refresh - self.refresh > 1.0
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.connector} · {self.name}".rstrip(" ·") if self.name else self.connector
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: float = 8.0) -> str:
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_mutter(out: str) -> list[Monitor]:
|
||||
"""Parse `busctl --json` output of Mutter DisplayConfig.GetCurrentState.
|
||||
|
||||
data = [serial, monitors, logical_monitors, props]; each monitor is
|
||||
[[connector, vendor, product, serial], [modes], props]; each mode is
|
||||
[id, width, height, refresh, scale, [scales], {props}] where props may hold is-current.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(out)["data"]
|
||||
raw_monitors = data[1]
|
||||
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
||||
return []
|
||||
monitors: list[Monitor] = []
|
||||
for mon in raw_monitors:
|
||||
try:
|
||||
connector, vendor, product = mon[0][0], mon[0][1], mon[0][2]
|
||||
modes = mon[1]
|
||||
except (IndexError, TypeError):
|
||||
continue
|
||||
current = None
|
||||
for m in modes:
|
||||
props = m[6] if len(m) > 6 and isinstance(m[6], dict) else {}
|
||||
if (props.get("is-current") or {}).get("data"):
|
||||
current = m
|
||||
break
|
||||
if current is None:
|
||||
continue
|
||||
w, h, r = int(current[1]), int(current[2]), float(current[3])
|
||||
max_r = max((float(m[3]) for m in modes if int(m[1]) == w and int(m[2]) == h), default=r)
|
||||
name = f"{_PNP.get(vendor, vendor)} {product}".strip()
|
||||
monitors.append(Monitor(connector, name, w, h, r, max_r))
|
||||
return monitors
|
||||
|
||||
|
||||
def _parse_xrandr(out: str) -> list[Monitor]:
|
||||
"""Parse `xrandr --query`: an output line with the active WxH+x+y, then indented mode lines
|
||||
whose rates carry `*` for the current one."""
|
||||
monitors: list[Monitor] = []
|
||||
out_re = re.compile(r"^(\S+) connected.*?(\d+)x(\d+)\+\d+\+\d+")
|
||||
mode_re = re.compile(r"^\s+(\d+)x(\d+)\s+(.+)$")
|
||||
name = ""
|
||||
cw = ch = 0
|
||||
cur_r = max_r = 0.0
|
||||
|
||||
def flush() -> None:
|
||||
if name and cw and cur_r:
|
||||
monitors.append(Monitor(name, "", cw, ch, cur_r, max_r or cur_r))
|
||||
|
||||
for line in out.splitlines():
|
||||
mo = out_re.match(line)
|
||||
if mo:
|
||||
flush()
|
||||
name, cw, ch = mo.group(1), int(mo.group(2)), int(mo.group(3))
|
||||
cur_r = max_r = 0.0
|
||||
continue
|
||||
mm = mode_re.match(line)
|
||||
if mm and name and int(mm.group(1)) == cw and int(mm.group(2)) == ch:
|
||||
for tok in mm.group(3).split():
|
||||
try:
|
||||
rate = float(tok.rstrip("*+"))
|
||||
except ValueError:
|
||||
continue
|
||||
max_r = max(max_r, rate)
|
||||
if "*" in tok:
|
||||
cur_r = rate
|
||||
flush()
|
||||
return monitors
|
||||
|
||||
|
||||
def _mutter() -> list[Monitor]:
|
||||
exe = shutil.which("busctl")
|
||||
if not exe:
|
||||
return []
|
||||
out = _run([exe, "--user", "--json=short", "call", "org.gnome.Mutter.DisplayConfig",
|
||||
"/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig",
|
||||
"GetCurrentState"])
|
||||
return _parse_mutter(out) if out.strip() else []
|
||||
|
||||
|
||||
def _xrandr() -> list[Monitor]:
|
||||
if not shutil.which("xrandr"):
|
||||
return []
|
||||
return _parse_xrandr(_run(["xrandr", "--query"]))
|
||||
|
||||
|
||||
def collect() -> list[Monitor]:
|
||||
"""Connected monitors, via the first backend that returns any (Mutter, then xrandr)."""
|
||||
for backend in (_mutter, _xrandr):
|
||||
try:
|
||||
monitors = backend()
|
||||
except Exception:
|
||||
monitors = []
|
||||
if monitors:
|
||||
return monitors
|
||||
return []
|
||||
@@ -251,6 +251,55 @@ def check_live_temps() -> list[Finding]:
|
||||
)]
|
||||
|
||||
|
||||
def check_pcie_links() -> list[Finding]:
|
||||
"""Flag NVMe drives linked below their PCIe capability — a slower slot or, most often,
|
||||
motherboard lane-sharing where a GPU/second card or another M.2 steals lanes from the slot.
|
||||
|
||||
Width reductions are reliable (reported as warnings); speed-only reductions are info (they can
|
||||
also be normal link power management at idle). The GPU is intentionally not checked here:
|
||||
NVIDIA drops its PCIe gen *and* width at idle, so a point-in-time snapshot is misleading.
|
||||
"""
|
||||
from . import inventory
|
||||
|
||||
findings: list[Finding] = []
|
||||
for name, dev in inventory.nvme_controllers():
|
||||
cur_g, cur_w, max_g, max_w = inventory.read_link(dev)
|
||||
if not cur_g or not max_g:
|
||||
continue
|
||||
if max_w and cur_w and cur_w != max_w: # fewer lanes → almost always lane-sharing
|
||||
findings.append(Finding(
|
||||
WARNING, "PCIe", f"{name} linked at x{cur_w} (supports x{max_w})",
|
||||
f"{name} negotiated PCIe Gen{cur_g} x{cur_w}, but the drive supports "
|
||||
f"Gen{max_g} x{max_w}. Fewer lanes is usually motherboard lane-sharing — a GPU or a "
|
||||
"second card in a PCIe slot, or another populated M.2, can steal lanes from this slot.",
|
||||
"Check your board manual's lane-sharing table; move the drive to a full-x4 "
|
||||
"(often CPU-attached) M.2 slot."))
|
||||
elif cur_g < max_g: # full width but a lower generation → slower slot or idle ASPM
|
||||
findings.append(Finding(
|
||||
INFO, "PCIe", f"{name} linked at Gen{cur_g} (supports Gen{max_g})",
|
||||
f"{name} negotiated PCIe Gen{cur_g} but supports Gen{max_g}. This can be a slower "
|
||||
"(chipset or older) M.2 slot, or normal link power management (ASPM) at idle.",
|
||||
"If you expect full speed, check the slot and the BIOS PCIe/ASPM settings."))
|
||||
return findings
|
||||
|
||||
|
||||
def check_displays() -> list[Finding]:
|
||||
"""Flag monitors running below their max refresh rate at the current resolution — e.g. a
|
||||
165 Hz panel set to 60 Hz, a common and easily-missed gaming setting (read-only suggestion)."""
|
||||
from . import displays
|
||||
|
||||
findings: list[Finding] = []
|
||||
for m in displays.collect():
|
||||
if m.can_go_faster:
|
||||
findings.append(Finding(
|
||||
INFO, "Display",
|
||||
f"{m.connector} at {round(m.refresh)} Hz (supports {round(m.max_refresh)} Hz)",
|
||||
f"{m.name or m.connector} is running at {round(m.refresh)} Hz at "
|
||||
f"{m.width}x{m.height}, but supports {round(m.max_refresh)} Hz at that resolution.",
|
||||
"Raise the refresh rate in your desktop's Display settings (GNOME: Settings → Displays)."))
|
||||
return findings
|
||||
|
||||
|
||||
def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||
"""Run all checks and return findings sorted by severity (worst first).
|
||||
|
||||
@@ -273,5 +322,7 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
|
||||
else:
|
||||
findings += check_smart()
|
||||
findings += check_live_temps()
|
||||
findings += check_pcie_links()
|
||||
findings += check_displays()
|
||||
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
||||
return findings
|
||||
|
||||
@@ -134,11 +134,10 @@ def _gen(speed: str) -> int | None:
|
||||
return _PCIE_GEN.get(tok)
|
||||
|
||||
|
||||
def _link_desc(dev: Path) -> str:
|
||||
"""Describe a PCI device's negotiated PCIe link, noting if it's below its max.
|
||||
def read_link(dev: Path) -> tuple[int | None, str, int | None, str]:
|
||||
"""Negotiated/max PCIe link for a PCI device dir: (cur_gen, cur_width, max_gen, max_width).
|
||||
|
||||
e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a
|
||||
slower slot.
|
||||
Widths are the raw sysfs strings (e.g. '4'); gens are ints (4) or None when unreadable.
|
||||
"""
|
||||
def rd(name: str) -> str:
|
||||
try:
|
||||
@@ -146,8 +145,17 @@ def _link_desc(dev: Path) -> str:
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
cur_g, cur_w = _gen(rd("current_link_speed")), rd("current_link_width")
|
||||
max_g, max_w = _gen(rd("max_link_speed")), rd("max_link_width")
|
||||
return (_gen(rd("current_link_speed")), rd("current_link_width"),
|
||||
_gen(rd("max_link_speed")), rd("max_link_width"))
|
||||
|
||||
|
||||
def _link_desc(dev: Path) -> str:
|
||||
"""Describe a PCI device's negotiated PCIe link, noting if it's below its max.
|
||||
|
||||
e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a
|
||||
slower slot.
|
||||
"""
|
||||
cur_g, cur_w, max_g, max_w = read_link(dev)
|
||||
if not cur_g or not cur_w:
|
||||
return ""
|
||||
desc = f"PCIe Gen{cur_g} x{cur_w}"
|
||||
@@ -156,6 +164,16 @@ def _link_desc(dev: Path) -> str:
|
||||
return desc
|
||||
|
||||
|
||||
def nvme_controllers() -> list[tuple[str, Path]]:
|
||||
"""Each NVMe controller as (name, pci-device-dir), e.g. ('nvme0', /sys/.../device)."""
|
||||
base = Path("/sys/class/nvme")
|
||||
try:
|
||||
entries = [p for p in base.iterdir() if re.fullmatch(r"nvme\d+", p.name)]
|
||||
except OSError:
|
||||
return []
|
||||
return sorted((p.name, p / "device") for p in entries)
|
||||
|
||||
|
||||
def _nvme_link(block_name: str) -> str:
|
||||
"""PCIe link for an NVMe block device (nvme0n1 → controller nvme0); '' for non-NVMe."""
|
||||
m = re.match(r"(nvme\d+)", block_name)
|
||||
@@ -183,10 +201,18 @@ def _storage() -> Section:
|
||||
|
||||
|
||||
def _display() -> Section:
|
||||
return Section("Display", [
|
||||
from . import displays
|
||||
|
||||
items = [
|
||||
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
|
||||
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
|
||||
])
|
||||
]
|
||||
for m in displays.collect():
|
||||
val = f"{m.width}x{m.height} @ {round(m.refresh)} Hz"
|
||||
if m.can_go_faster:
|
||||
val += f" (supports {round(m.max_refresh)} Hz)"
|
||||
items.append((m.label(), val))
|
||||
return Section("Display", items)
|
||||
|
||||
|
||||
def _dmidecode() -> dict:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for display detection (Mutter D-Bus JSON + xrandr parsers)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from rigdoctor.core import displays
|
||||
|
||||
# Minimal Mutter GetCurrentState (busctl --json) shape: current mode is 60 Hz, panel max 165 Hz.
|
||||
_MUTTER_60 = (
|
||||
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
|
||||
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
|
||||
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-preferred":{"type":"b","data":true}}]'
|
||||
'],{}]],[],{}]}'
|
||||
)
|
||||
_MUTTER_MAX = (
|
||||
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
|
||||
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
|
||||
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{}]'
|
||||
'],{}]],[],{}]}'
|
||||
)
|
||||
|
||||
_XRANDR_60 = """Screen 0: minimum 8 x 8, current 3440 x 1440, maximum 16384 x 16384
|
||||
DP-1 connected primary 3440x1440+0+0 (normal left inverted right x axis y axis) 800mm x 335mm
|
||||
3440x1440 60.00*+ 165.00 100.00
|
||||
2560x1440 165.00 60.00
|
||||
HDMI-1 disconnected (normal left inverted right x axis y axis)
|
||||
"""
|
||||
|
||||
|
||||
class MutterParseTests(unittest.TestCase):
|
||||
def test_parses_and_flags_higher_refresh(self):
|
||||
mons = displays._parse_mutter(_MUTTER_60)
|
||||
self.assertEqual(len(mons), 1)
|
||||
m = mons[0]
|
||||
self.assertEqual(m.connector, "DP-1")
|
||||
self.assertEqual(m.name, "Samsung LC34G55T") # PNP code SAM mapped
|
||||
self.assertEqual((m.width, m.height), (3440, 1440))
|
||||
self.assertEqual(round(m.refresh), 60)
|
||||
self.assertEqual(round(m.max_refresh), 165)
|
||||
self.assertTrue(m.can_go_faster)
|
||||
|
||||
def test_at_max_is_not_flagged(self):
|
||||
m = displays._parse_mutter(_MUTTER_MAX)[0]
|
||||
self.assertEqual(round(m.refresh), 165)
|
||||
self.assertFalse(m.can_go_faster)
|
||||
|
||||
def test_garbage_returns_empty(self):
|
||||
self.assertEqual(displays._parse_mutter("not json"), [])
|
||||
self.assertEqual(displays._parse_mutter("{}"), [])
|
||||
|
||||
|
||||
class XrandrParseTests(unittest.TestCase):
|
||||
def test_current_and_max_refresh(self):
|
||||
mons = displays._parse_xrandr(_XRANDR_60)
|
||||
self.assertEqual(len(mons), 1) # disconnected output ignored
|
||||
m = mons[0]
|
||||
self.assertEqual(m.connector, "DP-1")
|
||||
self.assertEqual((m.width, m.height), (3440, 1440))
|
||||
self.assertEqual(round(m.refresh), 60)
|
||||
self.assertEqual(round(m.max_refresh), 165)
|
||||
self.assertTrue(m.can_go_faster)
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
self.assertEqual(displays._parse_xrandr(""), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+56
-1
@@ -1,8 +1,19 @@
|
||||
"""Tests for the M4 health report's log scanner (synthetic input)."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core.health import CRITICAL, WARNING, run_health_checks, scan_journal_text
|
||||
from rigdoctor.core import displays, health
|
||||
from rigdoctor.core.health import (
|
||||
CRITICAL,
|
||||
INFO,
|
||||
WARNING,
|
||||
check_displays,
|
||||
check_pcie_links,
|
||||
run_health_checks,
|
||||
scan_journal_text,
|
||||
)
|
||||
|
||||
|
||||
class HealthScanTests(unittest.TestCase):
|
||||
@@ -42,5 +53,49 @@ class HealthScanTests(unittest.TestCase):
|
||||
self.assertEqual(ranks, sorted(ranks))
|
||||
|
||||
|
||||
class PcieLinkCheckTests(unittest.TestCase):
|
||||
def _with_link(self, cur_g, cur_w, max_g, max_w):
|
||||
# one fake NVMe controller returning the given link tuple
|
||||
return (mock.patch("rigdoctor.core.inventory.nvme_controllers",
|
||||
return_value=[("nvme0", Path("/x"))]),
|
||||
mock.patch("rigdoctor.core.inventory.read_link",
|
||||
return_value=(cur_g, cur_w, max_g, max_w)))
|
||||
|
||||
def test_reduced_width_is_a_warning_about_lane_sharing(self):
|
||||
ctrls, link = self._with_link(4, "2", 4, "4") # Gen4 x2 but supports x4
|
||||
with ctrls, link:
|
||||
findings = check_pcie_links()
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0].severity, WARNING)
|
||||
self.assertIn("lane-sharing", findings[0].detail)
|
||||
|
||||
def test_reduced_speed_only_is_info(self):
|
||||
ctrls, link = self._with_link(3, "4", 4, "4") # Gen3 x4 but supports Gen4
|
||||
with ctrls, link:
|
||||
findings = check_pcie_links()
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0].severity, INFO)
|
||||
|
||||
def test_full_speed_no_finding(self):
|
||||
ctrls, link = self._with_link(4, "4", 4, "4")
|
||||
with ctrls, link:
|
||||
self.assertEqual(check_pcie_links(), [])
|
||||
|
||||
|
||||
class DisplayCheckTests(unittest.TestCase):
|
||||
def test_lower_than_max_refresh_is_flagged(self):
|
||||
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 60.0, 165.0)
|
||||
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
|
||||
findings = check_displays()
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0].severity, INFO)
|
||||
self.assertIn("165", findings[0].title)
|
||||
|
||||
def test_at_max_refresh_no_finding(self):
|
||||
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 165.0, 165.0)
|
||||
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
|
||||
self.assertEqual(check_displays(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user