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
This commit is contained in:
jack
2026-07-12 20:02:25 -07:00
parent 6003bde84d
commit 67158ed7ab
15 changed files with 699 additions and 54 deletions
+162
View File
@@ -50,6 +50,11 @@ def test_detect_wafer_spawns_thread(controller):
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
@@ -91,6 +96,9 @@ def test_clear_session(controller):
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()
@@ -109,11 +117,32 @@ def test_erase_memory_handler(controller):
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):
@@ -300,3 +329,136 @@ def test_export_dir_created_under_save_data_dir(controller):
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)