Files
pyGUI/tests/test_device_controller.py
T
jack 67158ed7ab feat(license): wafer license gate, runtime warning, and first-launch wizard
- Gate READ/ERASE/DEBUG behind wafer license check; log hint to load .bin
- waferRuntimeExceeded() warns when onboard runtime > 350s
- LicenseModel: remove-license (soft hide, .bin stays on disk) via
  removed_licenses.txt; re-loading un-hides
- FirstLaunchWizard: 2-step popup (license + save dir) shown on first run
- Per-tab file memory in HomePage (map/graph/data don't bleed state)
- Merge test_device_controller_license into test_device_controller
2026-07-12 20:02:25 -07:00

465 lines
16 KiB
Python

import logging
from unittest.mock import MagicMock, patch
import pytest
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.data.local_settings import LocalSettings
from pygui.serialcomm.serial_port import WaferInfo
@pytest.fixture
def mock_settings():
s = LocalSettings()
s.save_data_dir = ""
s.connection_status = "Disconnected"
s.activity_log = []
s.last_wafer_info = {}
return s
@pytest.fixture
def controller(mock_settings):
import shutil
import tempfile
temp_dir = tempfile.mkdtemp()
yield DeviceController(mock_settings, temp_dir)
shutil.rmtree(temp_dir)
def test_refresh_ports(controller):
controller._service.enumerate_ports = MagicMock(return_value=["COM1", "COM2"])
controller.refreshPorts()
assert "COM1" in controller.availablePorts
def test_detect_wafer_spawns_thread(controller):
with patch("threading.Thread") as mock_thread:
info = WaferInfo(
family_code="P",
serial_number="P00001",
sensor_count=244,
mfg_date_hex="12345678",
sensor_assigned_hex="01",
locked_hex="00",
runtime=100,
cycle_count=10
)
controller._service.detect_all_ports = MagicMock(
return_value=("COM1", info)
)
controller.detectWafer()
mock_thread.assert_called_once()
assert controller.operationInProgress
def test_read_memory_without_detect_return_error(controller):
# License the wafer so this test exercises the "no port" branch rather
# than short-circuiting on the license guard.
controller._license_lookup = lambda s: "02"
controller._last_wafer_info = {"serialNumber": "A00001"}
emitted_result = None
def on_read_result(result):
nonlocal emitted_result
emitted_result = result
controller.readResult.connect(on_read_result)
controller.readMemoryAsync()
assert emitted_result is not None
assert "error" in emitted_result
def test_setters_and_properties(controller):
controller.setSelectedPort("COM3")
assert controller.selectedPort == "COM3"
controller.setSaveDataDir("some/custom/dir")
assert controller.saveDataDir == "some/custom/dir"
assert controller.connectionStatus == "Disconnected"
assert controller.dataRowCount == 0
assert controller.dataColCount == 0
assert isinstance(controller.lastWaferInfo, list)
assert controller.dataModel is not None
assert controller.graphView is not None
def test_clear_activity_log(controller):
controller._activity_log.append("[12:00:00] Some message")
assert len(controller._activity_log) > 0
controller.clearActivityLog()
assert len(controller._activity_log) == 0
def test_clear_session(controller):
controller._connection_status = "Connected"
controller._selected_port = "COM1"
controller._last_wafer_info = {"serialNumber": "P00001"}
controller.clearSession()
assert controller.connectionStatus == "Disconnected"
assert controller.selectedPort == ""
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
def test_erase_memory_spawns_thread(controller):
# License the wafer so the new license guard doesn't block the erase.
controller._license_lookup = lambda s: "02"
controller._last_wafer_info = {"serialNumber": "A00001"}
with patch("threading.Thread") as mock_thread:
controller.eraseMemory("COM1")
mock_thread.assert_called_once()
assert controller.operationInProgress
def test_erase_memory_handler(controller):
emitted_result = None
def on_erase_result(result):
nonlocal emitted_result
emitted_result = result
controller.eraseResult.connect(on_erase_result)
controller._handle_erase_finished(True)
assert emitted_result == {"success": True}
assert controller.connectionStatus == "Connected"
assert not controller.operationInProgress
def test_read_debug_spawns_thread(controller):
# License the wafer so the license guard doesn't block the debug read.
controller._license_lookup = lambda s: "02"
controller._last_wafer_info = {"serialNumber": "A00001"}
with patch("threading.Thread") as mock_thread:
controller.readDebug("COM1")
mock_thread.assert_called_once()
assert controller.operationInProgress
def test_read_debug_denied_without_license(controller):
"""readDebug() returns D1 sensor data, so it obeys the same license guard."""
controller._license_lookup = lambda s: ""
controller._last_wafer_info = {"serialNumber": "A00001"}
emitted_result = None
def on_debug_result(result):
nonlocal emitted_result
emitted_result = result
controller.debugResult.connect(on_debug_result)
with patch("threading.Thread") as mock_thread:
controller.readDebug("COM1")
mock_thread.assert_not_called()
assert emitted_result == {"success": False, "error": "Wafer not licensed"}
assert not controller.operationInProgress
def test_read_debug_handler(controller):
emitted_result = None
def on_debug_result(result):
nonlocal emitted_result
emitted_result = result
controller.debugResult.connect(on_debug_result)
controller._handle_debug_finished({
"success": True,
"sensor_bytes": 512,
"debug_bytes": 512
})
assert emitted_result is not None
assert emitted_result.get("success") is True
assert emitted_result.get("sensor_bytes") == 512
assert emitted_result.get("debug_bytes") == 512
assert controller.connectionStatus == "Connected"
assert not controller.operationInProgress
def _make_p_family_bytes(value: int = 0x0100) -> bytes:
"""Synthetic single-block P-family binary: 244 valid words + 12 overhead."""
data = bytearray()
for i in range(256):
word = value if i < 244 else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
def test_debug_worker_decodes_and_saves_csv(controller, tmp_path):
controller._last_wafer_info = {"familyCode": "P"}
controller._save_data_dir = str(tmp_path)
controller._service.read_wafer_data = MagicMock(
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
)
captured = {}
controller._debugFinished.connect(captured.update)
controller._debug_worker("COM1")
assert captured.get("success") is True
assert captured.get("sensor_bytes") == 512
assert captured.get("debug_bytes") == 512
assert "csv_path" in captured
from pathlib import Path
assert Path(captured["csv_path"]).exists()
assert Path(captured["csv_path"]).name == "debug_info.csv"
def test_debug_worker_reports_error_when_csv_save_fails(controller, tmp_path):
controller._last_wafer_info = {"familyCode": "P"}
# Point save dir somewhere save_debug_csv cannot write to, so it returns None.
controller._save_data_dir = str(tmp_path / "debug_info.csv") # a file, not a dir
(tmp_path / "debug_info.csv").write_text("occupied")
controller._service.read_wafer_data = MagicMock(
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
)
captured = {}
controller._debugFinished.connect(captured.update)
controller._debug_worker("COM1")
assert captured.get("success") is not True
assert "error" in captured
def test_debug_handler_logs_csv_path(controller):
controller._handle_debug_finished({
"success": True,
"sensor_bytes": 512,
"debug_bytes": 512,
"csv_path": "/tmp/debug_info.csv",
})
assert "/tmp/debug_info.csv" in controller.activityLog
def test_parse_and_save_data_and_get_chart_data(controller):
res = controller.getChartData()
assert res == {"success": False}
controller._raw_bytes = bytes([12] * 512)
emitted_result = None
def on_parsed(result):
nonlocal emitted_result
emitted_result = result
controller.parsedDataReady.connect(on_parsed)
controller.parseAndSaveData("P", "COM1")
assert emitted_result is not None
assert emitted_result.get("success") is True
assert controller.dataRowCount == 1
assert controller.dataColCount == 244
chart_res = controller.getChartData()
assert chart_res.get("success") is True
assert "Sensor1" in chart_res.get("sensor_names")
assert len(chart_res.get("series")) == 244
def test_logging_handler(controller):
logger = logging.getLogger("test_logger")
logger.info("Hello QML Activity Log")
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
def test_fresh_session_on_startup(mock_settings):
import shutil
import tempfile
# Configure mock settings with some data to simulate previous session
mock_settings.activity_log = ["[12:00:00] Old message"]
mock_settings.selected_port = "COM9"
mock_settings.last_wafer_info = {"familyCode": "P", "serialNumber": "P00002"}
mock_settings.data_row_count = 50
mock_settings.data_col_count = 244
temp_dir = tempfile.mkdtemp()
try:
new_controller = DeviceController(mock_settings, temp_dir)
# Should start completely fresh (empty/default) ignoring the persisted status
assert len(new_controller._activity_log) == 0
assert new_controller.selectedPort == ""
assert new_controller.lastWaferInfo == ["", "", 0, "", 0, 0]
assert new_controller.dataRowCount == 0
assert new_controller.dataColCount == 0
finally:
shutil.rmtree(temp_dir)
def test_detect_wafer_clears_old_session(controller):
controller._selected_port = "COM1"
controller._last_wafer_info = {"familyCode": "P", "serialNumber": "P00001"}
controller._data_row_count = 10
controller._data_col_count = 244
controller._activity_log.append("[12:00:00] Previous message")
# We mock detect_all_ports to not run long or fail
controller._service.detect_all_ports = MagicMock(return_value=(None, None))
with patch("threading.Thread"):
controller.detectWafer()
# Verify it cleared all session variables and logs immediately at start of detect
assert controller.selectedPort == ""
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
assert controller.dataRowCount == 0
assert controller.dataColCount == 0
# Log should only contain "Scanning all ports for wafer ..."
assert len(controller._activity_log) == 1
assert "Scanning all ports" in controller._activity_log[0]
def test_device_service_port_caching(controller):
service = controller._service
# Mock comports list
with patch("serial.tools.list_ports.comports", return_value=[]), \
patch("subprocess.run") as mock_run:
# Mock subprocess to return empty
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = ""
# Clear cache first
service.clear_port_scan_cache()
# First scan should invoke list_ports and subprocess
ports1 = service.enumerate_ports()
assert mock_run.call_count == 1
# Second scan within 2 seconds should return cached list without invoking subprocess
ports2 = service.enumerate_ports()
assert mock_run.call_count == 1
assert ports1 == ports2
# Clear cache and scan again should invoke subprocess
service.clear_port_scan_cache()
service.enumerate_ports()
assert mock_run.call_count == 2
def test_export_dir_created_under_save_data_dir(controller):
from pathlib import Path
result = controller.exportDir()
assert result == str(Path(controller.saveDataDir) / "Export")
assert Path(result).is_dir()
def test_wafer_licensed_default_no_license(controller):
"""Default license_lookup (empty lambda) -> waferLicensed() is False."""
controller._last_wafer_info = {"serialNumber": "P00001"}
assert controller.waferLicensed() is False
def test_wafer_licensed_with_valid_license(mock_settings):
"""A license_lookup returning a level -> waferLicensed() is True."""
import shutil
import tempfile
temp_dir = tempfile.mkdtemp()
try:
controller = DeviceController(
mock_settings, temp_dir, license_lookup=lambda s: "02"
)
controller._last_wafer_info = {"serialNumber": "A00001"}
assert controller.waferLicensed() is True
finally:
shutil.rmtree(temp_dir)
def test_read_memory_denied_without_license(controller):
"""readMemoryAsync() must refuse to spawn a thread for an unlicensed wafer."""
controller._license_lookup = lambda s: ""
controller._last_wafer_info = {"serialNumber": "A00001"}
controller._selected_port = "COM1"
emitted_result = None
def on_read_result(result):
nonlocal emitted_result
emitted_result = result
controller.readResult.connect(on_read_result)
with patch("threading.Thread") as mock_thread:
controller.readMemoryAsync()
mock_thread.assert_not_called()
assert emitted_result is not None
assert "error" in emitted_result
assert not controller.operationInProgress
def test_erase_memory_denied_without_license(controller):
"""eraseMemory() must refuse to spawn a thread for an unlicensed wafer."""
controller._license_lookup = lambda s: ""
controller._last_wafer_info = {"serialNumber": "A00001"}
emitted_result = None
def on_erase_result(result):
nonlocal emitted_result
emitted_result = result
controller.eraseResult.connect(on_erase_result)
with patch("threading.Thread") as mock_thread:
controller.eraseMemory("COM1")
mock_thread.assert_not_called()
assert emitted_result == {"success": False}
assert not controller.operationInProgress
def test_read_memory_allowed_with_license(controller):
"""readMemoryAsync() proceeds and spawns a thread once the wafer is licensed."""
controller._license_lookup = lambda s: "02"
controller._last_wafer_info = {"serialNumber": "A00001"}
controller._selected_port = "COM1"
with patch("threading.Thread") as mock_thread:
controller.readMemoryAsync()
mock_thread.assert_called_once()
assert controller.operationInProgress
def test_erase_memory_allowed_with_license(controller):
"""eraseMemory() proceeds and spawns a thread once the wafer is licensed."""
controller._license_lookup = lambda s: "02"
controller._last_wafer_info = {"serialNumber": "A00001"}
with patch("threading.Thread") as mock_thread:
controller.eraseMemory("COM1")
mock_thread.assert_called_once()
assert controller.operationInProgress
def test_wafer_runtime_exceeded_false_under_threshold(controller):
controller._last_wafer_info = {"runtime": 100}
assert controller.waferRuntimeExceeded() is False
def test_wafer_runtime_exceeded_true_over_threshold(controller):
controller._last_wafer_info = {"runtime": 999}
assert controller.waferRuntimeExceeded() is True
def test_detect_finished_warns_on_runtime_exceeded(controller):
info = WaferInfo(
family_code="P",
serial_number="P00001",
sensor_count=244,
mfg_date_hex="12345678",
sensor_assigned_hex="01",
locked_hex="00",
runtime=999,
cycle_count=10,
)
log_messages = []
controller.activityLogUpdated.connect(lambda msg: log_messages.append(msg))
controller._handle_detect_finished("COM1", info)
assert any("exceeds recommended" in msg for msg in log_messages)
def test_detect_finished_no_warning_under_threshold(controller):
info = WaferInfo(
family_code="P",
serial_number="P00001",
sensor_count=244,
mfg_date_hex="12345678",
sensor_assigned_hex="01",
locked_hex="00",
runtime=100,
cycle_count=10,
)
log_messages = []
controller.activityLogUpdated.connect(lambda msg: log_messages.append(msg))
controller._handle_detect_finished("COM1", info)
assert not any("exceeds recommended" in msg for msg in log_messages)