testsAndMisc/linux_configuration/tests/test_usage_report_render.py
Krzysztof kuhy Rudnicki ff1a1b6a0e usage_report: split usage_report.py 426 -> 101 + 195 + 117 + 68
The entry point now holds only the parser, mode dispatch and main. Three
modules take one concern each: _usage_report_logs (where the logs are,
the state file, per-day segment planning), _usage_report_emit (preflight,
stdout, clipboard), _usage_report_run (the two run paths and the
aggregation they drive).

Verified by the 100%-coverage tests from the previous commit. An AST
comparison of all 22 top-level defs and classes reports 22 identical, 0
mismatches.

All four modules hold 100% line+branch. 204 tests before and after. Both
CLI modes were run against the machine's real logs, which is the check
the unit tests cannot make: they stub the collaborators, so only a real
run proves the new import graph resolves.

Two lessons, recorded because the next target is a 498-line file:

This cut was too big. Two names had to be relocated after their first
placement created an import cycle, and four modules' worth of monkeypatch
targets needed re-pointing. A smaller first slice would have been cheaper.

Patching a moved function must target the module whose namespace resolves
it - the caller's, not the one it was moved to. Getting that wrong on
_run_since made main() call the real function, which read the actual atop
logs and hung the suite rather than failing.

Three import bugs (Path and _fmt_h left under TYPE_CHECKING but used at
runtime, dataclass missing where _Aggregates landed) were caught
immediately by the coverage from the previous commit.

Over-cap 38 -> 37.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 12:46:06 +02:00

147 lines
4.8 KiB
Python

"""Tests for usage_report report assembly: the methodology section and the
full Markdown report that stitches every section together.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import _usage_report_render as render
from _usage_report_run import _Aggregates
from _usage_report_types import GpuAgg, ProcAgg, _Window
if TYPE_CHECKING:
import pytest
# --------------------------------------------------------------------------- #
# _methodology_section
# --------------------------------------------------------------------------- #
def test_methodology_section_reports_observed_interval() -> None:
"""A known sample interval is stated as an observed value."""
window = _Window(interval_s=10, seconds=3600)
lines = render._methodology_section("atop.log", "pmon.log", window)
assert "- **atop sample interval (observed)**: 10s" in lines
assert "- **atop log(s)**: atop.log" in lines
assert "- **pmon log(s)**: pmon.log" in lines
def test_methodology_section_flags_unknown_interval() -> None:
"""A zero interval means only one sample, and says so instead of '0s'."""
lines = render._methodology_section("a", "p", _Window(interval_s=0))
assert any("interval unknown" in line for line in lines)
assert not any("(observed)" in line for line in lines)
def test_methodology_section_states_coverage_window_in_human_units() -> None:
"""The coverage window is formatted with _fmt_h, not raw seconds."""
lines = render._methodology_section("a", "p", _Window(interval_s=10, seconds=5000))
assert any("1h 23m" in line for line in lines)
# --------------------------------------------------------------------------- #
# _render_report
# --------------------------------------------------------------------------- #
def _stub_sections(monkeypatch: pytest.MonkeyPatch) -> None:
"""Replace the host section so reports do not depend on the real machine."""
monkeypatch.setattr(render, "_host_profile", lambda: {"hostname": "box"})
monkeypatch.setattr(render.os, "cpu_count", lambda: 4)
def _aggregates(
*,
cpu: dict[str, ProcAgg] | None = None,
gpu: dict[str, GpuAgg] | None = None,
window: _Window | None = None,
gpu_samples: int = 0,
) -> _Aggregates:
"""Build an _Aggregates with only the fields a given test cares about."""
return _Aggregates(
cpu={} if cpu is None else cpu,
gpu={} if gpu is None else gpu,
window=_Window() if window is None else window,
gpu_samples=gpu_samples,
days_with_data=1,
)
def test_render_report_includes_every_section(monkeypatch: pytest.MonkeyPatch) -> None:
"""The assembled report carries all five headings and the LLM prompt."""
_stub_sections(monkeypatch)
aggs = _aggregates(
cpu={"firefox": ProcAgg("firefox", cpu_ticks=1000, peak_rss_kb=4096)},
gpu={"game": GpuAgg("game", sm_pct_sum=50.0, samples=5)},
window=_Window(
start="A",
end="B",
distinct_samples=7,
interval_s=10,
seconds=60,
),
gpu_samples=5,
)
out = render._render_report(
aggs,
top=5,
atop_desc="atop.log",
pmon_desc="pmon.log",
period_line="- **Period**: today",
)
assert out.startswith("# System resource usage report\n")
for heading in (
"## Host",
"## Methodology",
"## Top CPU consumers",
"## Top RAM consumers",
"## Top GPU consumers",
"## Suggested LLM prompt",
):
assert heading in out
assert "- **Period**: today" in out
assert "- **atop window**: A → B" in out
assert "7 distinct" in out
assert "firefox" in out
assert "game" in out
assert out.endswith("\n")
def test_render_report_notes_absent_gpu_data(monkeypatch: pytest.MonkeyPatch) -> None:
"""With no GPU aggregates the GPU section is a placeholder, not a table."""
_stub_sections(monkeypatch)
out = render._render_report(
_aggregates(window=_Window(interval_s=10, seconds=60)),
top=5,
atop_desc="a",
pmon_desc="p",
period_line="- **Period**: x",
)
assert "_No GPU pmon data found._" in out
# The phrase "GPU SM-seconds" also appears in the methodology prose, so
# assert on the table header cell instead to prove no table was emitted.
assert "| GPU SM-seconds |" not in out
def test_render_report_marks_single_sample_interval(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A zero interval renders as 'n/a (single sample)' in the header block."""
_stub_sections(monkeypatch)
out = render._render_report(
_aggregates(window=_Window(interval_s=0)),
top=5,
atop_desc="a",
pmon_desc="p",
period_line="- **Period**: x",
)
assert "sample interval ≈ n/a (single sample)" in out