feat(plugin): add MangoHud log parser with unit tests
This commit is contained in:
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
# MangoHud v0.8.4
|
||||
# note: session started at 2026-06-28 14:30:00
|
||||
# preset: 0
|
||||
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
|
||||
61,16.39,45,81,55,65,15,10
|
||||
59,16.95,44,79,56,66,14,10
|
||||
60,16.67,45,80,55,65,15,10
|
||||
63,15.87,47,83,55,65,15,10
|
||||
57,17.54,43,77,56,66,14,10
|
||||
60,16.67,45,80,55,65,15,10
|
||||
61,16.39,46,81,55,65,15,10
|
||||
# benchmark summary
|
||||
97%, 62
|
||||
AVG, 60
|
||||
1%, 57
|
||||
0.1%, 57
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user