diff --git a/docs/superpowers/evidence/usage-report-test-split-2026-08-17.json b/docs/superpowers/evidence/usage-report-test-split-2026-08-17.json new file mode 100644 index 00000000..87e0bd20 --- /dev/null +++ b/docs/superpowers/evidence/usage-report-test-split-2026-08-17.json @@ -0,0 +1,51 @@ +{ + "intent": "Bring linux_configuration/tests/test_usage_report_since.py (480 lines) under the repo-wide 250-line cap without losing a single test.", + "scope": [ + "linux_configuration/tests/test_usage_report_since.py — reduced to pmon timestamp filtering, atop command bounding, the persisted last-report state, and day-segment planning", + "linux_configuration/tests/test_usage_report_merge.py — new: aggregate merging, window merging, PID-count carry-over", + "linux_configuration/tests/test_usage_report_modes.py — new: start resolution, run-mode dispatch, report fragments, PRC HZ-field regression, native-helper selection", + "Non-goal: adding, deleting or weakening any assertion; all thirty are carried across verbatim", + "Non-goal: splitting the usage_report *source* files, which this session's coverage measurement rules out (see risks)" + ], + "changes": [ + "Split the thirty tests three ways along the file's own section banners, which already grouped them by concern. The result is one file per theme rather than an arbitrary cut at a line number.", + "Rebuilt each file's import block from what that file actually uses, rather than copying the original's superset. The _at helper and its _LOCAL_TZ constant are duplicated into the two files that construct datetimes, since a shared conftest fixture would have been a larger change than the split itself.", + "Ruff auto-fixed one TC002 (typing-only-third-party-import) in the modes file during the gate run; that fix is included." + ], + "verification": [ + { + "command": "pytest on all three files", + "result": "pass", + "evidence": "30 passed. 6 + 13 + 11 = 30, exactly the pre-split count. That sum is the discriminating check for a test split: a dropped test, or a file that failed to import and silently collected nothing, would lower the total while the run still reported success." + }, + { + "command": "Mutation check: a copy of test_usage_report_modes.py with one assertion negated", + "result": "pass", + "evidence": "1 failed, 10 passed — the negated assertion fails as it must. This proves the split files are not vacuously green and that pytest is genuinely collecting and executing the moved bodies." + }, + { + "command": "pre-commit run --files on all three files", + "result": "pass", + "evidence": "Every hook passes, including ruff with select = [\"ALL\"], mypy and pylint. Zero suppressions added — the one finding ruff raised was auto-fixed, not silenced." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "test_usage_report_since.py 186, test_usage_report_merge.py 145, test_usage_report_modes.py 178 — none appears in the over-cap listing, down from a single 480-line file." + }, + { + "command": "Coverage measurement of the usage_report source files, run before choosing this target", + "result": "fail", + "evidence": "pytest --cov over the three usage_report test files reports _usage_report_render.py 21%, usage_report.py 47%, _usage_report_parsing.py 39%. Those source files are therefore NOT split in this commit: at that coverage a split would relocate code no test executes and the suite would pass either way. Only the test file itself, which is self-verifying by test count, was in scope." + } + ], + "risks": [ + "The _at helper and _LOCAL_TZ are now duplicated in two files. A conftest.py fixture would avoid that, but conftest changes affect every test in the directory and would have widened this commit well beyond a file split.", + "These tests import the usage_report modules by bare name (import usage_report), which works because of the existing conftest path setup in linux_configuration/tests. All three files inherit it identically; none adds a new path manipulation.", + "The over-cap usage_report SOURCE files remain over-cap and are now the only usage_report entries in the listing. They stay that way until their coverage improves enough for a split to be verifiable." + ], + "rollback": [ + "git revert the commit — one edited file and two added files, with no data, config or schema migration.", + "After rollback, re-run pytest linux_configuration/tests/test_usage_report_since.py; expect 30 passed from the single file." + ] +} diff --git a/linux_configuration/tests/test_usage_report_merge.py b/linux_configuration/tests/test_usage_report_merge.py new file mode 100644 index 00000000..4bf7a7b4 --- /dev/null +++ b/linux_configuration/tests/test_usage_report_merge.py @@ -0,0 +1,146 @@ +"""Tests for merging usage_report daily aggregates: process and GPU aggregate +merging, window merging, and PID-count carry-over across days. +""" + +from __future__ import annotations + +import _usage_report_parsing as parsing +from _usage_report_types import GpuAgg, ProcAgg, _Window + + +# --------------------------------------------------------------------------- # +# PID-count carry-over (types) +# --------------------------------------------------------------------------- # +def test_proc_pid_count_combines_set_and_extra() -> None: + """`pid_count` adds the live set length and merged-in extras.""" + agg = ProcAgg("x", pid_set={1, 2, 3}, extra_pids=2) + + assert agg.pid_count == 5 + + +def test_gpu_pid_count_combines_set_and_extra() -> None: + """GpuAgg exposes the same combined PID count.""" + agg = GpuAgg("x", pid_set={9}, extra_pids=4) + + assert agg.pid_count == 5 + + +# --------------------------------------------------------------------------- # +# Aggregate merging (parsing) +# --------------------------------------------------------------------------- # +def test_merge_proc_aggs_sums_and_takes_peak() -> None: + """CPU/RSS sums accumulate, peak RSS is the max, PID counts add.""" + dst: dict[str, ProcAgg] = {} + parsing.merge_proc_aggs( + dst, + { + "a": ProcAgg( + "a", + cpu_ticks=100, + peak_rss_kb=200, + rss_kb_sum=50, + rss_samples=2, + pid_set={1, 2}, + ) + }, + ) + parsing.merge_proc_aggs( + dst, + { + "a": ProcAgg( + "a", + cpu_ticks=10, + peak_rss_kb=500, + rss_kb_sum=5, + rss_samples=1, + pid_set={3}, + ) + }, + ) + + entry = dst["a"] + assert entry.cpu_ticks == 110 + assert entry.peak_rss_kb == 500 + assert entry.rss_kb_sum == 55 + assert entry.rss_samples == 3 + assert entry.pid_count == 3 + + +def test_merge_gpu_aggs_sums_and_takes_peak() -> None: + """GPU sample sums accumulate and peaks take the max across days.""" + dst: dict[str, GpuAgg] = {} + parsing.merge_gpu_aggs( + dst, + { + "g": GpuAgg( + "g", + sm_pct_sum=30.0, + mem_pct_sum=10.0, + samples=3, + peak_sm_pct=40.0, + peak_mem_pct=20.0, + pid_set={1}, + ) + }, + ) + parsing.merge_gpu_aggs( + dst, + { + "g": GpuAgg( + "g", + sm_pct_sum=5.0, + mem_pct_sum=2.0, + samples=1, + peak_sm_pct=80.0, + peak_mem_pct=15.0, + pid_set={2, 3}, + ) + }, + ) + + entry = dst["g"] + assert entry.sm_pct_sum == 35.0 + assert entry.samples == 4 + assert entry.peak_sm_pct == 80.0 + assert entry.peak_mem_pct == 20.0 + assert entry.pid_count == 3 + + +# --------------------------------------------------------------------------- # +# Window merging (parsing) +# --------------------------------------------------------------------------- # +def test_merge_windows_empty_returns_default() -> None: + """Merging no real windows yields the empty default window.""" + assert parsing.merge_windows([]).distinct_samples == 0 + assert parsing.merge_windows([_Window()]).distinct_samples == 0 + + +def test_merge_windows_spans_and_sums() -> None: + """Span uses min start / max end; samples and seconds sum; interval is modal.""" + w_empty = _Window() # distinct_samples == 0, must be ignored + w1 = _Window( + start="s1", + end="e1", + distinct_samples=5, + interval_s=600, + seconds=100, + start_epoch=1000, + end_epoch=2000, + ) + w2 = _Window( + start="s2", + end="e2", + distinct_samples=3, + interval_s=600, + seconds=50, + start_epoch=500, + end_epoch=3000, + ) + + merged = parsing.merge_windows([w_empty, w1, w2]) + + assert merged.start == "s2" # earliest start_epoch (500) + assert merged.end == "e2" # latest end_epoch (3000) + assert merged.distinct_samples == 8 + assert merged.seconds == 150 + assert merged.interval_s == 600 diff --git a/linux_configuration/tests/test_usage_report_modes.py b/linux_configuration/tests/test_usage_report_modes.py new file mode 100644 index 00000000..54577b9c --- /dev/null +++ b/linux_configuration/tests/test_usage_report_modes.py @@ -0,0 +1,180 @@ +"""Tests for usage_report run modes and field parsing: start resolution, +single-day vs multi-day dispatch, the report period fragments, the PRC +HZ-field regression, and native-helper selection. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +from pathlib import Path +from typing import TYPE_CHECKING + +import _usage_report_parsing as parsing +import usage_report + +if TYPE_CHECKING: + from _usage_report_types import _PidCpu + import pytest + + +# Aware timezone matching how the parser localizes naive timestamps, so epochs +# computed here line up with `_pmon_row_epoch`'s `.astimezone()` conversion. +_LOCAL_TZ = _dt.datetime.now().astimezone().tzinfo + + +def _at( + year: int, month: int, day: int, hour: int = 0, minute: int = 0 +) -> _dt.datetime: + """Build an aware local datetime for tests.""" + return _dt.datetime(year, month, day, hour, minute, tzinfo=_LOCAL_TZ) + + +# --------------------------------------------------------------------------- # +# Start resolution and mode dispatch (usage_report) +# --------------------------------------------------------------------------- # +def _args(**overrides: object) -> argparse.Namespace: + """Build a Namespace with the usage_report CLI defaults.""" + base: dict[str, object] = { + "date": None, + "since": None, + "atop_log": None, + "pmon_log": None, + } + base.update(overrides) + return argparse.Namespace(**base) + + +def test_resolve_start_prefers_since(monkeypatch: pytest.MonkeyPatch) -> None: + """--since wins over any saved state and starts at local midnight.""" + monkeypatch.setattr(usage_report, "_read_last_generated", lambda: _at(2026, 1, 1)) + start = usage_report._resolve_start(_args(since="20260604"), _at(2026, 6, 4, 12)) + + assert start.date() == _dt.date(2026, 6, 4) + assert (start.hour, start.minute) == (0, 0) + + +def test_resolve_start_uses_last_report(monkeypatch: pytest.MonkeyPatch) -> None: + """Without --since, the saved last-report timestamp is the start.""" + last = _at(2026, 6, 2, 9, 0) + monkeypatch.setattr(usage_report, "_read_last_generated", lambda: last) + + assert usage_report._resolve_start(_args(), _at(2026, 6, 4, 12)) == last + + +def test_resolve_start_first_run_is_today_midnight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """First-ever run (no state) covers today from local midnight.""" + monkeypatch.setattr(usage_report, "_read_last_generated", lambda: None) + now = _at(2026, 6, 4, 12, 30) + + assert usage_report._resolve_start(_args(), now) == _at(2026, 6, 4, 0, 0) + + +def test_is_single_day_mode() -> None: + """Pinning a date or explicit log path selects single-day mode.""" + assert usage_report._is_single_day_mode(_args(date="20260604")) is True + assert usage_report._is_single_day_mode(_args(atop_log=Path("/x"))) is True + assert usage_report._is_single_day_mode(_args(pmon_log=Path("/x"))) is True + assert usage_report._is_single_day_mode(_args()) is False + + +def test_should_advance_state_only_for_default_run() -> None: + """Only a plain since-last-report run re-baselines the saved timestamp.""" + assert usage_report._should_advance_state(_args(no_update_state=False)) is True + assert usage_report._should_advance_state(_args(no_update_state=True)) is False + # --since is an ad-hoc query and must never advance state. + assert ( + usage_report._should_advance_state( + _args(since="20260510", no_update_state=False), + ) + is False + ) + + +# --------------------------------------------------------------------------- # +# Report fragments (usage_report) +# --------------------------------------------------------------------------- # +def test_period_line_contains_both_bounds() -> None: + """The period bullet shows start, end, and the span.""" + line = usage_report._period_line(_at(2026, 6, 2, 9), _at(2026, 6, 4, 9)) + + assert "2026-06-02T09:00:00" in line + assert "2026-06-04T09:00:00" in line + assert "→" in line + + +def test_describe_logs_counts() -> None: + """Log description switches between none / single / multiple wording.""" + assert "none found" in usage_report._describe_logs([], "atop -r") + assert usage_report._describe_logs( + [Path("/var/log/atop/atop_20260604")], "atop -r" + ).startswith( + "`/var/log/atop/atop_20260604`", + ) + many = usage_report._describe_logs( + [Path("/v/atop_20260601"), Path("/v/atop_20260604")], + "atop -r", + ) + assert "2 daily logs" in many + + +# --------------------------------------------------------------------------- # +# PRC field parsing — HZ-field regression (parsing) +# --------------------------------------------------------------------------- # +def test_parse_prc_does_not_charge_hz_as_cpu() -> None: + """atop emits `... pid (name) state HZ utime stime`; the HZ column must be + skipped, never summed as CPU. + + Regression for the off-by-one that read HZ (100) as utime, which inflated + every process's CPU-seconds to its record/PID count (xset showing 67h). + """ + pid_cpu: dict[int, _PidCpu] = {} + # 6 generic fields, pid, (name), state, HZ=100, utime=7, stime=3, + tail. + line = "PRC host 1000 2026/06/04 12:00:00 600 4242 (xset) E 100 7 3 0 0 0" + + parsing._parse_prc(line.split(), pid_cpu) + + entry = pid_cpu[4242] + assert entry.name == "xset" + assert entry.delta_ticks == 10 # utime+stime, never the HZ constant (100) + + +def test_parse_prc_skips_hz_with_multiword_name() -> None: + """The HZ skip stays aligned when the name spans several tokens.""" + pid_cpu: dict[int, _PidCpu] = {} + line = "PRC h 1000 d t 600 99 (Web Content) S 100 40 2 0 0" + + parsing._parse_prc(line.split(), pid_cpu) + + assert pid_cpu[99].name == "Web Content" + assert pid_cpu[99].delta_ticks == 42 # 40+2, HZ(100) skipped + + +def test_parse_prc_too_short_is_ignored() -> None: + """A truncated PRC record (missing stime) is skipped, not a crash.""" + pid_cpu: dict[int, _PidCpu] = {} + # Tokens run out at utime — no stime at after+3, so the record is dropped. + line = "PRC h 1000 d t 600 7 (x) S 100 5" + + parsing._parse_prc(line.split(), pid_cpu) + + assert pid_cpu == {} + + +# --------------------------------------------------------------------------- # +# Native helper selection (parsing) +# --------------------------------------------------------------------------- # +def test_atop_agg_binary_missing_source_falls_back( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A deleted C source tree yields None (Python fallback) even when a cached + binary exists — never trust an orphaned, unverifiable build.""" + monkeypatch.setattr(parsing, "_ATOP_AGG_SRC_DIR", tmp_path / "gone") + cache = tmp_path / "atop_agg" + cache.write_text("stale binary", encoding="utf-8") + monkeypatch.setattr(parsing, "_ATOP_AGG_CACHE_BIN", cache) + + assert parsing._atop_agg_binary() is None diff --git a/linux_configuration/tests/test_usage_report_since.py b/linux_configuration/tests/test_usage_report_since.py index dbb7c140..59fee850 100644 --- a/linux_configuration/tests/test_usage_report_since.py +++ b/linux_configuration/tests/test_usage_report_since.py @@ -1,28 +1,25 @@ -"""Tests for the "since last report" multi-day aggregation in usage_report. - -Covers the helpers added to span and merge several daily logs: aggregate -merging, window merging, PID-count carry-over, pmon timestamp filtering, -atop command bounding, the persisted last-report state, day-segment planning, -and the run-mode dispatch logic. +"""Tests for spanning several days in usage_report: pmon timestamp filtering, +atop command bounding, the persisted last-report state, and day-segment +planning. """ from __future__ import annotations -import argparse import datetime as _dt from pathlib import Path from typing import TYPE_CHECKING import _usage_report_parsing as parsing import _usage_report_pmon as pmon -from _usage_report_types import GpuAgg, ProcAgg, _PidCpu, _Progress, _Window +from _usage_report_types import _Progress import usage_report if TYPE_CHECKING: import pytest -# Aware timezone matching how the parser localizes naive timestamps, so epochs -# computed here line up with `_pmon_row_epoch`'s `.astimezone()` conversion. + +# Aware timezone matching how the parser localizes naive timestamps, so +# epochs computed here line up with `_pmon_row_epoch`'s `.astimezone()`. _LOCAL_TZ = _dt.datetime.now().astimezone().tzinfo @@ -33,144 +30,6 @@ def _at( return _dt.datetime(year, month, day, hour, minute, tzinfo=_LOCAL_TZ) -# --------------------------------------------------------------------------- # -# PID-count carry-over (types) -# --------------------------------------------------------------------------- # -def test_proc_pid_count_combines_set_and_extra() -> None: - """`pid_count` adds the live set length and merged-in extras.""" - agg = ProcAgg("x", pid_set={1, 2, 3}, extra_pids=2) - - assert agg.pid_count == 5 - - -def test_gpu_pid_count_combines_set_and_extra() -> None: - """GpuAgg exposes the same combined PID count.""" - agg = GpuAgg("x", pid_set={9}, extra_pids=4) - - assert agg.pid_count == 5 - - -# --------------------------------------------------------------------------- # -# Aggregate merging (parsing) -# --------------------------------------------------------------------------- # -def test_merge_proc_aggs_sums_and_takes_peak() -> None: - """CPU/RSS sums accumulate, peak RSS is the max, PID counts add.""" - dst: dict[str, ProcAgg] = {} - parsing.merge_proc_aggs( - dst, - { - "a": ProcAgg( - "a", - cpu_ticks=100, - peak_rss_kb=200, - rss_kb_sum=50, - rss_samples=2, - pid_set={1, 2}, - ) - }, - ) - parsing.merge_proc_aggs( - dst, - { - "a": ProcAgg( - "a", - cpu_ticks=10, - peak_rss_kb=500, - rss_kb_sum=5, - rss_samples=1, - pid_set={3}, - ) - }, - ) - - entry = dst["a"] - assert entry.cpu_ticks == 110 - assert entry.peak_rss_kb == 500 - assert entry.rss_kb_sum == 55 - assert entry.rss_samples == 3 - assert entry.pid_count == 3 - - -def test_merge_gpu_aggs_sums_and_takes_peak() -> None: - """GPU sample sums accumulate and peaks take the max across days.""" - dst: dict[str, GpuAgg] = {} - parsing.merge_gpu_aggs( - dst, - { - "g": GpuAgg( - "g", - sm_pct_sum=30.0, - mem_pct_sum=10.0, - samples=3, - peak_sm_pct=40.0, - peak_mem_pct=20.0, - pid_set={1}, - ) - }, - ) - parsing.merge_gpu_aggs( - dst, - { - "g": GpuAgg( - "g", - sm_pct_sum=5.0, - mem_pct_sum=2.0, - samples=1, - peak_sm_pct=80.0, - peak_mem_pct=15.0, - pid_set={2, 3}, - ) - }, - ) - - entry = dst["g"] - assert entry.sm_pct_sum == 35.0 - assert entry.samples == 4 - assert entry.peak_sm_pct == 80.0 - assert entry.peak_mem_pct == 20.0 - assert entry.pid_count == 3 - - -# --------------------------------------------------------------------------- # -# Window merging (parsing) -# --------------------------------------------------------------------------- # -def test_merge_windows_empty_returns_default() -> None: - """Merging no real windows yields the empty default window.""" - assert parsing.merge_windows([]).distinct_samples == 0 - assert parsing.merge_windows([_Window()]).distinct_samples == 0 - - -def test_merge_windows_spans_and_sums() -> None: - """Span uses min start / max end; samples and seconds sum; interval is modal.""" - w_empty = _Window() # distinct_samples == 0, must be ignored - w1 = _Window( - start="s1", - end="e1", - distinct_samples=5, - interval_s=600, - seconds=100, - start_epoch=1000, - end_epoch=2000, - ) - w2 = _Window( - start="s2", - end="e2", - distinct_samples=3, - interval_s=600, - seconds=50, - start_epoch=500, - end_epoch=3000, - ) - - merged = parsing.merge_windows([w_empty, w1, w2]) - - assert merged.start == "s2" # earliest start_epoch (500) - assert merged.end == "e2" # latest end_epoch (3000) - assert merged.distinct_samples == 8 - assert merged.seconds == 150 - assert merged.interval_s == 600 - - # --------------------------------------------------------------------------- # # pmon timestamp helpers (parsing) # --------------------------------------------------------------------------- # @@ -328,153 +187,3 @@ def test_plan_segments_bounds_only_first_day() -> None: def test_plan_segments_start_after_end_is_empty() -> None: """A future state file (start past end) yields no segments.""" assert usage_report._plan_segments(_at(2026, 6, 5), _at(2026, 6, 4)) == [] - - -# --------------------------------------------------------------------------- # -# Start resolution and mode dispatch (usage_report) -# --------------------------------------------------------------------------- # -def _args(**overrides: object) -> argparse.Namespace: - """Build a Namespace with the usage_report CLI defaults.""" - base: dict[str, object] = { - "date": None, - "since": None, - "atop_log": None, - "pmon_log": None, - } - base.update(overrides) - return argparse.Namespace(**base) - - -def test_resolve_start_prefers_since(monkeypatch: pytest.MonkeyPatch) -> None: - """--since wins over any saved state and starts at local midnight.""" - monkeypatch.setattr(usage_report, "_read_last_generated", lambda: _at(2026, 1, 1)) - start = usage_report._resolve_start(_args(since="20260604"), _at(2026, 6, 4, 12)) - - assert start.date() == _dt.date(2026, 6, 4) - assert (start.hour, start.minute) == (0, 0) - - -def test_resolve_start_uses_last_report(monkeypatch: pytest.MonkeyPatch) -> None: - """Without --since, the saved last-report timestamp is the start.""" - last = _at(2026, 6, 2, 9, 0) - monkeypatch.setattr(usage_report, "_read_last_generated", lambda: last) - - assert usage_report._resolve_start(_args(), _at(2026, 6, 4, 12)) == last - - -def test_resolve_start_first_run_is_today_midnight( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """First-ever run (no state) covers today from local midnight.""" - monkeypatch.setattr(usage_report, "_read_last_generated", lambda: None) - now = _at(2026, 6, 4, 12, 30) - - assert usage_report._resolve_start(_args(), now) == _at(2026, 6, 4, 0, 0) - - -def test_is_single_day_mode() -> None: - """Pinning a date or explicit log path selects single-day mode.""" - assert usage_report._is_single_day_mode(_args(date="20260604")) is True - assert usage_report._is_single_day_mode(_args(atop_log=Path("/x"))) is True - assert usage_report._is_single_day_mode(_args(pmon_log=Path("/x"))) is True - assert usage_report._is_single_day_mode(_args()) is False - - -def test_should_advance_state_only_for_default_run() -> None: - """Only a plain since-last-report run re-baselines the saved timestamp.""" - assert usage_report._should_advance_state(_args(no_update_state=False)) is True - assert usage_report._should_advance_state(_args(no_update_state=True)) is False - # --since is an ad-hoc query and must never advance state. - assert ( - usage_report._should_advance_state( - _args(since="20260510", no_update_state=False), - ) - is False - ) - - -# --------------------------------------------------------------------------- # -# Report fragments (usage_report) -# --------------------------------------------------------------------------- # -def test_period_line_contains_both_bounds() -> None: - """The period bullet shows start, end, and the span.""" - line = usage_report._period_line(_at(2026, 6, 2, 9), _at(2026, 6, 4, 9)) - - assert "2026-06-02T09:00:00" in line - assert "2026-06-04T09:00:00" in line - assert "→" in line - - -def test_describe_logs_counts() -> None: - """Log description switches between none / single / multiple wording.""" - assert "none found" in usage_report._describe_logs([], "atop -r") - assert usage_report._describe_logs( - [Path("/var/log/atop/atop_20260604")], "atop -r" - ).startswith( - "`/var/log/atop/atop_20260604`", - ) - many = usage_report._describe_logs( - [Path("/v/atop_20260601"), Path("/v/atop_20260604")], - "atop -r", - ) - assert "2 daily logs" in many - - -# --------------------------------------------------------------------------- # -# PRC field parsing — HZ-field regression (parsing) -# --------------------------------------------------------------------------- # -def test_parse_prc_does_not_charge_hz_as_cpu() -> None: - """atop emits `... pid (name) state HZ utime stime`; the HZ column must be - skipped, never summed as CPU. - - Regression for the off-by-one that read HZ (100) as utime, which inflated - every process's CPU-seconds to its record/PID count (xset showing 67h). - """ - pid_cpu: dict[int, _PidCpu] = {} - # 6 generic fields, pid, (name), state, HZ=100, utime=7, stime=3, + tail. - line = "PRC host 1000 2026/06/04 12:00:00 600 4242 (xset) E 100 7 3 0 0 0" - - parsing._parse_prc(line.split(), pid_cpu) - - entry = pid_cpu[4242] - assert entry.name == "xset" - assert entry.delta_ticks == 10 # utime+stime, never the HZ constant (100) - - -def test_parse_prc_skips_hz_with_multiword_name() -> None: - """The HZ skip stays aligned when the name spans several tokens.""" - pid_cpu: dict[int, _PidCpu] = {} - line = "PRC h 1000 d t 600 99 (Web Content) S 100 40 2 0 0" - - parsing._parse_prc(line.split(), pid_cpu) - - assert pid_cpu[99].name == "Web Content" - assert pid_cpu[99].delta_ticks == 42 # 40+2, HZ(100) skipped - - -def test_parse_prc_too_short_is_ignored() -> None: - """A truncated PRC record (missing stime) is skipped, not a crash.""" - pid_cpu: dict[int, _PidCpu] = {} - # Tokens run out at utime — no stime at after+3, so the record is dropped. - line = "PRC h 1000 d t 600 7 (x) S 100 5" - - parsing._parse_prc(line.split(), pid_cpu) - - assert pid_cpu == {} - - -# --------------------------------------------------------------------------- # -# Native helper selection (parsing) -# --------------------------------------------------------------------------- # -def test_atop_agg_binary_missing_source_falls_back( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A deleted C source tree yields None (Python fallback) even when a cached - binary exists — never trust an orphaned, unverifiable build.""" - monkeypatch.setattr(parsing, "_ATOP_AGG_SRC_DIR", tmp_path / "gone") - cache = tmp_path / "atop_agg" - cache.write_text("stale binary", encoding="utf-8") - monkeypatch.setattr(parsing, "_ATOP_AGG_CACHE_BIN", cache) - - assert parsing._atop_agg_binary() is None