feat(plugin): add MangoHud log parser with unit tests

This commit is contained in:
2026-06-28 07:54:32 +08:00
parent dc1281411d
commit e48efe86b1
6 changed files with 327 additions and 1 deletions
@@ -0,0 +1,199 @@
"""Tests for MangoHud log parsing."""
import os
# Parser functions — these will be imported from main.py once implemented.
# For now we define them here to test the logic, then move to main.py.
def parse_mangohud_log(log_content: str) -> dict:
"""Parse a MangoHud log file's content and return FPS stats.
Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?}
"""
lines = log_content.strip().split('\n')
# Find the header row (first non-comment, non-empty line that looks like column names)
header_idx = None
fps_col = 0
frametime_col = None
gpu_power_col = None
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
# Check if this is a header (contains 'fps')
if 'fps' in stripped.lower() and ',' in stripped:
columns = [c.strip().lower() for c in stripped.split(',')]
if 'fps' in columns:
fps_col = columns.index('fps')
if 'frametime' in columns:
frametime_col = columns.index('frametime')
if 'gpu_power' in columns:
gpu_power_col = columns.index('gpu_power')
header_idx = i
break
if header_idx is None:
return {"error": "Could not find FPS column in log header"}
# Extract data rows (lines after header that start with a number)
fps_values = []
frametime_values = []
gpu_power_values = []
for line in lines[header_idx + 1:]:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
# Check if it's a summary line (e.g. "97%,\t62" or "AVG,\t60")
if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')):
continue
parts = [p.strip() for p in stripped.split(',')]
try:
fps = float(parts[fps_col])
fps_values.append(fps)
if frametime_col is not None and frametime_col < len(parts):
ft = float(parts[frametime_col])
frametime_values.append(ft)
if gpu_power_col is not None and gpu_power_col < len(parts):
gp = float(parts[gpu_power_col])
gpu_power_values.append(gp)
except (ValueError, IndexError):
continue
if not fps_values:
return {"error": "No FPS data found in log"}
# Compute stats
fps_avg = round(sum(fps_values) / len(fps_values), 1)
fps_low = round(min(fps_values), 1)
fps_high = round(max(fps_values), 1)
# 1% low: average the slowest 1% of frame times (largest frametimes),
# then convert to FPS. Falls back to the lowest FPS percentile if no
# frametime data is available.
if frametime_values:
sorted_ft = sorted(frametime_values)
one_percent_count = max(1, int(len(sorted_ft) * 0.01))
worst_ft = sorted_ft[-one_percent_count:]
avg_worst_ft = sum(worst_ft) / len(worst_ft)
fps_one_percent_low = round(1000.0 / avg_worst_ft, 1) if avg_worst_ft > 0 else None
else:
# Fall back: sort FPS values, take 1st percentile from bottom
sorted_fps = sorted(fps_values)
one_percent_idx = max(0, int(len(sorted_fps) * 0.01))
fps_one_percent_low = round(sorted_fps[one_percent_idx], 1)
tdp_watts = None
if gpu_power_values:
tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1)
return {
"fpsAvg": fps_avg,
"fpsLow": fps_low,
"fpsHigh": fps_high,
"fpsOnePercentLow": fps_one_percent_low,
"tdpWatts": tdp_watts,
}
# ── Tests ──────────────────────────────────────────────────────────
def test_parse_basic_log():
log = """\
# MangoHud v0.8.4
fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power
60,16.67,45,80,55,65,15,10
62,16.13,46,82,55,65,15,10
58,17.24,44,78,56,66,14,10
"""
result = parse_mangohud_log(log)
assert "error" not in result
assert result["fpsAvg"] == 60.0
assert result["fpsLow"] == 58.0
assert result["fpsHigh"] == 62.0
assert result["tdpWatts"] == 14.7 # avg of 15,15,14 = 14.667 -> 14.7
def test_parse_log_with_summary_section():
"""The summary section (97%, AVG, 1%, 0.1%) should be skipped as data."""
log = """\
# MangoHud v0.8.4
fps,frametime,cpu_load,gpu_power
60,16.67,45,15
62,16.13,46,15
58,17.24,44,14
# benchmark summary
97%, 62
AVG, 60
1%, 57
0.1%, 57
"""
result = parse_mangohud_log(log)
assert "error" not in result
assert result["fpsAvg"] == 60.0
# Should not have tried to parse summary lines as data
assert result["fpsLow"] == 58.0
assert result["fpsHigh"] == 62.0
def test_parse_empty_log_returns_error():
result = parse_mangohud_log("")
assert "error" in result
def test_parse_log_without_fps_column():
log = """\
# no fps here
cpu_load,gpu_load
45,80
"""
result = parse_mangohud_log(log)
assert "error" in result
def test_parse_log_without_gpu_power():
"""tdpWatts should be None if gpu_power column is absent."""
log = """\
fps,frametime,cpu_load
60,16.67,45
62,16.13,46
"""
result = parse_mangohud_log(log)
assert result["tdpWatts"] is None
def test_parse_one_percent_low_from_frametime():
"""1% low should be computed from frame times when available."""
log = """\
fps,frametime
60,16.67
30,33.33
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
"""
result = parse_mangohud_log(log)
# The 33.33ms frame time is the worst — 1% low should be ~30 fps
assert result["fpsOnePercentLow"] is not None
assert result["fpsOnePercentLow"] <= 35 # roughly 1000/33.33 = 30
def test_parse_fixture_file():
"""Parse the actual fixture file."""
fixture_path = os.path.join(
os.path.dirname(__file__), "fixtures", "sample_mangohud.log"
)
with open(fixture_path, 'r') as f:
content = f.read()
result = parse_mangohud_log(content)
assert "error" not in result
assert result["fpsAvg"] == 60.1 # avg of the 10 data rows
assert result["fpsLow"] == 57.0
assert result["fpsHigh"] == 63.0
assert result["tdpWatts"] is not None