test_parser.py 6.9 KB
Newer Older
one's avatar
one committed
1
2
3
4
5
6
7
8
"""Tests for hytop.gpu.parser."""

from __future__ import annotations

import json

import pytest

9
10
11
12
13
from hytop.gpu.parser import (
    parse_hy_smi_output,
    parse_number,
    strip_ansi,
)
one's avatar
one committed
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107

# ---------------------------------------------------------------------------
# Real hy-smi JSON fixture (from actual 8-card Hygon DCU node)
# ---------------------------------------------------------------------------

# Full-flag output (--showtemp --showpower --showhcuclocks --showmemuse --showuse --json)
# Representative cards: card0 (idle) and card7 (100% HCU load)
HY_SMI_FULL = {
    "card0": {
        "Average Graphics Package Power (W)": "157.0",
        "Temperature (Sensor edge) (C)": "31.0",
        "Temperature (Sensor junction) (C)": "34.0",
        "Temperature (Sensor mem) (C)": "28.0",
        "Temperature (Sensor core) (C)": "30.0",
        "HCU use (%)": "0.0",
        "HCU memory use (%)": "89",
        "sclk clock level": "10",
        "sclk clock speed": "1500Mhz",
    },
    "card7": {
        "Average Graphics Package Power (W)": "141.0",
        "Temperature (Sensor edge) (C)": "28.0",
        "Temperature (Sensor junction) (C)": "33.0",
        "Temperature (Sensor mem) (C)": "25.0",
        "Temperature (Sensor core) (C)": "25.0",
        "HCU use (%)": "100.0",
        "HCU memory use (%)": "89",
        "sclk clock level": "10",
        "sclk clock speed": "1500Mhz",
    },
}

# Temp-only output (--showtemp --json): extra sensor keys should be ignored
HY_SMI_TEMP_ONLY = {
    "card0": {
        "Temperature (Sensor edge) (C)": "28.0",
        "Temperature (Sensor junction) (C)": "31.0",
        "Temperature (Sensor mem) (C)": "25.0",
        "Temperature (Sensor core) (C)": "26.0",
    },
}


# ---------------------------------------------------------------------------
# strip_ansi
# ---------------------------------------------------------------------------


class TestStripAnsi:
    def test_plain_text_unchanged(self):
        assert strip_ansi("hello") == "hello"

    def test_color_codes_removed(self):
        assert strip_ansi("\x1b[31mred\x1b[0m") == "red"

    def test_empty_string(self):
        assert strip_ansi("") == ""

    def test_multiple_codes(self):
        assert strip_ansi("\x1b[1m\x1b[4mbold\x1b[0m") == "bold"


# ---------------------------------------------------------------------------
# parse_number
# ---------------------------------------------------------------------------


class TestParseNumber:
    def test_integer_string(self):
        assert parse_number("89") == pytest.approx(89.0)

    def test_float_string(self):
        assert parse_number("157.0") == pytest.approx(157.0)

    def test_value_with_unit_suffix(self):
        # "1500Mhz" — real sclk clock speed format from hy-smi
        assert parse_number("1500Mhz") == pytest.approx(1500.0)

    def test_no_number_raises(self):
        with pytest.raises(ValueError, match="cannot parse"):
            parse_number("N/A")

    def test_negative_number(self):
        assert parse_number("-5.5") == pytest.approx(-5.5)


# ---------------------------------------------------------------------------
# parse_hy_smi_output — with real fixture data
# ---------------------------------------------------------------------------


class TestParseHySmiOutput:
    def test_full_output_card_count(self):
        raw = json.dumps(HY_SMI_FULL)
108
109
        result, reason = parse_hy_smi_output(raw, sample_ts=1.0)
        assert reason is None
one's avatar
one committed
110
111
112
113
        assert set(result.keys()) == {0, 7}

    def test_full_output_card0_metrics(self):
        raw = json.dumps(HY_SMI_FULL)
114
115
        result, reason = parse_hy_smi_output(raw, sample_ts=1.0)
        assert reason is None
one's avatar
one committed
116
117
118
        s = result[0]
        assert s.temp_c == pytest.approx(30.0)
        assert s.avg_pwr_w == pytest.approx(157.0)
