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:
@@ -111,6 +111,7 @@ Popup {
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||
Item { Layout.preferredWidth: 28 }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -132,6 +133,14 @@ Popup {
|
||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
||||
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
|
||||
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||
ToolButton {
|
||||
Layout.preferredWidth: 28
|
||||
text: "✕"
|
||||
font.pixelSize: Theme.fontSm
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Remove license"
|
||||
onClicked: licenseModel.removeLicense(modelData.serial, modelData.level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== First-Launch Wizard =====
|
||||
// Shown once, on first launch (no settings.json yet). Two skippable steps:
|
||||
// load a license, then pick a save folder. Either step can be skipped —
|
||||
// review-mode features work without a license, and save dir already has a
|
||||
// sensible default, so this is guidance, not a gate.
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.NoAutoClose
|
||||
width: 520
|
||||
height: 320
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
property int step: 0
|
||||
|
||||
signal finished()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: licenseFileDialog
|
||||
title: "Load License"
|
||||
nameFilters: ["License files (*.bin)"]
|
||||
onAccepted: {
|
||||
licenseModel.loadLicenseFile(selectedFile.toString())
|
||||
root.step = 1
|
||||
}
|
||||
}
|
||||
|
||||
FolderDialog {
|
||||
id: saveDirDialog
|
||||
title: "Choose a folder to save data"
|
||||
onAccepted: {
|
||||
var dir = decodeURIComponent(String(selectedFolder).replace(/^file:\/\//, ""))
|
||||
deviceController.setSaveDataDir(dir)
|
||||
file_browser.setCurrentDirectory(dir)
|
||||
root.finished()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 28
|
||||
spacing: 20
|
||||
|
||||
Label {
|
||||
text: "Welcome to ISenseCloud"
|
||||
font.pixelSize: Theme.font2xl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
|
||||
// ── Step 1: License ──
|
||||
ColumnLayout {
|
||||
visible: root.step === 0
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 12
|
||||
|
||||
Label {
|
||||
text: "Load your wafer license to enable live device actions\n(Detect, Read, Erase, Debug). You can also do this later\nfrom About."
|
||||
font.pixelSize: Theme.fontMd
|
||||
color: Theme.bodyColor
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// ── Step 2: Save Directory ──
|
||||
ColumnLayout {
|
||||
visible: root.step === 1
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 12
|
||||
|
||||
Label {
|
||||
text: "Choose where wafer read data gets saved. If you skip\nthis, it defaults to a \"csv\" folder inside your app data\ndirectory — changeable anytime from Status."
|
||||
font.pixelSize: Theme.fontMd
|
||||
color: Theme.bodyColor
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// ── Buttons ──
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: "Skip"
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: root.step === 0 ? (root.step = 1) : root.finished()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: root.step === 0 ? "Load License" : "Choose Folder"
|
||||
Layout.preferredWidth: 160
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: root.step === 0 ? licenseFileDialog.open() : saveDirDialog.open()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.primaryAccent
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-10
@@ -19,20 +19,70 @@ Rectangle {
|
||||
|
||||
// ===== View State =====
|
||||
property int selectedTabIndex: 0
|
||||
|
||||
// Map, Graph, and Data each keep their OWN remembered file selection —
|
||||
// switching tabs must not bleed one tab's pick into another. streamController
|
||||
// itself only holds ONE loaded file at a time (it's a single review session),
|
||||
// so "per-tab selection" means: remember the filename per tab here, and
|
||||
// re-load the right one into that single session whenever the tab becomes
|
||||
// active again. Data tab's compareFileA/B are tracked the same way further
|
||||
// below (Connections on dataTabLoader.item).
|
||||
property string mapReviewFile: ""
|
||||
property string graphReviewFile: ""
|
||||
property string dataCompareFileA: ""
|
||||
property string dataCompareFileB: ""
|
||||
|
||||
onSelectedTabIndexChanged: {
|
||||
if (selectedTabIndex === 2) {
|
||||
file_browser.refreshFiles();
|
||||
streamController.unloadFile()
|
||||
} else if (streamController.mode === "live") {
|
||||
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||
streamController.setMode("review")
|
||||
if (root.mapReviewFile !== "") {
|
||||
if (streamController.loadedFile !== root.mapReviewFile)
|
||||
streamController.loadFile(root.mapReviewFile)
|
||||
} else if (streamController.loadedFile !== "") {
|
||||
// Map has no memory of its own yet — don't inherit whatever
|
||||
// another tab (e.g. Data's last Run A/B pick) left loaded.
|
||||
streamController.unloadFile()
|
||||
}
|
||||
} else if (selectedTabIndex === 3) {
|
||||
if (root.graphReviewFile !== "") {
|
||||
if (streamController.loadedFile !== root.graphReviewFile)
|
||||
streamController.loadFile(root.graphReviewFile)
|
||||
} else if (streamController.loadedFile !== "") {
|
||||
streamController.unloadFile()
|
||||
}
|
||||
}
|
||||
// Leaving Map tab no longer force-stops a live stream: streamController
|
||||
// (StreamReader + timers) is parented to itself, not to this QML tree,
|
||||
// so acquisition keeps running in the background. The Map Loader just
|
||||
// deactivates — trendChart's local 60s buffer resets on return (it's
|
||||
// QML-item-local state), but the wafer map and stream resume live.
|
||||
}
|
||||
|
||||
// Records which tab's memory a file pick belongs to. Only fires for
|
||||
// picks made while actually on Map(2) or Graph(3) — Data tab's own
|
||||
// Connections (further below) captures its own picks independently,
|
||||
// and this is silent while on Status(0)/Data(1) for any other load.
|
||||
Connections {
|
||||
target: streamController
|
||||
function onLoadedFileChanged() {
|
||||
if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile
|
||||
else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile
|
||||
}
|
||||
}
|
||||
|
||||
// Data tab's Run A/B picks survive it being destroyed/recreated by its
|
||||
// Loader (see dataTabLoader below) by mirroring them here.
|
||||
Connections {
|
||||
target: dataTabLoader.item
|
||||
function onCompareFileAChanged() { root.dataCompareFileA = dataTabLoader.item.compareFileA }
|
||||
function onCompareFileBChanged() { root.dataCompareFileB = dataTabLoader.item.compareFileB }
|
||||
}
|
||||
|
||||
|
||||
|
||||
property bool memoryRead: false
|
||||
property bool waferDetected: false
|
||||
property bool waferLicensed: false
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
@@ -41,16 +91,41 @@ Rectangle {
|
||||
// as an opaque wrapper whose fields read as undefined, so only
|
||||
// test truthiness (None on failure, dict on success).
|
||||
root.waferDetected = !!result
|
||||
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
|
||||
// Wafer's own onboard runtime — not a local file a technician can
|
||||
// reset — is what starts the review trial clock. startReplayTrial()
|
||||
// is a true no-op once the marker exists (see license_model.py),
|
||||
// so re-detecting the same over-limit wafer never extends it.
|
||||
if (root.waferDetected && deviceController.waferRuntimeExceeded()) {
|
||||
licenseModel.startReplayTrial()
|
||||
}
|
||||
}
|
||||
function onParsedDataReady(result) {
|
||||
if (result.success && result.csv_path) {
|
||||
streamController.loadFile(result.csv_path)
|
||||
if (root.selectedTabIndex === 0) {
|
||||
// Fires while still on Status(0), before the tab switch below —
|
||||
// the generic per-tab tracker only attributes picks made while
|
||||
// already on Map/Graph, so record this one explicitly.
|
||||
root.mapReviewFile = result.csv_path
|
||||
if (root.selectedTabIndex === 0 && licenseModel.licenses.length > 0) {
|
||||
root.selectedTabIndex = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: licenseModel
|
||||
function onLicensesChanged() {
|
||||
// Map tab is license-gated: if its licenses vanish while it's
|
||||
// selected, snap back to STATUS instead of showing a bright
|
||||
// pill over an unloaded tab. Also re-check the wafer license
|
||||
// live so Read/Erase don't stay stale until the next detect.
|
||||
if (root.selectedTabIndex === 2 && licenseModel.licenses.length === 0) {
|
||||
root.selectedTabIndex = 0
|
||||
}
|
||||
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onReadResult(result) {
|
||||
@@ -181,6 +256,26 @@ Rectangle {
|
||||
id: aboutDialog
|
||||
}
|
||||
|
||||
// ===== First-Launch Wizard =====
|
||||
FirstLaunchWizard {
|
||||
id: firstLaunchWizard
|
||||
onFinished: {
|
||||
close()
|
||||
// Wizard closed with no license loaded (skipped, or Load License
|
||||
// never landed a valid file) — start the 30-day review trial now
|
||||
// rather than waiting for a wafer to be detected over-runtime.
|
||||
if (licenseModel.licenses.length === 0) {
|
||||
licenseModel.startReplayTrial()
|
||||
}
|
||||
settingsModel.saveSettings()
|
||||
}
|
||||
}
|
||||
Component.onCompleted: {
|
||||
if (isFirstLaunch) {
|
||||
firstLaunchWizard.open()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Main Layout: Rail + Workspace =====
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
@@ -246,6 +341,11 @@ Rectangle {
|
||||
delegate: Button {
|
||||
id: pillBtn
|
||||
checked: root.selectedTabIndex === index
|
||||
// MAP (index 2) is locked until a valid license .bin is installed.
|
||||
// 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
|
||||
flat: true
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
@@ -276,7 +376,7 @@ Rectangle {
|
||||
source: model.icon
|
||||
sourceSize.width: 16; sourceSize.height: 16
|
||||
color: (pillBtn.checked || pillBtn.hovered) ? Theme.headingColor : Theme.sideMutedText
|
||||
opacity: pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.85 : 0.4
|
||||
opacity: !pillBtn.locked ? (pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.85 : 0.4) : 0.2
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
@@ -295,7 +395,7 @@ Rectangle {
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
opacity: pillBtn.checked ? 1.0 : 0.0
|
||||
opacity: !pillBtn.locked ? (pillBtn.checked ? 1.0 : 0.0) : 0.25
|
||||
clip: true
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||
@@ -311,10 +411,12 @@ Rectangle {
|
||||
|
||||
AppToolTip {
|
||||
visible: pillBtn.hovered
|
||||
text: "<b>" + model.label + "</b>: " + model.desc
|
||||
text: (index === 2 && licenseModel.licenses.length === 0)
|
||||
? "<b>MAP</b>: No license loaded — load one in About to unlock"
|
||||
: "<b>" + model.label + "</b>: " + model.desc
|
||||
}
|
||||
|
||||
onClicked: root.selectedTabIndex = index
|
||||
onClicked: if (!pillBtn.locked) root.selectedTabIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -594,6 +696,8 @@ Rectangle {
|
||||
SourcePanel {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
selectedTabIndex: root.selectedTabIndex
|
||||
dataTab: dataTabLoader.item
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,10 +736,24 @@ Rectangle {
|
||||
id: dataTabLoader
|
||||
source: "Tabs/DataTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
// Component is still recreated lazily on every tab switch
|
||||
// (memory-cheap, matches Status/Map/Graph) — what survives
|
||||
// is just the two filenames (root.dataCompareFileA/B),
|
||||
// restored here so Run A/B picks and their two-color
|
||||
// highlight aren't lost on return. Computed DTW results
|
||||
// are NOT re-run automatically — re-fetching is a real
|
||||
// (if fast) backend call, so the user re-clicks "Run DTW
|
||||
// Comparison" rather than it silently firing on tab entry.
|
||||
onLoaded: {
|
||||
item.compareFileA = root.dataCompareFileA
|
||||
item.compareFileB = root.dataCompareFileB
|
||||
item.activeBox = root.dataCompareFileB !== "" ? 0
|
||||
: root.dataCompareFileA !== "" ? 2 : 1
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
source: "Tabs/WaferMapTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
active: StackLayout.index === root.selectedTabIndex && licenseModel.licenses.length > 0
|
||||
}
|
||||
Loader {
|
||||
source: "Tabs/GraphTab.qml"
|
||||
|
||||
@@ -15,6 +15,9 @@ Rectangle {
|
||||
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||
// that's mid-operation), independent of hover/press state.
|
||||
property bool toggled: false
|
||||
// Shown on hover even while disabled, e.g. to explain *why* a greyed-out
|
||||
// action is unavailable. Empty string shows no tooltip.
|
||||
property string tooltipText: ""
|
||||
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||
: root.destructive ? Theme.statusErrorColor
|
||||
: Theme.headingColor
|
||||
@@ -23,7 +26,7 @@ Rectangle {
|
||||
|
||||
implicitHeight: Theme.sideButtonHeight
|
||||
radius: 8
|
||||
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||
color: root.toggled || (root.enabled && (mouseArea.containsMouse || mouseArea.pressed))
|
||||
? Theme.sideActiveBackground : "transparent"
|
||||
border.width: 0
|
||||
|
||||
@@ -64,10 +67,17 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: root.enabled
|
||||
// Hover stays live while disabled so tooltipText can explain why —
|
||||
// click is still gated on root.enabled below.
|
||||
hoverEnabled: true
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.enabled) root.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
AppToolTip {
|
||||
visible: root.tooltipText.length > 0 && mouseArea.containsMouse
|
||||
text: root.tooltipText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ ColumnLayout {
|
||||
property string exportSummary: ""
|
||||
property string importSummary: ""
|
||||
|
||||
// Set by HomePage.qml — a separate .qml file has its own id scope, so
|
||||
// dataTabLoader/root from HomePage aren't reachable here directly.
|
||||
property int selectedTabIndex: 0
|
||||
property var dataTab: null
|
||||
|
||||
Connections {
|
||||
target: batchExportController
|
||||
function onExportFinished(summary) {
|
||||
@@ -352,11 +357,7 @@ ColumnLayout {
|
||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||
}
|
||||
|
||||
readonly property var dataTab: {
|
||||
try {
|
||||
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
readonly property var dataTab: root.selectedTabIndex === 1 ? root.dataTab : null
|
||||
|
||||
// Run A's effective file: the master file when "Compare vs Master
|
||||
// File" is toggled on, otherwise the manually selected file.
|
||||
|
||||
@@ -44,7 +44,9 @@ ColumnLayout {
|
||||
label: "READ MEMORY"
|
||||
iconSource: "../icons/read.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
enabled: deviceController.waferDetected && root.waferLicensed && !deviceController.operationInProgress
|
||||
tooltipText: (deviceController.waferDetected && !root.waferLicensed)
|
||||
? "Wafer not licensed — load its license .bin (About dialog)" : ""
|
||||
onClicked: deviceController.readMemoryAsync()
|
||||
}
|
||||
|
||||
@@ -53,7 +55,9 @@ ColumnLayout {
|
||||
iconSource: "../icons/erase.svg"
|
||||
Layout.fillWidth: true
|
||||
destructive: true
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
enabled: deviceController.waferDetected && root.waferLicensed && !deviceController.operationInProgress
|
||||
tooltipText: (deviceController.waferDetected && !root.waferLicensed)
|
||||
? "Wafer not licensed — load its license .bin (About dialog)" : ""
|
||||
onClicked: deviceController.eraseMemory(
|
||||
deviceController.selectedPort)
|
||||
}
|
||||
@@ -65,7 +69,9 @@ ColumnLayout {
|
||||
// One-shot toggle: "on" while the debug read is in flight,
|
||||
// resets automatically once deviceController flips status.
|
||||
toggled: deviceController.connectionStatus === "Reading debug..."
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
enabled: deviceController.waferDetected && root.waferLicensed && !deviceController.operationInProgress
|
||||
tooltipText: (deviceController.waferDetected && !root.waferLicensed)
|
||||
? "Wafer not licensed — load its license .bin (About dialog)" : ""
|
||||
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,22 @@ Rectangle {
|
||||
|
||||
TabBar {
|
||||
id: modeBar
|
||||
// Guards the programmatic sync below from re-triggering
|
||||
// onCurrentIndexChanged's setMode()/startStream() side effects.
|
||||
property bool _syncing: false
|
||||
currentIndex: 0
|
||||
Component.onCompleted: {
|
||||
// WaferMapTab (and this panel) is destroyed/recreated by
|
||||
// its Loader on every tab switch, so this TabBar always
|
||||
// starts fresh at index 0 — but streamController.mode is
|
||||
// backend state that outlives the switch (streaming now
|
||||
// continues off-tab). Sync the visual index to the real
|
||||
// mode on creation instead of defaulting to "Review" and
|
||||
// silently disagreeing with a still-live stream.
|
||||
_syncing = true
|
||||
currentIndex = streamController.mode === "live" ? 1 : 0
|
||||
_syncing = false
|
||||
}
|
||||
spacing: 2
|
||||
padding: 2
|
||||
Layout.fillWidth: true
|
||||
@@ -152,6 +167,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (_syncing) return;
|
||||
if (currentIndex === 1) {
|
||||
if (deviceController.connectionStatus !== "Connected") {
|
||||
currentIndex = 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ module ISC
|
||||
Main 1.0 Main.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
AboutDialog 1.0 AboutDialog.qml
|
||||
FirstLaunchWizard 1.0 FirstLaunchWizard.qml
|
||||
|
||||
# ===== Singleton Theme =====
|
||||
singleton Theme 1.0 Theme.qml
|
||||
|
||||
+11
-5
@@ -49,6 +49,9 @@ def main() -> int:
|
||||
# Settings tab is visible to the device layer immediately — no reload,
|
||||
# no second copy to go stale.
|
||||
data_dir = str(default_data_dir())
|
||||
# settings.json only exists once something has saved — absence means this
|
||||
# machine has never run the app before (first-launch wizard trigger).
|
||||
is_first_launch = not (Path(data_dir) / "settings.json").exists()
|
||||
raw_settings = LocalSettings.read_settings(data_dir)
|
||||
|
||||
settings_model = LocalSettingsModel(raw_settings, data_dir)
|
||||
@@ -56,6 +59,7 @@ def main() -> int:
|
||||
settings_model.loadSettings()
|
||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||
engine.rootContext().setContextProperty("isFirstLaunch", is_first_launch)
|
||||
|
||||
# App version from package metadata so the About dialog can't drift
|
||||
# from pyproject.toml.
|
||||
@@ -65,17 +69,19 @@ def main() -> int:
|
||||
app_version = "dev"
|
||||
engine.rootContext().setContextProperty("appVersion", app_version)
|
||||
|
||||
# ===== License Model (About dialog grid, replay trial) =====
|
||||
license_model = LicenseModel(data_dir)
|
||||
engine.rootContext().setContextProperty("licenseModel", license_model)
|
||||
|
||||
# ===== Device Controller (serial comm) =====
|
||||
device_controller = DeviceController(raw_settings, data_dir)
|
||||
device_controller = DeviceController(
|
||||
raw_settings, data_dir, license_lookup=license_model.levelForWafer
|
||||
)
|
||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||
|
||||
# Source panel browses the same folder recordings/reads are saved to.
|
||||
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
|
||||
|
||||
# ===== License Model (About dialog grid, replay trial) =====
|
||||
license_model = LicenseModel(data_dir)
|
||||
engine.rootContext().setContextProperty("licenseModel", license_model)
|
||||
|
||||
# ===== Session Controller (live/review wafer dashboard) =====
|
||||
stream_controller = SessionController()
|
||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||
|
||||
@@ -9,7 +9,7 @@ import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
|
||||
@@ -29,6 +29,12 @@ from pygui.serialcomm.serial_port import WaferInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ponytail: placeholder pending real hardware spec — the C# reference had two
|
||||
# runtime numbers (350, 21600s) but neither was ever an enforced comparison,
|
||||
# just a dead constant and a display string. Recalibrate once product gives
|
||||
# the actual recommended figure.
|
||||
WAFER_RUNTIME_WARNING_SECONDS = 350
|
||||
|
||||
|
||||
class DeviceController(QObject):
|
||||
"""Controls serial communication with the temperature-sensing wafer.
|
||||
@@ -57,10 +63,17 @@ class DeviceController(QObject):
|
||||
_eraseFinished = Signal(bool) # (success)
|
||||
_debugFinished = Signal(dict) # (result_dict)
|
||||
|
||||
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
settings: LocalSettings,
|
||||
data_dir: str,
|
||||
license_lookup: Callable[[str], str] = lambda s: "",
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._settings = settings
|
||||
self._data_dir = data_dir
|
||||
self._license_lookup = license_lookup
|
||||
from pygui.serialcomm.device_service import DeviceService
|
||||
self._service = DeviceService(settings)
|
||||
|
||||
@@ -266,6 +279,21 @@ class DeviceController(QObject):
|
||||
"series": series_data,
|
||||
}
|
||||
|
||||
@Slot(result=bool)
|
||||
def waferLicensed(self) -> bool:
|
||||
"""Check if the current wafer has a valid license."""
|
||||
return self._wafer_license_level() != ""
|
||||
|
||||
@Slot(result=bool)
|
||||
def waferRuntimeExceeded(self) -> bool:
|
||||
"""True if the detected wafer's onboard runtime is past the recommended limit."""
|
||||
runtime = int(self._last_wafer_info.get("runtime", 0))
|
||||
return runtime > WAFER_RUNTIME_WARNING_SECONDS
|
||||
|
||||
def _wafer_license_level(self) -> str:
|
||||
"""Get license level for the current wafer serial number."""
|
||||
return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
|
||||
|
||||
def _append_log(self, message: str) -> None:
|
||||
"""Append a message to the activity log and emit updated log."""
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
@@ -383,6 +411,12 @@ class DeviceController(QObject):
|
||||
self._last_wafer_info = wafer_dict
|
||||
self._set_wafer_detected(True)
|
||||
self._set_connection_status("Connected")
|
||||
if self.waferRuntimeExceeded():
|
||||
self._append_log(
|
||||
f"Warning: wafer runtime {wafer_dict['runtime']}s exceeds "
|
||||
f"recommended {WAFER_RUNTIME_WARNING_SECONDS}s — "
|
||||
"consider ordering a replacement wafer"
|
||||
)
|
||||
self.detectResult.emit(wafer_dict)
|
||||
self.selectedPortChanged.emit()
|
||||
self._save_status()
|
||||
@@ -410,6 +444,11 @@ class DeviceController(QObject):
|
||||
self._append_log("Already busy — ignoring read request")
|
||||
return
|
||||
|
||||
if self._wafer_license_level() == "":
|
||||
self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||
self.readResult.emit({"error": "Wafer not licensed"})
|
||||
return
|
||||
|
||||
if not self._selected_port:
|
||||
self._append_log("No wafer detected — run Detect Wafer first")
|
||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||
@@ -470,6 +509,11 @@ class DeviceController(QObject):
|
||||
self._append_log("Already busy — ignoring erase request")
|
||||
return
|
||||
|
||||
if self._wafer_license_level() == "":
|
||||
self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||
self.eraseResult.emit({"success": False})
|
||||
return
|
||||
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Erasing...")
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
@@ -510,6 +554,13 @@ class DeviceController(QObject):
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring debug read request")
|
||||
return
|
||||
|
||||
# Debug read returns D1 sensor data — same license boundary as READ MEMORY.
|
||||
if self._wafer_license_level() == "":
|
||||
self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||
self.debugResult.emit({"success": False, "error": "Wafer not licensed"})
|
||||
return
|
||||
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Reading debug...")
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
|
||||
@@ -33,7 +33,10 @@ class LicenseModel(QObject):
|
||||
|
||||
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
|
||||
``DataDir\\licenses``). The 30-day replay trial marker is
|
||||
``<data_dir>/replay.lic``.
|
||||
``<data_dir>/replay.lic``. "Removed" licenses are tracked in
|
||||
``<data_dir>/removed_licenses.txt`` (one ``serial-level`` per line) —
|
||||
removal hides a license from the app without deleting its ``.bin``,
|
||||
so the vendor-issued file always stays recoverable on disk.
|
||||
"""
|
||||
|
||||
licensesChanged = Signal()
|
||||
@@ -65,8 +68,12 @@ class LicenseModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def refresh(self) -> None:
|
||||
"""Re-scan the licenses directory."""
|
||||
self._licenses = read_license_files(self._licenses_dir)
|
||||
"""Re-scan the licenses directory, filtering out removed entries."""
|
||||
removed = self._read_removed()
|
||||
self._licenses = [
|
||||
lic for lic in read_license_files(self._licenses_dir)
|
||||
if f"{lic.serial}-{lic.level}" not in removed
|
||||
]
|
||||
self.licensesChanged.emit()
|
||||
|
||||
# ── Load License ──────────────────────────────────────────────────
|
||||
@@ -92,13 +99,61 @@ class LicenseModel(QObject):
|
||||
log.warning("License file failed validation: %s", src.name)
|
||||
return False
|
||||
|
||||
# Skip the copy when src already IS the file in place — e.g. re-picking
|
||||
# a previously-removed license from <data_dir>/licenses/ to un-hide it.
|
||||
# shutil.copyfile raises SameFileError (an OSError) on src == dst.
|
||||
try:
|
||||
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(src, self._licenses_dir / src.name)
|
||||
dst = self._licenses_dir / src.name
|
||||
if src.resolve() != dst.resolve():
|
||||
shutil.copyfile(src, dst)
|
||||
except OSError as exc:
|
||||
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
|
||||
return False
|
||||
|
||||
# An explicit (re)load un-hides the license if it was previously
|
||||
# removed from the app view — the user is deliberately bringing it back.
|
||||
self._discard_removed(lic.serial, lic.level)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
# ── Remove License (app-side only — .bin stays on disk) ────────────
|
||||
@property
|
||||
def _removed_marker(self) -> Path:
|
||||
return self._data_dir / "removed_licenses.txt"
|
||||
|
||||
def _read_removed(self) -> set[str]:
|
||||
try:
|
||||
return set(self._removed_marker.read_text().splitlines())
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
def _discard_removed(self, serial: str, level: str) -> None:
|
||||
entry = f"{serial}-{level}"
|
||||
removed = self._read_removed()
|
||||
if entry not in removed:
|
||||
return
|
||||
removed.discard(entry)
|
||||
try:
|
||||
self._removed_marker.write_text("\n".join(sorted(removed)) + "\n" if removed else "")
|
||||
except OSError as exc:
|
||||
log.warning("Cannot update removed-license marker %s: %s", self._removed_marker, exc)
|
||||
|
||||
@Slot(str, str, result=bool)
|
||||
def removeLicense(self, serial: str, level: str) -> bool:
|
||||
"""Hide license (serial, level) from the app. Leaves the .bin on disk."""
|
||||
if not any(lic.serial == serial and lic.level == level for lic in self._licenses):
|
||||
return False
|
||||
|
||||
entry = f"{serial}-{level}"
|
||||
removed = self._read_removed()
|
||||
removed.add(entry)
|
||||
try:
|
||||
self._removed_marker.write_text("\n".join(sorted(removed)) + "\n")
|
||||
except OSError as exc:
|
||||
log.warning("Cannot update removed-license marker %s: %s", self._removed_marker, exc)
|
||||
return False
|
||||
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
@@ -128,9 +183,17 @@ class LicenseModel(QObject):
|
||||
|
||||
@Slot(result=bool)
|
||||
def startReplayTrial(self) -> bool:
|
||||
"""Create the trial marker (idempotent). False on I/O failure."""
|
||||
"""Create the trial marker if absent. False on I/O failure.
|
||||
|
||||
Must never touch an existing marker: Path.touch(exist_ok=True) bumps
|
||||
mtime even when the file is already there, which would silently push
|
||||
the 30-day clock forward every time this is called on an
|
||||
already-started trial — an infinite-reset hole.
|
||||
"""
|
||||
if self._trial_marker.exists():
|
||||
return True
|
||||
try:
|
||||
self._trial_marker.touch(exist_ok=True)
|
||||
self._trial_marker.touch()
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.warning("Cannot create trial marker: %s", exc)
|
||||
|
||||
@@ -378,12 +378,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
return idx
|
||||
return -1
|
||||
|
||||
# TODO P6.1: build batch export on top of this single-frame export
|
||||
# THINKING: export_image() already does the hard part (grabToImage → PNG);
|
||||
# batch export is just a loop over file_browser.files that loads each CSV
|
||||
# through the existing wafer-map pipeline and calls this per file into a
|
||||
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
|
||||
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
||||
|
||||
@Slot(str, result=bool)
|
||||
@Slot(str, str, result=bool)
|
||||
def export_image(self, file_path: str, extra: str = "") -> bool:
|
||||
@@ -520,13 +515,6 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
||||
return r * 1.05
|
||||
|
||||
# TODO P6.2: reuse this for the edge-to-center delta metric
|
||||
# THINKING: radial_metrics.py (new) needs the same "which sensors are near
|
||||
# the edge vs. near the center" bucketing this function already computes
|
||||
# for ring-line drawing — do NOT re-derive ring boundaries separately, this
|
||||
# already handles round vs. square wafer shapes correctly (see family_spec.py
|
||||
# square-wafer work). Outermost group ≈ edge sensors, innermost ≈ center.
|
||||
# See docs/pending/alpha-release-polish-plan.md §6.2.
|
||||
def _sensor_ring_radii_mm(self) -> list[float]:
|
||||
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||
if not self._sensors:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
|
||||
# docs/pending/license-gating-plan.md). Two cases:
|
||||
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
|
||||
# emits readResult {"error": ...} and spawns no thread.
|
||||
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
|
||||
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
|
||||
# reorders the busy/license checks or renames the "serialNumber" key.
|
||||
# Depends on P1.2 + P2.1 being implemented.
|
||||
@@ -135,6 +135,63 @@ def test_model_load_license_file_url(tmp_path, qapp):
|
||||
assert model.loadLicenseFile("file://" + src) is True
|
||||
|
||||
|
||||
def test_model_remove_license_keeps_bin_on_disk(tmp_path, qapp):
|
||||
(tmp_path / "licenses").mkdir()
|
||||
write_license(tmp_path / "licenses", serial="A00001", level="01")
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert len(model.licenses) == 1
|
||||
|
||||
assert model.removeLicense("A00001", "01") is True
|
||||
assert model.licenses == []
|
||||
# app-side removal only — the vendor-issued .bin is never deleted
|
||||
assert (tmp_path / "licenses" / "A00001-01.bin").exists()
|
||||
|
||||
# sticky across a fresh scan (not just until the next refresh)
|
||||
model.refresh()
|
||||
assert model.licenses == []
|
||||
|
||||
# sticky across a real relaunch — brand new instance, no shared state
|
||||
relaunched = LicenseModel(str(tmp_path))
|
||||
assert relaunched.licenses == []
|
||||
|
||||
|
||||
def test_model_remove_license_missing_returns_false(tmp_path, qapp):
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.removeLicense("Z99999", "00") is False
|
||||
|
||||
|
||||
def test_model_reload_after_remove_unhides(tmp_path, qapp):
|
||||
src_dir = tmp_path / "dl"
|
||||
src_dir.mkdir()
|
||||
src = write_license(src_dir, serial="A00001", level="01")
|
||||
model = LicenseModel(str(tmp_path / "appdata"))
|
||||
assert model.loadLicenseFile(src) is True
|
||||
assert len(model.licenses) == 1
|
||||
|
||||
model.removeLicense("A00001", "01")
|
||||
assert model.licenses == []
|
||||
|
||||
# explicit reload of the same file brings it back
|
||||
assert model.loadLicenseFile(src) is True
|
||||
assert len(model.licenses) == 1
|
||||
|
||||
|
||||
def test_model_reload_in_place_unhides(tmp_path, qapp):
|
||||
"""Un-hiding by re-picking the .bin from its own <data_dir>/licenses/ dir —
|
||||
src and dst resolve to the same file, must not error on the copy step."""
|
||||
model = LicenseModel(str(tmp_path / "appdata"))
|
||||
write_license(tmp_path / "appdata" / "licenses", serial="A00001", level="01")
|
||||
model.refresh()
|
||||
assert len(model.licenses) == 1
|
||||
|
||||
model.removeLicense("A00001", "01")
|
||||
assert model.licenses == []
|
||||
|
||||
in_place = tmp_path / "appdata" / "licenses" / "A00001-01.bin"
|
||||
assert model.loadLicenseFile(str(in_place)) is True
|
||||
assert len(model.licenses) == 1
|
||||
|
||||
|
||||
def test_model_load_rejects_invalid(tmp_path, qapp):
|
||||
src_dir = tmp_path / "dl"
|
||||
src_dir.mkdir()
|
||||
@@ -167,6 +224,23 @@ def test_replay_state_none_then_temporary(tmp_path, qapp):
|
||||
assert model.replayTrialExpired() is False
|
||||
|
||||
|
||||
def test_start_replay_trial_does_not_reset_existing_marker(tmp_path, qapp):
|
||||
"""Repeat calls must not push the clock forward — the original
|
||||
touch(exist_ok=True) bumped mtime on every call, which would let anyone
|
||||
re-trigger the trial start (e.g. an over-limit wafer re-detected) reset
|
||||
the 30-day window indefinitely."""
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.startReplayTrial() is True
|
||||
first_mtime = model._trial_marker.stat().st_mtime
|
||||
|
||||
# Simulate the marker being old (as if most of the trial has elapsed).
|
||||
old_time = first_mtime - 25 * 86400
|
||||
os.utime(model._trial_marker, (old_time, old_time))
|
||||
|
||||
assert model.startReplayTrial() is True
|
||||
assert model._trial_marker.stat().st_mtime == old_time
|
||||
|
||||
|
||||
def test_replay_state_permanent_with_level1(tmp_path, qapp):
|
||||
(tmp_path / "licenses").mkdir()
|
||||
write_license(tmp_path / "licenses", level="01")
|
||||
|
||||
Reference in New Issue
Block a user