mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-13 08:50:47 +01:00
43 tests, no production change - the module is byte-identical to HEAD. This lands separately from the split that follows because a split verified by tests written in the same commit proves nothing: the tests would have been written against the post-split shape. Covers every function, and specifically the branches a happy-path test misses: zero-length window and cpu_count() -> None in _cpu_table, non-positive RSS and the CPU/pid tie-break in _dedupe_ram, the sibling truncation boundary in _ram_table, zero total_samples in _gpu_table, OSError and unparseable MemTotal in _host_profile, and the single-sample unknown-interval path in _methodology_section and _render_report. _host_profile is the only impure function; its /proc reads go through a monkeypatched Path.open and nvidia-smi is stubbed at the _run seam, so no test touches the disk or forks. 117 stmts / 28 branches, 0 missed. linux_configuration/tests: 100 -> 143 passing. No suppressions: two ANN401s were removed by typing the helper kwargs explicitly rather than silencing them, and a mypy ignore went away with a fallback branch no caller reached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
574 lines
20 KiB
Python
574 lines
20 KiB
Python
"""Tests for usage_report Markdown rendering: duration/escaping helpers, the
|
|
CPU/RAM/GPU tables, the RSS-bucket dedupe, the host and methodology sections,
|
|
and full report assembly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from typing import TYPE_CHECKING
|
|
|
|
import _usage_report_render as render
|
|
from _usage_report_types import _HZ, GpuAgg, ProcAgg, _Window
|
|
import pytest
|
|
from usage_report import _Aggregates
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
def _proc(
|
|
name: str,
|
|
*,
|
|
cpu_ticks: int = 0,
|
|
peak_rss_kb: int = 0,
|
|
rss_kb_sum: int = 0,
|
|
rss_samples: int = 0,
|
|
pid_set: set[int] | None = None,
|
|
) -> ProcAgg:
|
|
"""Build a ProcAgg with only the fields a given test cares about."""
|
|
return ProcAgg(
|
|
name,
|
|
cpu_ticks=cpu_ticks,
|
|
peak_rss_kb=peak_rss_kb,
|
|
rss_kb_sum=rss_kb_sum,
|
|
rss_samples=rss_samples,
|
|
pid_set=set() if pid_set is None else pid_set,
|
|
)
|
|
|
|
|
|
def _cells(row: str) -> list[str]:
|
|
"""Split a Markdown table row into stripped cell values."""
|
|
return [c.strip() for c in row.strip().strip("|").split("|")]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _fmt_h
|
|
# --------------------------------------------------------------------------- #
|
|
@pytest.mark.parametrize(
|
|
("seconds", "expected"),
|
|
[
|
|
(0.0, "0.0s"),
|
|
(8.34, "8.3s"),
|
|
(59.9, "59.9s"),
|
|
(60.0, "1m 00s"),
|
|
(252.0, "4m 12s"),
|
|
(3599.0, "59m 59s"),
|
|
(3600.0, "1h 00m"),
|
|
(5000.0, "1h 23m"),
|
|
],
|
|
)
|
|
def test_fmt_h_picks_unit_by_magnitude(seconds: float, expected: str) -> None:
|
|
"""Durations switch h/m/s at exactly 3600 and 60 seconds."""
|
|
assert render._fmt_h(seconds) == expected
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _md_escape
|
|
# --------------------------------------------------------------------------- #
|
|
def test_md_escape_escapes_pipe_and_flattens_newline() -> None:
|
|
"""Pipes are backslash-escaped and newlines become spaces."""
|
|
assert render._md_escape("a|b\nc") == r"a\|b c"
|
|
|
|
|
|
def test_md_escape_leaves_plain_names_untouched() -> None:
|
|
"""A name with no table-breaking characters passes through unchanged."""
|
|
assert render._md_escape("firefox") == "firefox"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _cpu_table
|
|
# --------------------------------------------------------------------------- #
|
|
def test_cpu_table_sorts_by_ticks_and_honours_top(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Rows are ordered by CPU ticks descending and truncated to *top*."""
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 4)
|
|
aggs = [
|
|
_proc("low", cpu_ticks=100),
|
|
_proc("high", cpu_ticks=900),
|
|
_proc("mid", cpu_ticks=500),
|
|
]
|
|
|
|
rows = render._cpu_table(aggs, window_s=60, top=2)
|
|
|
|
assert len(rows) == 4 # header + separator + 2 data rows
|
|
assert _cells(rows[2])[1] == "high"
|
|
assert _cells(rows[3])[1] == "mid"
|
|
|
|
|
|
def test_cpu_table_divides_box_percentage_by_cpu_count(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""'Avg CPU% (of box)' is the single-core percentage divided by ncpu."""
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 4)
|
|
# ProcAgg.cpu_seconds divides ticks by the real clock tick rate, so derive
|
|
# the tick count from it: one full core busy for the whole 60 s window.
|
|
aggs = [_proc("busy", cpu_ticks=60 * _HZ)]
|
|
|
|
rows = render._cpu_table(aggs, window_s=60, top=1)
|
|
|
|
cells = _cells(rows[2])
|
|
assert cells[3] == "100.0%"
|
|
assert cells[4] == "25.0%"
|
|
|
|
|
|
def test_cpu_table_zero_window_yields_zero_percent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A zero-length window cannot be divided by, so percentages are 0."""
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 2)
|
|
rows = render._cpu_table([_proc("x", cpu_ticks=500)], window_s=0, top=1)
|
|
|
|
cells = _cells(rows[2])
|
|
assert cells[3] == "0.0%"
|
|
assert cells[4] == "0.0%"
|
|
|
|
|
|
def test_cpu_table_falls_back_to_one_cpu(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When os.cpu_count() returns None the box percentage assumes one core."""
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: None)
|
|
rows = render._cpu_table([_proc("x", cpu_ticks=100)], window_s=60, top=1)
|
|
|
|
cells = _cells(rows[2])
|
|
assert cells[3] == cells[4]
|
|
|
|
|
|
def test_cpu_table_escapes_program_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A pipe in a program name cannot break out of its table cell."""
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 1)
|
|
rows = render._cpu_table([_proc("a|b", cpu_ticks=1)], window_s=60, top=1)
|
|
|
|
assert r"a\|b" in rows[2]
|
|
|
|
|
|
def test_cpu_table_empty_input_is_header_only(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No aggregates means header and separator with no data rows."""
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 1)
|
|
assert len(render._cpu_table([], window_s=60, top=5)) == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _dedupe_ram
|
|
# --------------------------------------------------------------------------- #
|
|
def test_dedupe_ram_groups_by_bucket_and_keeps_top_cpu() -> None:
|
|
"""Same-RSS rows collapse to the highest-CPU representative."""
|
|
same_rss = 4096
|
|
rows = render._dedupe_ram(
|
|
[
|
|
_proc("worker", peak_rss_kb=same_rss, cpu_ticks=10),
|
|
_proc("parent", peak_rss_kb=same_rss, cpu_ticks=99),
|
|
_proc("other", peak_rss_kb=same_rss, cpu_ticks=5),
|
|
],
|
|
)
|
|
|
|
assert len(rows) == 1
|
|
rep, siblings = rows[0]
|
|
assert rep.name == "parent"
|
|
assert sorted(siblings) == ["other", "worker"]
|
|
|
|
|
|
def test_dedupe_ram_skips_non_positive_rss() -> None:
|
|
"""Rows with no measured RSS are dropped entirely."""
|
|
assert render._dedupe_ram([_proc("ghost", peak_rss_kb=0)]) == []
|
|
|
|
|
|
def test_dedupe_ram_orders_buckets_by_rss_descending() -> None:
|
|
"""Distinct RSS buckets come back largest-first."""
|
|
rows = render._dedupe_ram(
|
|
[
|
|
_proc("small", peak_rss_kb=1024),
|
|
_proc("large", peak_rss_kb=10 * 1024),
|
|
],
|
|
)
|
|
|
|
assert [rep.name for rep, _ in rows] == ["large", "small"]
|
|
|
|
|
|
def test_dedupe_ram_breaks_cpu_ties_on_pid_count() -> None:
|
|
"""Equal CPU ticks fall back to the higher PID count for the rep."""
|
|
rows = render._dedupe_ram(
|
|
[
|
|
_proc("one_pid", peak_rss_kb=2048, cpu_ticks=7, pid_set={1}),
|
|
_proc("many_pids", peak_rss_kb=2048, cpu_ticks=7, pid_set={1, 2, 3}),
|
|
],
|
|
)
|
|
|
|
assert rows[0][0].name == "many_pids"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _ram_table
|
|
# --------------------------------------------------------------------------- #
|
|
def test_ram_table_shows_dash_when_no_siblings() -> None:
|
|
"""A lone row in its bucket renders an em dash in the sibling column."""
|
|
rows = render._ram_table([_proc("solo", peak_rss_kb=2048)], top=5)
|
|
|
|
assert _cells(rows[2])[-1] == "—"
|
|
|
|
|
|
def test_ram_table_lists_siblings_without_overflow_note() -> None:
|
|
"""Up to the display cap, every sibling name is listed and no '+N more'."""
|
|
aggs = [_proc("rep", peak_rss_kb=4096, cpu_ticks=100)]
|
|
aggs += [_proc(f"s{i}", peak_rss_kb=4096, cpu_ticks=1) for i in range(3)]
|
|
|
|
sib = _cells(render._ram_table(aggs, top=5)[2])[-1]
|
|
|
|
assert "more" not in sib
|
|
assert sorted(sib.split(", ")) == ["s0", "s1", "s2"]
|
|
|
|
|
|
def test_ram_table_truncates_sibling_list_with_count() -> None:
|
|
"""Beyond the cap, extra siblings collapse into a '(+N more)' suffix."""
|
|
over = render._MAX_SIBLINGS_SHOWN + 2
|
|
aggs = [_proc("rep", peak_rss_kb=4096, cpu_ticks=100)]
|
|
aggs += [_proc(f"s{i}", peak_rss_kb=4096, cpu_ticks=1) for i in range(over)]
|
|
|
|
sib = _cells(render._ram_table(aggs, top=5)[2])[-1]
|
|
|
|
assert sib.endswith(f"(+{over - render._MAX_SIBLINGS_SHOWN} more)")
|
|
# Exactly _MAX_SIBLINGS_SHOWN names are listed, joined by ", ".
|
|
assert sib.count(",") == render._MAX_SIBLINGS_SHOWN - 1
|
|
|
|
|
|
def test_ram_table_escapes_sibling_and_rep_names() -> None:
|
|
"""Pipes are escaped in both the representative and sibling columns."""
|
|
aggs = [
|
|
_proc("re|p", peak_rss_kb=4096, cpu_ticks=100),
|
|
_proc("si|b", peak_rss_kb=4096, cpu_ticks=1),
|
|
]
|
|
|
|
row = render._ram_table(aggs, top=5)[2]
|
|
|
|
assert r"re\|p" in row
|
|
assert r"si\|b" in row
|
|
|
|
|
|
def test_ram_table_honours_top_limit() -> None:
|
|
"""Only *top* buckets are rendered."""
|
|
aggs = [_proc(f"p{i}", peak_rss_kb=(i + 1) * 4096) for i in range(5)]
|
|
|
|
assert len(render._ram_table(aggs, top=2)) == 4
|
|
|
|
|
|
def test_ram_table_empty_input_is_header_only() -> None:
|
|
"""No aggregates means header and separator only."""
|
|
assert len(render._ram_table([], top=5)) == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _gpu_table
|
|
# --------------------------------------------------------------------------- #
|
|
def test_gpu_table_sorts_by_gpu_seconds_and_honours_top() -> None:
|
|
"""Rows are ordered by SM-seconds descending and truncated to *top*."""
|
|
aggs = {
|
|
"low": GpuAgg("low", sm_pct_sum=10.0, samples=1),
|
|
"high": GpuAgg("high", sm_pct_sum=900.0, samples=1),
|
|
"mid": GpuAgg("mid", sm_pct_sum=100.0, samples=1),
|
|
}
|
|
|
|
rows = render._gpu_table(aggs, total_samples=10, top=2)
|
|
|
|
assert len(rows) == 4
|
|
assert _cells(rows[2])[1] == "high"
|
|
assert _cells(rows[3])[1] == "mid"
|
|
|
|
|
|
def test_gpu_table_reports_sample_presence_percentage() -> None:
|
|
"""The Samples column shows the share of total samples the process was in."""
|
|
aggs = {"g": GpuAgg("g", sm_pct_sum=50.0, samples=5)}
|
|
|
|
cells = _cells(render._gpu_table(aggs, total_samples=20, top=1)[2])
|
|
|
|
assert cells[6] == "5 (25%)"
|
|
|
|
|
|
def test_gpu_table_zero_total_samples_yields_zero_presence() -> None:
|
|
"""A zero sample total cannot be divided by, so presence renders as 0%."""
|
|
aggs = {"g": GpuAgg("g", sm_pct_sum=50.0, samples=5)}
|
|
|
|
cells = _cells(render._gpu_table(aggs, total_samples=0, top=1)[2])
|
|
|
|
assert cells[6] == "5 (0%)"
|
|
|
|
|
|
def test_gpu_table_escapes_program_names() -> None:
|
|
"""A pipe in a GPU process name cannot break out of its cell."""
|
|
aggs = {"a|b": GpuAgg("a|b", sm_pct_sum=1.0, samples=1)}
|
|
|
|
assert r"a\|b" in render._gpu_table(aggs, total_samples=1, top=1)[2]
|
|
|
|
|
|
def test_gpu_table_empty_input_is_header_only() -> None:
|
|
"""No GPU aggregates means header and separator only."""
|
|
assert len(render._gpu_table({}, total_samples=0, top=5)) == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _host_profile
|
|
# --------------------------------------------------------------------------- #
|
|
def _fake_proc_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
contents: dict[str, str | OSError],
|
|
) -> None:
|
|
"""Route Path.open for /proc/cpuinfo and /proc/meminfo to canned text.
|
|
|
|
_host_profile only ever opens those two paths, so every caller supplies
|
|
both and there is no real-filesystem fallback to keep here.
|
|
"""
|
|
|
|
def fake_open(
|
|
self: Path,
|
|
*_args: object,
|
|
**_kwargs: object,
|
|
) -> io.StringIO:
|
|
value = contents[str(self)]
|
|
if isinstance(value, OSError):
|
|
raise value
|
|
return io.StringIO(value)
|
|
|
|
monkeypatch.setattr(render.Path, "open", fake_open)
|
|
|
|
|
|
def test_host_profile_reads_cpu_model_and_memory(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""CPU model and total memory are lifted out of the /proc files."""
|
|
monkeypatch.setattr(render.platform, "node", lambda: "box")
|
|
monkeypatch.setattr(render.platform, "release", lambda: "6.1.0")
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 8)
|
|
monkeypatch.setattr(render, "_run", lambda _cmd: "")
|
|
_fake_proc_files(
|
|
monkeypatch,
|
|
{
|
|
"/proc/cpuinfo": "flags\t: x\nmodel name\t: Ryzen 9\nmodel name\t: other\n",
|
|
"/proc/meminfo": "SwapTotal: 1 kB\nMemTotal: 32768000 kB\n",
|
|
},
|
|
)
|
|
|
|
info = render._host_profile()
|
|
|
|
assert info["hostname"] == "box"
|
|
assert info["kernel"] == "6.1.0"
|
|
assert info["cpus_online"] == "8"
|
|
assert info["cpu_model"] == "Ryzen 9"
|
|
assert info["memory_total_gib"] == "31.2"
|
|
assert "gpu" not in info
|
|
|
|
|
|
def test_host_profile_includes_gpu_when_nvidia_smi_answers(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Multi-line nvidia-smi output is flattened onto one line."""
|
|
monkeypatch.setattr(render.platform, "node", lambda: "box")
|
|
monkeypatch.setattr(render.platform, "release", lambda: "6.1.0")
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 1)
|
|
monkeypatch.setattr(render, "_run", lambda _cmd: "GPU0, 8 GiB\nGPU1, 8 GiB\n")
|
|
_fake_proc_files(monkeypatch, {"/proc/cpuinfo": "", "/proc/meminfo": ""})
|
|
|
|
assert render._host_profile()["gpu"] == "GPU0, 8 GiB; GPU1, 8 GiB"
|
|
|
|
|
|
def test_host_profile_survives_unreadable_proc_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An OSError on either /proc file leaves that key absent, not raising."""
|
|
monkeypatch.setattr(render.platform, "node", lambda: "box")
|
|
monkeypatch.setattr(render.platform, "release", lambda: "6.1.0")
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 1)
|
|
monkeypatch.setattr(render, "_run", lambda _cmd: "")
|
|
_fake_proc_files(
|
|
monkeypatch,
|
|
{
|
|
"/proc/cpuinfo": OSError("nope"),
|
|
"/proc/meminfo": OSError("nope"),
|
|
},
|
|
)
|
|
|
|
info = render._host_profile()
|
|
|
|
assert "cpu_model" not in info
|
|
assert "memory_total_gib" not in info
|
|
|
|
|
|
def test_host_profile_survives_meminfo_without_digits(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A MemTotal line with no parseable number is skipped, not fatal."""
|
|
monkeypatch.setattr(render.platform, "node", lambda: "box")
|
|
monkeypatch.setattr(render.platform, "release", lambda: "6.1.0")
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: 1)
|
|
monkeypatch.setattr(render, "_run", lambda _cmd: "")
|
|
_fake_proc_files(
|
|
monkeypatch,
|
|
{"/proc/cpuinfo": "", "/proc/meminfo": "MemTotal: kB\n"},
|
|
)
|
|
|
|
assert "memory_total_gib" not in render._host_profile()
|
|
|
|
|
|
def test_host_profile_defaults_cpu_count_to_zero(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""os.cpu_count() returning None renders as '0' rather than 'None'."""
|
|
monkeypatch.setattr(render.platform, "node", lambda: "box")
|
|
monkeypatch.setattr(render.platform, "release", lambda: "6.1.0")
|
|
monkeypatch.setattr(render.os, "cpu_count", lambda: None)
|
|
monkeypatch.setattr(render, "_run", lambda _cmd: "")
|
|
_fake_proc_files(monkeypatch, {"/proc/cpuinfo": "", "/proc/meminfo": ""})
|
|
|
|
assert render._host_profile()["cpus_online"] == "0"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _fingerprint_section
|
|
# --------------------------------------------------------------------------- #
|
|
def test_fingerprint_section_renders_each_fact_as_a_bullet(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Every host-profile key becomes one bold Markdown bullet."""
|
|
monkeypatch.setattr(
|
|
render, "_host_profile", lambda: {"hostname": "box", "kernel": "6"}
|
|
)
|
|
|
|
assert render._fingerprint_section() == [
|
|
"## Host",
|
|
"",
|
|
"- **hostname**: box",
|
|
"- **kernel**: 6",
|
|
"",
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _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": _proc("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
|