Files
pyGUI/tests/test_data_parser.py
T
Jack.Le e306db6816 Add SettingsTab, StatusTab, DataTab and wire up DeviceController
- SettingsTab: chamber ID, master CSV mapping (families A-Z), wafer
  behavior toggle, light/dark mode toggle, Edit CSV Metadata dialog
- StatusTab: connection status card, wafer info, activity log (blank
  until Detect Wafer fires from the side rail)
- DataTab: empty state, ready for parsed data
- HomePage: side rail buttons dispatch device actions and jump to
  Status tab; selectedSideActionIndex defaults to -1 on startup
- DeviceController registered as QML context property in main.py
- LocalSettings: added status persistence fields
- Theme: added subheadingColor and disabledText
- Added serialcomm/ package and backend data/graph modules
- gitignore: restore clean version
2026-05-28 15:49:07 -07:00

300 lines
10 KiB
Python

"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
import pytest
from serialcomm.data_parser import (
csv_column_count,
parse_binary_data,
convert_to_temperatures,
remove_trailing_zeros,
save_to_csv,
_convert_hex_to_temp,
MAXDUT_P,
MAXDUT_X,
)
# ── csv_column_count ──────────────────────────────────────────────────────────
class TestCsvColumnCount:
def test_a_family(self):
assert csv_column_count("A") == 48
def test_e_family(self):
assert csv_column_count("E") == 48
def test_p_family(self):
assert csv_column_count("P") == 48
def test_b_family(self):
assert csv_column_count("B") == 29
def test_c_family(self):
assert csv_column_count("C") == 29
def test_d_family(self):
assert csv_column_count("D") == 29
def test_f_family(self):
assert csv_column_count("F") == 22
def test_x_family(self):
assert csv_column_count("X") == 80
def test_unknown_family_returns_zero(self):
assert csv_column_count("Z") == 0
def test_empty_string_returns_zero(self):
assert csv_column_count("") == 0
# ── Temperature conversion ────────────────────────────────────────────────────
class TestConvertHexToTemp:
"""Spot-check known hex → temperature values.
Reference values derived from the C# ConvertBinaryArrayToDecimal logic.
"""
def test_zero_hex_gives_zero_standard(self):
# 0x0000 → all bits 0 → temperature 0.0
result = _convert_hex_to_temp("0000", "B")
assert result == 0.0
def test_zero_hex_gives_zero_aep(self):
result = _convert_hex_to_temp("0000", "A")
assert result == 0.0
def test_standard_positive_small(self):
# 0x0050 = 0000 0000 0101 0000 → bits 1-11 = 000000000101 = 4+1 = 5, fraction bits = 00 → 5.0°C
result = _convert_hex_to_temp("0050", "B")
assert result == pytest.approx(5.0, abs=0.1)
def test_standard_negative(self):
# Sign bit set → negative temperature
result = _convert_hex_to_temp("8050", "B")
assert result < 0
def test_aep_positive(self):
# 0x6400 = 0110 0100 0000 0000
# sign=0, int bits 1-8 = 11001000 = ...
# Just check it's in a reasonable range
result = _convert_hex_to_temp("6400", "A")
assert -300 < result < 300
def test_x_family_uses_aep(self):
# X uses same AEP formula as A
result_x = _convert_hex_to_temp("0100", "X")
result_a = _convert_hex_to_temp("0100", "A")
assert result_x == result_a
def test_unknown_family_falls_back_to_standard(self):
# Should not raise; falls back to standard formula
result = _convert_hex_to_temp("0050", "Z")
assert isinstance(result, float)
# ── Binary parsing ────────────────────────────────────────────────────────────
def _make_p_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
"""Build synthetic P-family binary data.
Each block is 256 words (512 bytes). Words 0-243 hold `value`,
words 244-255 are zero (overhead).
"""
data = bytearray()
for _ in range(num_blocks):
for i in range(256):
word = value if i < MAXDUT_P else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
def _make_x_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
"""Build synthetic X-family binary data.
Each block is 256 words (512 bytes). Words 0-79 hold `value`,
words 80-255 are zero (overhead).
"""
data = bytearray()
for _ in range(num_blocks):
for i in range(256):
word = value if i < MAXDUT_X else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
class TestParseBinaryData:
def test_p_family_single_block_returns_one_row(self):
data = _make_p_block(1)
result = parse_binary_data(data, "P")
assert result is not None
assert len(result) == 1
def test_p_family_single_block_row_has_244_sensors(self):
data = _make_p_block(1)
result = parse_binary_data(data, "P")
assert result is not None
assert len(result[0]) == MAXDUT_P # 244
def test_p_family_two_blocks_returns_two_rows(self):
data = _make_p_block(2)
result = parse_binary_data(data, "P")
assert result is not None
assert len(result) == 2
def test_p_family_hex_values_are_4_chars(self):
data = _make_p_block(1)
result = parse_binary_data(data, "P")
assert result is not None
for hex_val in result[0]:
assert len(hex_val) == 4
def test_x_family_single_block_returns_one_row(self):
data = _make_x_block(1)
result = parse_binary_data(data, "X")
assert result is not None
assert len(result) == 1
def test_x_family_single_block_row_has_80_sensors(self):
data = _make_x_block(1)
result = parse_binary_data(data, "X")
assert result is not None
assert len(result[0]) == MAXDUT_X # 80
def test_a_family_uses_p_parser(self):
# A/B/C/D/E/F all route to _parse_p_binary
data = _make_p_block(1)
result = parse_binary_data(data, "A")
assert result is not None
assert len(result[0]) == MAXDUT_P
def test_empty_bytes_returns_empty_list(self):
result = parse_binary_data(b"", "P")
# No full block → no rows
assert result is not None
assert result == []
# ── convert_to_temperatures ───────────────────────────────────────────────────
class TestConvertToTemperatures:
def test_returns_same_shape(self):
hex_data = [["0000", "0000"], ["0000", "0000"]]
result = convert_to_temperatures(hex_data, "B")
assert len(result) == 2
assert len(result[0]) == 2
def test_zero_hex_gives_zero_string(self):
hex_data = [["0000"]]
result = convert_to_temperatures(hex_data, "B")
assert result[0][0] == "0.0"
def test_values_are_strings(self):
hex_data = [["0050"]]
result = convert_to_temperatures(hex_data, "B")
assert isinstance(result[0][0], str)
# Must be parseable as float
float(result[0][0])
def test_p_family_single_block(self):
data = _make_p_block(1, value=0x0100)
hex_data = parse_binary_data(data, "P")
result = convert_to_temperatures(hex_data, "P")
assert len(result) == 1
assert all(isinstance(v, str) for v in result[0])
# ── remove_trailing_zeros ─────────────────────────────────────────────────────
class TestRemoveTrailingZeros:
def test_removes_all_zero_last_row(self):
data = [["25.0", "24.5"], ["0.0", "0.0"]]
remove_trailing_zeros(data)
assert len(data) == 1
def test_preserves_non_zero_rows(self):
data = [["25.0", "24.5"], ["23.0", "22.0"]]
remove_trailing_zeros(data)
assert len(data) == 2
def test_removes_multiple_trailing_zero_rows(self):
data = [["25.0"], ["0.0"], ["0.0"]]
remove_trailing_zeros(data)
assert len(data) == 1
def test_all_zero_data_becomes_empty(self):
data = [["0.0", "0.0"], ["0.0", "0.0"]]
remove_trailing_zeros(data)
assert data == []
def test_empty_list_stays_empty(self):
data = []
remove_trailing_zeros(data)
assert data == []
def test_mixed_keeps_first_nonzero(self):
data = [["25.0"], ["1.0"], ["0.0"]]
remove_trailing_zeros(data)
assert len(data) == 2
# ── save_to_csv ───────────────────────────────────────────────────────────────
class TestSaveToCsv:
def test_creates_file(self, tmp_path):
data = [["25.0", "24.5"], ["23.0", "22.0"]]
result = save_to_csv(data, "P", "P00001", str(tmp_path))
assert result is not None
from pathlib import Path
assert Path(result).exists()
def test_filename_contains_serial(self, tmp_path):
data = [["25.0"]]
result = save_to_csv(data, "P", "P12345", str(tmp_path))
assert result is not None
assert "P12345" in result
def test_csv_has_sensor_headers(self, tmp_path):
# Header count is driven by csv_column_count(family), not data width.
# P family → 48 sensors; F family → 22 sensors; X family → 80 sensors.
cases = [("P", 48), ("F", 22), ("X", 80), ("B", 29)]
for family, expected_cols in cases:
data = [["25.0", "24.5"]]
result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
assert result is not None, f"save_to_csv returned None for {family}"
headers = open(result).readline().strip().split(",")
assert len(headers) == expected_cols, (
f"{family}: expected {expected_cols} headers, got {len(headers)}"
)
assert headers[0] == "Sensor1"
assert headers[-1] == f"Sensor{expected_cols}"
def test_csv_row_count_matches_data(self, tmp_path):
data = [["25.0"], ["24.5"], ["23.0"]]
result = save_to_csv(data, "P", "P00001", str(tmp_path))
assert result is not None
lines = open(result).readlines()
# 1 header + 3 data rows
assert len(lines) == 4
def test_creates_output_dir_if_missing(self, tmp_path):
nested = str(tmp_path / "a" / "b" / "c")
data = [["25.0"]]
result = save_to_csv(data, "P", "P00001", nested)
assert result is not None
from pathlib import Path
assert Path(result).exists()
def test_returns_none_on_invalid_path(self):
data = [["25.0"]]
result = save_to_csv(data, "P", "P00001", "/\x00invalid\x00path")
assert result is None