"""Tests for hytop.gpu.parser.""" from __future__ import annotations import json import pytest from hytop.gpu.parser import ( parse_hy_smi_output, parse_number, strip_ansi, ) # --------------------------------------------------------------------------- # 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) result, reason = parse_hy_smi_output(raw, sample_ts=1.0) assert reason is None assert set(result.keys()) == {0, 7} def test_full_output_card0_metrics(self): raw = json.dumps(HY_SMI_FULL) result, reason = parse_hy_smi_output(raw, sample_ts=1.0) assert reason is None s = result[0] assert s.temp_c == pytest.approx(30.0) assert s.avg_pwr_w == pytest.approx(157.0) assert s.gpu_pct == pytest.approx(0.0) 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) result, reason = parse_hy_smi_output(raw, sample_ts=1.0) assert reason is None assert result[7].gpu_pct == pytest.approx(100.0) def test_temp_only_output(self): raw = json.dumps(HY_SMI_TEMP_ONLY) result, reason = parse_hy_smi_output(raw, sample_ts=1.0) assert reason is None 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 assert s.gpu_pct is None def test_sample_ts_propagated(self): raw = json.dumps(HY_SMI_FULL) result, reason = parse_hy_smi_output(raw, sample_ts=42.5) assert reason is None 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"]} result, reason = parse_hy_smi_output(json.dumps(payload), sample_ts=1.0) assert reason is None assert list(result.keys()) == [0] def test_empty_string_returns_empty(self): samples, reason = parse_hy_smi_output("", sample_ts=1.0) assert samples == {} assert reason == "empty output" def test_invalid_json_returns_empty(self): samples, reason = parse_hy_smi_output("not json", sample_ts=1.0) assert samples == {} assert reason == "invalid json output" 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" result, reason = parse_hy_smi_output(raw_with_ansi, sample_ts=1.0) assert reason is None assert 0 in result 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"