one's avatar
one committed
119
        assert s.gpu_pct == pytest.approx(0.0)
one's avatar
one committed
120
121
122
123
124
        assert s.vram_pct == pytest.approx(89.0)  # integer string "89" → 89.0
        assert s.sclk_mhz == pytest.approx(1500.0)  # "1500Mhz" → 1500.0

    def test_full_output_card7_hcu_load(self):
        raw = json.dumps(HY_SMI_FULL)
125
126
        result, reason = parse_hy_smi_output(raw, sample_ts=1.0)
        assert reason is None
one's avatar
one committed
127
        assert result[7].gpu_pct == pytest.approx(100.0)
one's avatar
one committed
128
129
130

    def test_temp_only_output(self):
        raw = json.dumps(HY_SMI_TEMP_ONLY)
131
132
        result, reason = parse_hy_smi_output(raw, sample_ts=1.0)
        assert reason is None
one's avatar
one committed
133
134
135
136
        s = result[0]
        assert s.temp_c == pytest.approx(26.0)
        # Unrelated sensor keys must not populate fields
        assert s.avg_pwr_w is None
one's avatar
one committed
137
        assert s.gpu_pct is None
one's avatar
one committed
138
139
140

    def test_sample_ts_propagated(self):
        raw = json.dumps(HY_SMI_FULL)
141
142
        result, reason = parse_hy_smi_output(raw, sample_ts=42.5)
        assert reason is None
one's avatar
one committed
143
144
145
146
        assert result[0].ts == pytest.approx(42.5)

    def test_unknown_card_keys_ignored(self):
        payload = {"sys_info": {"foo": "bar"}, "card0": HY_SMI_FULL["card0"]}
147
148
        result, reason = parse_hy_smi_output(json.dumps(payload), sample_ts=1.0)
        assert reason is None
one's avatar
one committed
149
150
151
        assert list(result.keys()) == [0]

    def test_empty_string_returns_empty(self):
152
153
154
        samples, reason = parse_hy_smi_output("", sample_ts=1.0)
        assert samples == {}
        assert reason == "empty output"
one's avatar
one committed
155
156

    def test_invalid_json_returns_empty(self):
157
158
159
        samples, reason = parse_hy_smi_output("not json", sample_ts=1.0)
        assert samples == {}
        assert reason == "invalid json output"
one's avatar
one committed
160
161
162
163

    def test_ansi_stripped_before_parse(self):
        # Some hy-smi versions emit ANSI colors; parser must strip them first
        raw_with_ansi = "\x1b[0m" + json.dumps(HY_SMI_TEMP_ONLY) + "\x1b[0m"
164
165
        result, reason = parse_hy_smi_output(raw_with_ansi, sample_ts=1.0)
        assert reason is None
one's avatar
one committed
166
        assert 0 in result
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196

    def test_noise_before_json_is_tolerated(self):
        raw_with_noise = (
            "Authorized users only.\\n"
            "RTNETLINK answers: File exists\\n"
            f"{json.dumps(HY_SMI_TEMP_ONLY)}"
        )
        result, reason = parse_hy_smi_output(raw_with_noise, sample_ts=1.0)
        assert reason is None
        assert 0 in result

    def test_noise_with_broken_json_still_returns_empty(self):
        raw_with_noise_and_invalid_json = (
            "Authorized users only.\\n"
            '{"card0": {"Average Graphics Package Power (W)": "133.0""broken": "1"}}'
        )
        samples, reason = parse_hy_smi_output(raw_with_noise_and_invalid_json, sample_ts=1.0)
        assert samples == {}
        assert reason == "invalid json output"

    def test_invalid_json_reason(self):
        samples, reason = parse_hy_smi_output("not json", sample_ts=1.0)
        assert samples == {}
        assert reason == "invalid json output"

    def test_no_card_rows_reason(self):
        payload = {"meta": {"version": "1"}}
        samples, reason = parse_hy_smi_output(json.dumps(payload), sample_ts=1.0)
        assert samples == {}
        assert reason == "no card rows in payload"