From ad646541ad64a58cd1330bc7954db3479b0de34e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 11:34:08 +0200 Subject: [PATCH] usage_report: split _usage_report_render.py 310 -> 181 + 123 + 24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified by the 100%-coverage tests from the previous commit, written before this seam existed. _cpu_table/_dedupe_ram/_ram_table/_gpu_table move to _usage_report_tables.py with the two constants they own. _md_escape and _fmt_h move to _usage_report_format.py: both the tables module and the render module need them, so a third module is what avoids a cycle. Import sites name the owning module; no re-export barrel. usage_report.py needed no edit - it imports _fmt_h and _render_report from the render module, and both names still resolve there. Evidence the move was verbatim: an AST comparison of all 10 functions against HEAD reports 10 identical, 0 mismatches. That matters because I retyped rather than cut-and-pasted, and did briefly introduce a literal × where the original had × - ruff's RUF001 caught it. Coverage stays 100% line+branch on all three (15/55/54 stmts, 4/10/14 branches, 0 missed). Test count 43 before and after, summed across the five files it now lives in - the check that matters for a test split, since a file outside the runner's glob is silently never collected. Directory total 143 passed, unchanged. Ran the real program: a full report from today's atop + pmon logs, every section rendering through the new boundaries including the RAM sibling dedupe and its (+20 more) truncation. Over-cap 41 -> 39. No suppressions; a shared test-helper module was tried and abandoned because name-tests-test would have needed an exclusion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../usage-report-render-split-2026-08-17.json | 18 + .../usage-report-render-split-2026-08-17.json | 58 +++ .../bin/_usage_report_format.py | 24 + .../bin/_usage_report_render.py | 137 +----- .../bin/_usage_report_tables.py | 123 +++++ .../tests/test_usage_report_format.py | 40 ++ .../tests/test_usage_report_gpu_table.py | 56 +++ .../tests/test_usage_report_host.py | 150 ++++++ .../tests/test_usage_report_render.py | 447 +----------------- .../tests/test_usage_report_tables.py | 216 +++++++++ 10 files changed, 699 insertions(+), 570 deletions(-) create mode 100644 docs/superpowers/contracts/usage-report-render-split-2026-08-17.json create mode 100644 docs/superpowers/evidence/usage-report-render-split-2026-08-17.json create mode 100644 linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_format.py create mode 100644 linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_tables.py create mode 100644 linux_configuration/tests/test_usage_report_format.py create mode 100644 linux_configuration/tests/test_usage_report_gpu_table.py create mode 100644 linux_configuration/tests/test_usage_report_host.py create mode 100644 linux_configuration/tests/test_usage_report_tables.py diff --git a/docs/superpowers/contracts/usage-report-render-split-2026-08-17.json b/docs/superpowers/contracts/usage-report-render-split-2026-08-17.json new file mode 100644 index 00000000..f8a7e5d9 --- /dev/null +++ b/docs/superpowers/contracts/usage-report-render-split-2026-08-17.json @@ -0,0 +1,18 @@ +{ + "title": "Split _usage_report_render.py along a functional seam", + "objective": "_usage_report_render.py was 310 lines against a 250-line cap. The preceding commit took it from 21% to 100% line and branch coverage precisely so this split could be verified against tests that predate the seam. Success means the file drops under the cap, every moved function is provably unchanged, coverage stays at 100%, the test count is identical, and the program still generates a real report.", + "acceptance_criteria": [ + "_usage_report_render.py is under 250 lines and the over-cap total drops (41 -> 39)", + "Every moved function is AST-identical to its HEAD version - verbatim movement, not a rewrite", + "All three resulting modules hold 100% line and branch coverage, matching the pre-split single-file figure", + "The test count is exactly 43 both before and after, summed across the split test files, so no test was lost to a collection glob", + "usage_report.py still runs and produces a complete report from the machine's real atop/pmon logs", + "No suppression of any kind, and no re-export barrel in _usage_report_render" + ], + "out_of_scope": [ + "_usage_report_parsing.py and usage_report.py, both still 425 lines and over cap - each needs its own coverage-first commit", + "Any behaviour change to the report output", + "The pacman_wrapper.sh source-text test rewrite and the installer deploy-lib work, which are separate tasks in this session's brief" + ], + "verifier": "python3 scratchpad/verify_split.py for the AST-identity claim; pytest linux_configuration/tests with --cov of the three modules and --cov-branch for coverage; pytest --collect-only per file for the test count; python usage_report.py --date for the runtime check; check_file_length.sh --all for the cap; pre-commit run --files for lint." +} diff --git a/docs/superpowers/evidence/usage-report-render-split-2026-08-17.json b/docs/superpowers/evidence/usage-report-render-split-2026-08-17.json new file mode 100644 index 00000000..c354caad --- /dev/null +++ b/docs/superpowers/evidence/usage-report-render-split-2026-08-17.json @@ -0,0 +1,58 @@ +{ + "intent": "Split _usage_report_render.py (310 lines, over the 250 cap) into three modules along a functional seam, verified by the 100%-coverage tests that landed in the preceding commit.", + "scope": [ + "_usage_report_render.py 310 -> 181 (host profile, methodology, report assembly)", + "New _usage_report_tables.py 123 (CPU/RAM/GPU Markdown tables and the RSS dedupe)", + "New _usage_report_format.py 24 (_md_escape and _fmt_h, shared by the other two)", + "The 43 tests were re-split to mirror the source: test_usage_report_render.py 573 -> 146, plus new tables (216), host (150), gpu_table (56) and format (40) files", + "Non-goal: any behaviour change. usage_report.py's import line is unchanged because both symbols it names stayed in the render module." + ], + "changes": [ + "Moved _cpu_table, _dedupe_ram, _ram_table and _gpu_table into _usage_report_tables.py, along with the _RAM_BUCKET_MIB and _MAX_SIBLINGS_SHOWN constants they own.", + "Moved _md_escape and _fmt_h into _usage_report_format.py. Both the tables module and the render module need them, so a third module avoids a circular import between the two.", + "Import sites name the owning module directly (from _usage_report_tables import ...); no re-export barrel was added to _usage_report_render.", + "usage_report.py needed no edit at all: it imports _fmt_h and _render_report from _usage_report_render, and _render_report stayed there while _fmt_h is re-imported into that module from the format module under its original name.", + "Split the test files along the same seam so none exceeds the cap, and so each test module sits next to the source module it exercises." + ], + "verification": [ + { + "command": "python3 scratchpad/verify_split.py - AST comparison of every moved function against HEAD", + "result": "pass", + "evidence": "All 10 functions (_cpu_table, _dedupe_ram, _ram_table, _gpu_table, _md_escape, _fmt_h, _host_profile, _fingerprint_section, _methodology_section, _render_report) report IDENTICAL: 'functions compared: 10, mismatches: 0'. The comparison normalises through ast.parse(ast.unparse(node)), so it is insensitive to formatting but catches any change in logic. This is the direct evidence that the code moved verbatim." + }, + { + "command": "pytest linux_configuration/tests/ --cov of the three modules --cov-branch", + "result": "pass", + "evidence": "_usage_report_format.py 15 stmts / 4 branches 100%; _usage_report_render.py 55 / 10 100%; _usage_report_tables.py 54 / 14 100%. Zero missed statements and zero partial branches in all three, matching the single-file 100% established in the previous commit." + }, + { + "command": "pytest --collect-only per test file, summed", + "result": "pass", + "evidence": "format 10 + gpu_table 5 + host 6 + render 6 + tables 16 = 43, exactly the pre-split count. This is the discriminating check for a test-file split: a new file whose name fell outside the runner's glob would be silently uncollected and the suite would still pass green. The whole directory reports 143 passed, unchanged." + }, + { + "command": "python usage_report.py --date --top 5 --no-clipboard --no-update-state --quiet", + "result": "pass", + "evidence": "Generated a real report from this machine's atop and pmon logs (28 atop samples, 9113 pmon samples). Every section rendered through the new module boundaries: host fingerprint with CPU model and GPU, methodology, CPU table, RAM table including the sibling dedupe and its '(+20 more)' truncation, and the GPU table. Running the program is the check that the import rewiring actually resolves at runtime, which no unit test proves." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "41 over-cap files before this commit, 39 after. _usage_report_render.py (310) and the 573-line test file are both off the list; none of the five resulting source/test files exceeds 250." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "Every hook passes, including ruff, ruff format, mypy, pylint, bandit, name-tests-test and the two noqa blockers. grep for 'noqa|type: ignore' across all eight files returns 0." + } + ], + "risks": [ + "Three modules where there was one means three import headers to keep in sync if the seam moves again. The format module is deliberately tiny (24 lines) rather than folded into types, because folding it there would make the tables module import a types module for formatting helpers that have nothing to do with the dataclasses.", + "While rewriting the render module I initially typed a literal 'x' multiplication sign where the original used the \\u00d7 escape. ruff's RUF001 caught it and the escape was restored, so the rendered output is byte-identical - but it is a reminder that retyping a file is riskier than moving it, which is why the AST comparison above exists.", + "A shared test-helper module was tried first and abandoned: name-tests-test rejects any .py under tests/ not named test_*.py, and working around it would have meant a hook exclusion. The two helpers are now defined in the one test file that needs them, with a single inlined ProcAgg construction in another - no duplication worth deduplicating, and no config suppression." + ], + "rollback": [ + "git revert the commit. _usage_report_render.py returns to 310 lines with all ten functions, the two new source modules and four new test files disappear, and test_usage_report_render.py returns to its 573-line single-file form.", + "After rollback, confirm pytest linux_configuration/tests still reports 143 passed and that _usage_report_render.py is back at 100% coverage as a single module. usage_report.py needs no attention either way, since this commit never touched it." + ] +} diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_format.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_format.py new file mode 100644 index 00000000..87698a4c --- /dev/null +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_format.py @@ -0,0 +1,24 @@ +"""Markdown escaping and duration formatting shared by the report sections.""" + +from __future__ import annotations + +_SEC_PER_HOUR = 3600 +_SEC_PER_MIN = 60 + + +def _md_escape(name: str) -> str: + """Escape characters that would break a Markdown table cell.""" + return name.replace("|", r"\|").replace("\n", " ") + + +def _fmt_h(seconds: float) -> str: + """Human-friendly duration: `"1h 23m"` / `"4m 12s"` / `"8.3s"`.""" + if seconds >= _SEC_PER_HOUR: + h = int(seconds // _SEC_PER_HOUR) + m = int((seconds % _SEC_PER_HOUR) // _SEC_PER_MIN) + return f"{h}h {m:02d}m" + if seconds >= _SEC_PER_MIN: + m = int(seconds // _SEC_PER_MIN) + s = int(seconds % _SEC_PER_MIN) + return f"{m}m {s:02d}s" + return f"{seconds:.1f}s" diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py index 29838b03..03bc6373 100644 --- a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py @@ -1,8 +1,7 @@ -"""Markdown report rendering helpers for usage_report.""" +"""Markdown report assembly for usage_report (host, methodology, full report).""" from __future__ import annotations -from collections import defaultdict import datetime as _dt import os from pathlib import Path @@ -10,19 +9,15 @@ import platform import re from typing import TYPE_CHECKING +from _usage_report_format import _fmt_h from _usage_report_parsing import _run -from _usage_report_types import _HZ, _PMON_INTERVAL_S, GpuAgg, ProcAgg, _Window +from _usage_report_tables import _cpu_table, _gpu_table, _ram_table +from _usage_report_types import _HZ, _PMON_INTERVAL_S, _Window if TYPE_CHECKING: - from collections.abc import Iterable - from usage_report import _Aggregates -_SEC_PER_HOUR = 3600 -_SEC_PER_MIN = 60 _PAGE_KB = os.sysconf("SC_PAGESIZE") // 1024 if hasattr(os, "sysconf") else 4 -_RAM_BUCKET_MIB = 1 # dedupe rows whose peak RSS rounds to the same MiB -_MAX_SIBLINGS_SHOWN = 6 def _host_profile() -> dict[str, str]: @@ -61,130 +56,6 @@ def _host_profile() -> dict[str, str]: return info -def _md_escape(name: str) -> str: - """Escape characters that would break a Markdown table cell.""" - return name.replace("|", r"\|").replace("\n", " ") - - -def _fmt_h(seconds: float) -> str: - """Human-friendly duration: `"1h 23m"` / `"4m 12s"` / `"8.3s"`.""" - if seconds >= _SEC_PER_HOUR: - h = int(seconds // _SEC_PER_HOUR) - m = int((seconds % _SEC_PER_HOUR) // _SEC_PER_MIN) - return f"{h}h {m:02d}m" - if seconds >= _SEC_PER_MIN: - m = int(seconds // _SEC_PER_MIN) - s = int(seconds % _SEC_PER_MIN) - return f"{m}m {s:02d}s" - return f"{seconds:.1f}s" - - -def _cpu_table(aggs: Iterable[ProcAgg], window_s: int, top: int) -> list[str]: - ncpu = os.cpu_count() or 1 - header = ( - "| # | Program | CPU-seconds | Avg CPU% (of 1 core) |" - " Avg CPU% (of box) | Peak RSS | PIDs |" - ) - sep = ( - "|---|---------|------------:|---------------------:|" - "------------------:|---------:|-----:|" - ) - rows: list[str] = [header, sep] - top_items = sorted(aggs, key=lambda a: a.cpu_ticks, reverse=True)[:top] - for idx, item in enumerate(top_items, start=1): - single = (item.cpu_seconds / window_s * 100) if window_s else 0.0 - box = single / ncpu - rows.append( - "| " - f"{idx} | {_md_escape(item.name)} | " - f"{item.cpu_seconds:,.0f}s ({_fmt_h(item.cpu_seconds)}) | " - f"{single:.1f}% | {box:.1f}% | " - f"{item.peak_rss_mb:,.0f} MiB | {item.pid_count} |", - ) - return rows - - -def _dedupe_ram(aggs: Iterable[ProcAgg]) -> list[tuple[ProcAgg, list[str]]]: - """Group rows by peak-RSS bucket; keep the top-CPU row per bucket. - - Returns a list of `(representative, sibling_names)` ordered by peak RSS - descending. Siblings are the other names that shared the same RSS bucket - (likely threads of the same parent process). - """ - buckets: dict[int, list[ProcAgg]] = defaultdict(list) - for item in aggs: - if item.peak_rss_kb <= 0: - continue - key = round(item.peak_rss_kb / 1024 / _RAM_BUCKET_MIB) - buckets[key].append(item) - result: list[tuple[ProcAgg, list[str]]] = [] - for bucket in buckets.values(): - bucket.sort(key=lambda a: (a.cpu_ticks, a.pid_count), reverse=True) - rep = bucket[0] - siblings = [b.name for b in bucket[1:]] - result.append((rep, siblings)) - result.sort(key=lambda t: t[0].peak_rss_kb, reverse=True) - return result - - -def _ram_table(aggs: Iterable[ProcAgg], top: int) -> list[str]: - header = ( - "| # | Program | Peak RSS | Avg RSS | CPU-seconds | PIDs |" - " Sibling names (shared RSS) |" - ) - sep = ( - "|---|---------|---------:|--------:|------------:|-----:|" - "----------------------------|" - ) - rows: list[str] = [header, sep] - for idx, (item, siblings) in enumerate(_dedupe_ram(aggs)[:top], start=1): - if not siblings: - sib = "—" - else: - shown = ", ".join(_md_escape(s) for s in siblings[:_MAX_SIBLINGS_SHOWN]) - extra = ( - f" (+{len(siblings) - _MAX_SIBLINGS_SHOWN} more)" - if len(siblings) > _MAX_SIBLINGS_SHOWN - else "" - ) - sib = f"{shown}{extra}" - rows.append( - "| " - f"{idx} | {_md_escape(item.name)} | " - f"{item.peak_rss_mb:,.0f} MiB | " - f"{item.avg_rss_mb:,.0f} MiB | " - f"{item.cpu_seconds:,.0f}s | " - f"{item.pid_count} | {sib} |", - ) - return rows - - -def _gpu_table(aggs: dict[str, GpuAgg], total_samples: int, top: int) -> list[str]: - header = ( - "| # | Program | GPU SM-seconds | Avg SM% (when present) |" - " Peak SM% | Peak MEM% | Samples | PIDs |" - ) - sep = ( - "|---|---------|---------------:|-----------------------:|" - "---------:|----------:|--------:|-----:|" - ) - rows: list[str] = [header, sep] - top_items = sorted(aggs.values(), key=lambda a: a.gpu_seconds, reverse=True)[:top] - for idx, item in enumerate(top_items, start=1): - presence = (item.samples / total_samples * 100) if total_samples else 0.0 - rows.append( - "| " - f"{idx} | {_md_escape(item.name)} | " - f"{item.gpu_seconds:,.0f}s ({_fmt_h(item.gpu_seconds)}) | " - f"{item.avg_sm_pct:.1f}% | " - f"{item.peak_sm_pct:.0f}% | " - f"{item.peak_mem_pct:.0f}% | " - f"{item.samples} ({presence:.0f}%) | " - f"{item.pid_count} |", - ) - return rows - - def _fingerprint_section() -> list[str]: info = _host_profile() return [ diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_tables.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_tables.py new file mode 100644 index 00000000..bb6c0e44 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_tables.py @@ -0,0 +1,123 @@ +"""Markdown table builders for usage_report (CPU, RAM and GPU sections).""" + +from __future__ import annotations + +from collections import defaultdict +import os +from typing import TYPE_CHECKING + +from _usage_report_format import _fmt_h, _md_escape + +if TYPE_CHECKING: + from collections.abc import Iterable + + from _usage_report_types import GpuAgg, ProcAgg + +_RAM_BUCKET_MIB = 1 # dedupe rows whose peak RSS rounds to the same MiB +_MAX_SIBLINGS_SHOWN = 6 + + +def _cpu_table(aggs: Iterable[ProcAgg], window_s: int, top: int) -> list[str]: + ncpu = os.cpu_count() or 1 + header = ( + "| # | Program | CPU-seconds | Avg CPU% (of 1 core) |" + " Avg CPU% (of box) | Peak RSS | PIDs |" + ) + sep = ( + "|---|---------|------------:|---------------------:|" + "------------------:|---------:|-----:|" + ) + rows: list[str] = [header, sep] + top_items = sorted(aggs, key=lambda a: a.cpu_ticks, reverse=True)[:top] + for idx, item in enumerate(top_items, start=1): + single = (item.cpu_seconds / window_s * 100) if window_s else 0.0 + box = single / ncpu + rows.append( + "| " + f"{idx} | {_md_escape(item.name)} | " + f"{item.cpu_seconds:,.0f}s ({_fmt_h(item.cpu_seconds)}) | " + f"{single:.1f}% | {box:.1f}% | " + f"{item.peak_rss_mb:,.0f} MiB | {item.pid_count} |", + ) + return rows + + +def _dedupe_ram(aggs: Iterable[ProcAgg]) -> list[tuple[ProcAgg, list[str]]]: + """Group rows by peak-RSS bucket; keep the top-CPU row per bucket. + + Returns a list of `(representative, sibling_names)` ordered by peak RSS + descending. Siblings are the other names that shared the same RSS bucket + (likely threads of the same parent process). + """ + buckets: dict[int, list[ProcAgg]] = defaultdict(list) + for item in aggs: + if item.peak_rss_kb <= 0: + continue + key = round(item.peak_rss_kb / 1024 / _RAM_BUCKET_MIB) + buckets[key].append(item) + result: list[tuple[ProcAgg, list[str]]] = [] + for bucket in buckets.values(): + bucket.sort(key=lambda a: (a.cpu_ticks, a.pid_count), reverse=True) + rep = bucket[0] + siblings = [b.name for b in bucket[1:]] + result.append((rep, siblings)) + result.sort(key=lambda t: t[0].peak_rss_kb, reverse=True) + return result + + +def _ram_table(aggs: Iterable[ProcAgg], top: int) -> list[str]: + header = ( + "| # | Program | Peak RSS | Avg RSS | CPU-seconds | PIDs |" + " Sibling names (shared RSS) |" + ) + sep = ( + "|---|---------|---------:|--------:|------------:|-----:|" + "----------------------------|" + ) + rows: list[str] = [header, sep] + for idx, (item, siblings) in enumerate(_dedupe_ram(aggs)[:top], start=1): + if not siblings: + sib = "—" + else: + shown = ", ".join(_md_escape(s) for s in siblings[:_MAX_SIBLINGS_SHOWN]) + extra = ( + f" (+{len(siblings) - _MAX_SIBLINGS_SHOWN} more)" + if len(siblings) > _MAX_SIBLINGS_SHOWN + else "" + ) + sib = f"{shown}{extra}" + rows.append( + "| " + f"{idx} | {_md_escape(item.name)} | " + f"{item.peak_rss_mb:,.0f} MiB | " + f"{item.avg_rss_mb:,.0f} MiB | " + f"{item.cpu_seconds:,.0f}s | " + f"{item.pid_count} | {sib} |", + ) + return rows + + +def _gpu_table(aggs: dict[str, GpuAgg], total_samples: int, top: int) -> list[str]: + header = ( + "| # | Program | GPU SM-seconds | Avg SM% (when present) |" + " Peak SM% | Peak MEM% | Samples | PIDs |" + ) + sep = ( + "|---|---------|---------------:|-----------------------:|" + "---------:|----------:|--------:|-----:|" + ) + rows: list[str] = [header, sep] + top_items = sorted(aggs.values(), key=lambda a: a.gpu_seconds, reverse=True)[:top] + for idx, item in enumerate(top_items, start=1): + presence = (item.samples / total_samples * 100) if total_samples else 0.0 + rows.append( + "| " + f"{idx} | {_md_escape(item.name)} | " + f"{item.gpu_seconds:,.0f}s ({_fmt_h(item.gpu_seconds)}) | " + f"{item.avg_sm_pct:.1f}% | " + f"{item.peak_sm_pct:.0f}% | " + f"{item.peak_mem_pct:.0f}% | " + f"{item.samples} ({presence:.0f}%) | " + f"{item.pid_count} |", + ) + return rows diff --git a/linux_configuration/tests/test_usage_report_format.py b/linux_configuration/tests/test_usage_report_format.py new file mode 100644 index 00000000..53a0af28 --- /dev/null +++ b/linux_configuration/tests/test_usage_report_format.py @@ -0,0 +1,40 @@ +"""Tests for usage_report's Markdown escaping and duration formatting.""" + +from __future__ import annotations + +import _usage_report_format as fmt +import pytest + + +# --------------------------------------------------------------------------- # +# _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 fmt._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 fmt._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 fmt._md_escape("firefox") == "firefox" diff --git a/linux_configuration/tests/test_usage_report_gpu_table.py b/linux_configuration/tests/test_usage_report_gpu_table.py new file mode 100644 index 00000000..3cbdb87c --- /dev/null +++ b/linux_configuration/tests/test_usage_report_gpu_table.py @@ -0,0 +1,56 @@ +"""Tests for usage_report's GPU Markdown table.""" + +from __future__ import annotations + +import _usage_report_tables as tables +from _usage_report_types import GpuAgg + + +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("|")] + + +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 = tables._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(tables._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(tables._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 tables._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(tables._gpu_table({}, total_samples=0, top=5)) == 2 diff --git a/linux_configuration/tests/test_usage_report_host.py b/linux_configuration/tests/test_usage_report_host.py new file mode 100644 index 00000000..32c27f49 --- /dev/null +++ b/linux_configuration/tests/test_usage_report_host.py @@ -0,0 +1,150 @@ +"""Tests for usage_report's host fingerprint: the /proc and nvidia-smi probes +behind _host_profile, and the Markdown bullets _fingerprint_section builds. +""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING + +import _usage_report_render as render + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +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 _stub_platform(monkeypatch: pytest.MonkeyPatch, *, cpus: int | None) -> None: + """Pin hostname, kernel and CPU count so assertions are machine-agnostic.""" + monkeypatch.setattr(render.platform, "node", lambda: "box") + monkeypatch.setattr(render.platform, "release", lambda: "6.1.0") + monkeypatch.setattr(render.os, "cpu_count", lambda: cpus) + + +# --------------------------------------------------------------------------- # +# _host_profile +# --------------------------------------------------------------------------- # +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.""" + _stub_platform(monkeypatch, cpus=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.""" + _stub_platform(monkeypatch, cpus=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.""" + _stub_platform(monkeypatch, cpus=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.""" + _stub_platform(monkeypatch, cpus=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'.""" + _stub_platform(monkeypatch, cpus=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", + "", + ] diff --git a/linux_configuration/tests/test_usage_report_render.py b/linux_configuration/tests/test_usage_report_render.py index 092a74df..5ded4c6d 100644 --- a/linux_configuration/tests/test_usage_report_render.py +++ b/linux_configuration/tests/test_usage_report_render.py @@ -1,448 +1,17 @@ -"""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. +"""Tests for usage_report report assembly: the methodology section and the +full Markdown report that stitches every section together. """ 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_types import GpuAgg, ProcAgg, _Window 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", - "", - ] + import pytest # --------------------------------------------------------------------------- # @@ -504,10 +73,14 @@ def test_render_report_includes_every_section(monkeypatch: pytest.MonkeyPatch) - """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)}, + 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 + start="A", + end="B", + distinct_samples=7, + interval_s=10, + seconds=60, ), gpu_samples=5, ) diff --git a/linux_configuration/tests/test_usage_report_tables.py b/linux_configuration/tests/test_usage_report_tables.py new file mode 100644 index 00000000..d51fae1f --- /dev/null +++ b/linux_configuration/tests/test_usage_report_tables.py @@ -0,0 +1,216 @@ +"""Tests for usage_report's CPU and RAM Markdown tables, including the +peak-RSS bucket dedupe that collapses thread rows onto their parent. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import _usage_report_tables as tables +from _usage_report_types import _HZ, ProcAgg + +if TYPE_CHECKING: + import pytest + + +def _proc( + name: str, + *, + cpu_ticks: int = 0, + peak_rss_kb: int = 0, + pid_set: set[int] | None = None, +) -> ProcAgg: + """Build a ProcAgg with only the fields these table tests care about.""" + return ProcAgg( + name, + cpu_ticks=cpu_ticks, + peak_rss_kb=peak_rss_kb, + 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("|")] + + +# --------------------------------------------------------------------------- # +# _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(tables.os, "cpu_count", lambda: 4) + aggs = [ + _proc("low", cpu_ticks=100), + _proc("high", cpu_ticks=900), + _proc("mid", cpu_ticks=500), + ] + + rows = tables._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(tables.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 = tables._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(tables.os, "cpu_count", lambda: 2) + rows = tables._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(tables.os, "cpu_count", lambda: None) + rows = tables._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(tables.os, "cpu_count", lambda: 1) + rows = tables._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(tables.os, "cpu_count", lambda: 1) + assert len(tables._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 = tables._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 tables._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 = tables._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 = tables._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 = tables._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(tables._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 = tables._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(tables._ram_table(aggs, top=5)[2])[-1] + + assert sib.endswith(f"(+{over - tables._MAX_SIBLINGS_SHOWN} more)") + # Exactly _MAX_SIBLINGS_SHOWN names are listed, joined by ", ". + assert sib.count(",") == tables._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 = tables._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(tables._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(tables._ram_table([], top=5)) == 2