diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index 302b877..0570a72 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -33,6 +33,9 @@ Rectangle { property string dataCompareFileB: "" onSelectedTabIndexChanged: { + // Time-based, so re-check on every switch rather than trusting a + // value computed at some earlier point in a possibly long-running session. + root.reviewBlocked = licenseModel.reviewAccessBlocked() if (selectedTabIndex === 2) { file_browser.refreshFiles(); if (root.mapReviewFile !== "") { @@ -68,6 +71,9 @@ Rectangle { if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile } + function onLiveError(message) { + deviceController.appendActivityLog("Live stream error — stream stopped: " + message) + } } // Data tab's Run A/B picks survive it being destroyed/recreated by its @@ -84,6 +90,24 @@ Rectangle { property bool waferDetected: false property bool waferLicensed: false + // Data/Graph/Map-review are gated on the 30-day trial once it's actually + // expired (ADR-0005 "Deferred" item — the trigger existed, nothing ever + // checked it). Status stays free: hardware actions are gated separately, + // per-wafer-serial, via waferLicensed above. + property bool reviewBlocked: false + onReviewBlockedChanged: { + // Property change signals only fire on an actual value change, so + // this logs once per none/temporary → expired transition, not on + // every recompute in onSelectedTabIndexChanged/onLicensesChanged. + if (reviewBlocked) { + deviceController.appendActivityLog( + "Review trial expired — load a wafer license to continue using Data/Map/Graph (About dialog)") + if (root.selectedTabIndex !== 0) { + root.selectedTabIndex = 0 + } + } + } + Connections { target: deviceController function onDetectResult(result){ @@ -124,6 +148,7 @@ Rectangle { root.selectedTabIndex = 0 } root.waferLicensed = root.waferDetected && deviceController.waferLicensed() + root.reviewBlocked = licenseModel.reviewAccessBlocked() } } Connections { @@ -133,9 +158,9 @@ Rectangle { result !== undefined && result.success === true) if (root.memoryRead) { - console.log("[P1.1] Memory read complete:", result.bytes, "bytes") + console.log("Memory read complete:", result.bytes, "bytes") } else { - console.log("[P1.1] Read failed:", result.error || "unknown") + console.log("Read failed:", result.error || "unknown") } } } @@ -271,6 +296,7 @@ Rectangle { } } Component.onCompleted: { + root.reviewBlocked = licenseModel.reviewAccessBlocked() if (isFirstLaunch) { firstLaunchWizard.open() } @@ -342,10 +368,13 @@ Rectangle { id: pillBtn checked: root.selectedTabIndex === index // MAP (index 2) is locked until a valid license .bin is installed. + // DATA/MAP/GRAPH (index !== 0) also lock once the 30-day review + // trial has actually expired with no permanent license (ADR-0005). // Stays enabled (not `enabled: false`) so hover still fires and the // tooltip can explain why — a disabled Item receives no mouse/hover // events at all, which would silently kill the tooltip below. - property bool locked: index === 2 && licenseModel.licenses.length === 0 + property bool locked: (index === 2 && licenseModel.licenses.length === 0) + || (index !== 0 && root.reviewBlocked) flat: true Layout.fillWidth: true Layout.fillHeight: true @@ -411,9 +440,15 @@ Rectangle { AppToolTip { visible: pillBtn.hovered - text: (index === 2 && licenseModel.licenses.length === 0) - ? "MAP: No license loaded — load one in About to unlock" - : "" + model.label + ": " + model.desc + // Trial-expired is checked first: it's the broader reason and + // applies identically to Data/Map/Graph, so when it's true it + // wins even for MAP — otherwise MAP's narrower "no license at + // all" case (trial not yet expired, still just unlicensed) shows. + text: (index !== 0 && root.reviewBlocked) + ? "" + model.label + ": Review trial expired — load a license in About to unlock" + : (index === 2 && licenseModel.licenses.length === 0) + ? "MAP: No license loaded — load one in About to unlock" + : "" + model.label + ": " + model.desc } onClicked: if (!pillBtn.locked) root.selectedTabIndex = index diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index 31ce017..22a98d0 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -303,6 +303,13 @@ class DeviceController(QObject): self._activity_log = self._activity_log[-200:] self.activityLogUpdated.emit("\n".join(self._activity_log)) + @Slot(str) + @slot_error_boundary + def appendActivityLog(self, message: str) -> None: + """Public entry point for QML-side conditions (e.g. license/trial + state) to log into the same Activity Log as backend-triggered lines.""" + self._append_log(message) + @Slot() @slot_error_boundary def clearActivityLog(self) -> None: diff --git a/tests/test_license.py b/tests/test_license.py index 2908cd4..0c8e597 100644 --- a/tests/test_license.py +++ b/tests/test_license.py @@ -267,3 +267,33 @@ def test_replay_trial_expiry(tmp_path, qapp): def test_replay_trial_expired_without_marker(tmp_path, qapp): model = LicenseModel(str(tmp_path)) assert model.replayTrialExpired() is True + + +# ===== Review-access gate (ADR-0005 "Deferred" item) ===== +def test_review_access_not_blocked_before_trial_starts(tmp_path, qapp): + model = LicenseModel(str(tmp_path)) + assert model.reviewAccessBlocked() is False + + +def test_review_access_not_blocked_during_fresh_trial(tmp_path, qapp): + model = LicenseModel(str(tmp_path)) + model.startReplayTrial() + assert model.reviewAccessBlocked() is False + + +def test_review_access_blocked_once_trial_expired(tmp_path, qapp): + model = LicenseModel(str(tmp_path)) + model.startReplayTrial() + old = time.time() - 31 * 86400 + os.utime(model._trial_marker, (old, old)) + assert model.reviewAccessBlocked() is True + + +def test_review_access_not_blocked_with_permanent_license_even_if_trial_expired(tmp_path, qapp): + (tmp_path / "licenses").mkdir() + write_license(tmp_path / "licenses", level="01") + model = LicenseModel(str(tmp_path)) + model.startReplayTrial() + old = time.time() - 31 * 86400 + os.utime(model._trial_marker, (old, old)) + assert model.reviewAccessBlocked() is False