diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 7a6e86daa..224dde7bd 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -104,11 +104,14 @@ jobs: New-Item -ItemType Directory -Path 'studio-ui-evidence' -Force | Out-Null Remove-Item Env:ARSTACK_STUDIO_QA_DIR -ErrorAction SilentlyContinue Remove-Item Env:ARSTACK_STUDIO_QA_NATIVE_ONLY -ErrorAction SilentlyContinue - foreach ($scale in @('1', '1.5', '1.75')) { + foreach ($scale in @('1', '1.25', '1.5', '1.75')) { $env:QT_SCALE_FACTOR = $scale $suffix = 'dpi-' + [string][int]([double]::Parse($scale, [System.Globalization.CultureInfo]::InvariantCulture) * 100) $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix New-Item -ItemType Directory -Path $logdir -Force | Out-Null + # Software viewport shots, when grabWindow supports the CI backend. + # Native Windows fidelity is verified separately below. + $env:ARSTACK_STUDIO_QA_DIR = $logdir $stdout = Join-Path $logdir 'geometry-stdout.txt' $stderr = Join-Path $logdir 'geometry-stderr.txt' $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` @@ -136,11 +139,11 @@ jobs: if ($probe.ExitCode -ne 0) { throw "Native Windows screenshot acceptance failed, exit=$($probe.ExitCode)" } - $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x620.png' + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x680.png' if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { - throw 'Native Windows 960x620 screenshot was missing or empty' + throw 'Native Windows 960x680 screenshot was missing or empty' } - Write-Host 'Windows Qt deterministic 3-viewports x 3-DPI geometry + native 960x620 screenshot: PASS' + Write-Host 'Windows Qt deterministic 5-viewports x 4-DPI geometry + native 960x680 screenshot: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c8a46b6e8..d48467244 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -7,10 +7,10 @@ import ARStack.Studio 1.0 ApplicationWindow { id: root - width: 1150 - height: 790 - minimumWidth: 960 - minimumHeight: 620 + width: 1024 + height: 748 + minimumWidth: 940 + minimumHeight: 640 visible: true title: "ARStack Studio · SMV + PTP · v" + Qt.application.version color: studioTheme.bg @@ -432,15 +432,16 @@ ApplicationWindow { function parseOperatorNumber(text) { var normalized = String(text).trim() - .replace(/\s/g, "") .replace(/degrees?/ig, "") .replace(/deg/ig, "") .replace(/°/g, "") .replace(/Hz/ig, "") - .replace(/[AV]$/i, "") + .replace(/[AV]$/i, "").trim() + // Decimal comma and dot are equally valid. Never interpret mixed + // punctuation or thousands-grouping as a silently altered number. if (normalized.indexOf(",") >= 0 && normalized.indexOf(".") < 0) normalized = normalized.replace(",", ".") - if (!normalized.length || normalized === "+" || normalized === "-" || normalized === ".") + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return NaN return Number(normalized) } @@ -575,8 +576,8 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { - // The table editors debounce keyboard edits for live preview. Flush - // them synchronously before creating the ordered board START payload. + // Flush every local draft once before serializing the canonical + // 4I+4V scenario. An invalid draft cannot be sent to the board. if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) { showMessage("Invalid signal input: correct highlighted field before Start.", true) return null @@ -1063,23 +1064,34 @@ ApplicationWindow { SurfacePanel { objectName: "studioInjectionSurface" - SplitView.fillWidth: true SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 540 - SplitView.preferredWidth: root.compactLayout ? 600 : 690 - SplitView.maximumWidth: 750 + SplitView.minimumWidth: 405 + SplitView.preferredWidth: 425 + SplitView.maximumWidth: 465 + SplitView.fillWidth: false theme: studioTheme + // Clicking unoccupied workspace commits the focused cell. + // Controls above this background retain their own taps. + MouseArea { + anchors.fill: parent + onClicked: root.contentItem.forceActiveFocus() + } + ColumnLayout { + z: 1 anchors.fill: parent - anchors.margins: root.compactLayout ? 11 : 13 - spacing: root.compactLayout ? 6 : 8 + // Injection width stays 405..465px at every desktop size. + // Never expand its children just because the whole window + // crosses the 1220px toolbar breakpoint. + anchors.margins: 10 + spacing: 6 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 1 - Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } + Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } // START owns the single ordered scenario transaction. @@ -1140,7 +1152,7 @@ ApplicationWindow { } theme: studioTheme monoFont: root.monoFont - compact: root.compactLayout + compact: true implicitWidth: 90 text: "50.000" suffixText: "Hz" @@ -1168,10 +1180,6 @@ ApplicationWindow { } } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "0"; implicitWidth: 44; toolTipText: "DC"; onClicked: root.setFrequencyValue(0) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } StudioLinkSwitch { theme: studioTheme uiFont: root.uiFont @@ -1221,7 +1229,7 @@ ApplicationWindow { titleText: "Voltage" symbolText: "U" unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" - compact: root.compactLayout + compact: true } SignalMatrix { id: currentMatrix @@ -1242,7 +1250,7 @@ ApplicationWindow { titleText: "Current" symbolText: "I" unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" - compact: root.compactLayout + compact: true } } @@ -1258,23 +1266,18 @@ ApplicationWindow { session: workflowBar.session uiFont: root.uiFont monoFont: root.monoFont - compact: root.compactLayout + compact: true } Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } RowLayout { objectName: "studioQualityRow" Layout.fillWidth: true - spacing: 8 + spacing: 6 Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } - Label { - text: root.signalFrequency === 0 - ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" - : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - } + // The same magnitude/phase is already visible in + // the active matrix row and plots. Removing this + // duplicate avoids squeezing the quality controls. Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } QualityEditor { enabled: !workflowBar.startQueued @@ -1285,7 +1288,7 @@ ApplicationWindow { uiFont: root.uiFont monoFont: root.monoFont qualityValue: root.activeQuality - compact: root.compactLayout + compact: true } } // No trailing filler: vertically stacked matrices @@ -1296,9 +1299,9 @@ ApplicationWindow { SplitView { visible: (root.phasorDockVisible && !root.phasorDetached) || (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 450 : 470 - SplitView.minimumWidth: root.compactLayout ? 300 : 340 - SplitView.maximumWidth: 680 + SplitView.preferredWidth: 560 + SplitView.minimumWidth: 390 + SplitView.fillWidth: true SplitView.fillHeight: true orientation: Qt.Vertical handle: Rectangle { @@ -1315,11 +1318,11 @@ ApplicationWindow { } DockFrame { + objectName: "studioPhasorDock" visible: root.phasorDockVisible && !root.phasorDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 225 - SplitView.maximumHeight: 265 + SplitView.preferredHeight: Math.max(170, (workspaceSplit.height - 9) / 2) SplitView.fillHeight: false theme: studioTheme titleText: "Phasor" @@ -1355,10 +1358,11 @@ ApplicationWindow { } DockFrame { + objectName: "studioWaveformDock" visible: root.waveformDockVisible && !root.waveformDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 328 + SplitView.preferredHeight: Math.max(170, (workspaceSplit.height - 9) / 2) SplitView.fillHeight: true theme: studioTheme titleText: "Waveform" diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml index 73aef4c91..031f7455f 100644 --- a/apps/arstack_studio/qml/QualityEditor.qml +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -103,7 +103,7 @@ RowLayout { theme: editor.theme uiFont: editor.uiFont id: presetBox - implicitWidth: editor.compact ? 98 : 108 + implicitWidth: editor.compact ? 90 : 108 model: ["Good", "Invalid", "Questionable", "Test"] currentIndex: editor.presetIndex(editor.qualityValue) font.family: editor.uiFont @@ -116,7 +116,7 @@ RowLayout { theme: editor.theme monoFont: editor.monoFont compact: true - implicitWidth: editor.compact ? 100 : 112 + implicitWidth: editor.compact ? 98 : 112 Component.onCompleted: text = editor.hexText(editor.qualityValue) onEditingFinished: { var trimmed = text.trim() diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 20477ffd8..b7bc89a41 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -27,9 +27,8 @@ Rectangle { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] } - // Commit any last keystroke synchronously before constructing an ordered - // START scenario. The 90ms edit debounce must never make the board - // receive old angles while the table already displays new ones. + // Flush final local drafts before START. Editing itself never writes to + // the board or mutates the scenario until Enter/Tab/focus-out. function flushPendingEdits() { for (var i = 0; i < rowRepeater.count; ++i) { const row = rowRepeater.itemAt(i) @@ -130,25 +129,44 @@ Rectangle { property bool selected: matrix.controller.activeSignal === signalRow.sid property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField - property real pendingMagnitude: mag - property real pendingPhase: angle + property bool magnitudeDirty: false + property bool phaseDirty: false + + // Idempotent commit: onEditingFinished and focus loss may fire + // for the same gesture, but only one model/board mutation occurs. + function commitRowField(column) { + const magnitude = column === 0 + const field = magnitude ? magnitudeField : phaseField + if (!(magnitude ? magnitudeDirty : phaseDirty)) + return !field.invalidInput + const value = matrix.controller.parseOperatorNumber(field.text) + const valid = magnitude + ? matrix.controller.validMagnitude(matrix.groupIndex, value) + : matrix.controller.validPhase(value) + if (!valid) { + field.invalidInput = true + return false // keep the draft visible for correction + } + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, + magnitude ? "magnitude" : "phase", value)) { + field.invalidInput = true + return false + } + if (magnitude) magnitudeDirty = false + else phaseDirty = false + field.invalidInput = false + field.text = value.toFixed(magnitude ? 3 : 2) + return true + } function flushPendingEdits() { - if (magnitudeApplyTimer.running || magnitudeField.activeFocus) { - const magValue = matrix.controller.parseOperatorNumber(magnitudeField.text) - if (!matrix.controller.validMagnitude(matrix.groupIndex, magValue)) - return false - magnitudeApplyTimer.stop() - if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "magnitude", magValue)) - return false + if (!commitRowField(0)) { + matrix.focusCell(rowIndex, 0) + return false } - if (phaseApplyTimer.running || phaseField.activeFocus) { - const phaseValue = matrix.controller.parseOperatorNumber(phaseField.text) - if (!matrix.controller.validPhase(phaseValue)) - return false - phaseApplyTimer.stop() - if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "phase", phaseValue)) - return false + if (!commitRowField(1)) { + matrix.focusCell(rowIndex, 1) + return false } return true } @@ -237,21 +255,8 @@ Rectangle { HoverHandler { id: rowHover } - Timer { - id: magnitudeApplyTimer - interval: 90 - repeat: false - onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", signalRow.pendingMagnitude) - } - Timer { - id: phaseApplyTimer - interval: 90 - repeat: false - onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", signalRow.pendingPhase) - } - - onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) - onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) + onMagChanged: if (!magnitudeField.activeFocus && !magnitudeDirty) magnitudeField.text = mag.toFixed(3) + onAngleChanged: if (!phaseField.activeFocus && !phaseDirty) phaseField.text = angle.toFixed(2) Rectangle { visible: signalRow.selected @@ -305,32 +310,16 @@ Rectangle { onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - text = signalRow.mag.toFixed(3) + if (!signalRow.magnitudeDirty) text = signalRow.mag.toFixed(3) selectAll() - } + } else signalRow.commitRowField(0) } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { - invalidInput = false - signalRow.pendingMagnitude = value - magnitudeApplyTimer.restart() - } else invalidInput = true - } - onEditingFinished: { - var value = matrix.controller.parseOperatorNumber(text) - if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { - magnitudeApplyTimer.stop() - text = signalRow.mag.toFixed(3) - invalidInput = false - matrix.controller.showMessage(signalRow.sid + " value is outside the supported range.", true) - } else { - signalRow.pendingMagnitude = value - magnitudeApplyTimer.stop() - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) - text = value.toFixed(3) - } + signalRow.magnitudeDirty = true + invalidInput = !matrix.controller.validMagnitude( + matrix.groupIndex, matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitRowField(0) Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -340,7 +329,8 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + if (signalRow.commitRowField(0)) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) event.accepted = true } } @@ -368,32 +358,16 @@ Rectangle { onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - text = signalRow.angle.toFixed(2) + if (!signalRow.phaseDirty) text = signalRow.angle.toFixed(2) selectAll() - } + } else signalRow.commitRowField(1) } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validPhase(value)) { - invalidInput = false - signalRow.pendingPhase = value - phaseApplyTimer.restart() - } else invalidInput = true - } - onEditingFinished: { - var value = matrix.controller.parseOperatorNumber(text) - if (!matrix.controller.validPhase(value)) { - phaseApplyTimer.stop() - text = signalRow.angle.toFixed(2) - invalidInput = false - matrix.controller.showMessage(signalRow.sid + " phase is outside the supported range.", true) - } else { - signalRow.pendingPhase = value - phaseApplyTimer.stop() - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) - text = value.toFixed(2) - } + signalRow.phaseDirty = true + invalidInput = !matrix.controller.validPhase( + matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitRowField(1) Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -403,7 +377,8 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + if (signalRow.commitRowField(1)) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) event.accepted = true } } diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index 906dbd87d..2178635cf 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -72,7 +72,7 @@ Rectangle { // This is an action strip, not a second diagnostic/status card. RowLayout { Layout.fillWidth: true - spacing: tray.compact ? 7 : 10 + spacing: tray.compact ? 5 : 10 StudioPtpSwitch { id: powerSwitch objectName: "studioPtpSwitch" @@ -112,7 +112,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true - Layout.minimumWidth: 118 + Layout.minimumWidth: tray.compact ? 102 : 118 model: ["Source", "Follower", "Monitor"] currentIndex: tray.device && tray.device.ptpRunning ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex @@ -140,7 +140,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true - Layout.minimumWidth: 100 + Layout.minimumWidth: tray.compact ? 84 : 100 model: ["AUTO", "0 · No sync", "1 · Local (sim)", "2 · Global (sim)"] currentIndex: tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch @@ -169,10 +169,15 @@ Rectangle { CalmButton { theme: tray.theme uiFont: tray.uiFont - text: "Details" + text: "" iconSource: Qt.resolvedUrl("../assets/material/settings.svg") + iconSize: 16 objectName: "studioTimingDetails" - implicitWidth: 66 + implicitWidth: 34 + implicitHeight: 32 + toolTipText: "Time sync & SV lock details" + Accessible.name: "Time sync & SV lock details" + Accessible.description: "Open PTP and SV synchronization settings" onClicked: if (tray.controller) tray.controller.openTimingSettings() } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 3295cbe9d..91eb99333 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -129,8 +129,12 @@ SurfacePanel { category: "operator-ui" property string lastEngineeringUrl: "" property int lastSelectedIndex: 0 - property bool phasorVisible: false - property bool waveformVisible: false + property bool phasorVisible: true + property bool waveformVisible: true + // Earlier builds silently started with both charts hidden. Bump the + // dock preference schema once so existing installations also regain + // the operator's essential phasor and waveform workspaces. + property int dockLayoutVersion: 0 property bool telemetryVisible: true property bool phasorDetached: false property bool waveformDetached: false @@ -145,6 +149,13 @@ SurfacePanel { } Component.onCompleted: { + if (operatorSettings.dockLayoutVersion < 2) { + operatorSettings.phasorVisible = true + operatorSettings.waveformVisible = true + operatorSettings.phasorDetached = false + operatorSettings.waveformDetached = false + operatorSettings.dockLayoutVersion = 2 + } controller.phasorDockVisible = operatorSettings.phasorVisible controller.waveformDockVisible = operatorSettings.waveformVisible controller.telemetryDockVisible = operatorSettings.telemetryVisible @@ -414,7 +425,7 @@ SurfacePanel { controller.phasorDetached = false controller.waveformDetached = false controller.phasorDockVisible = true - controller.waveformDockVisible = false + controller.waveformDockVisible = true controller.telemetryDockVisible = true controller.telemetryExpanded = false controller.showMessage("Dock layout reset.", false) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 2663d7d3a..71056ebff 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -80,12 +80,14 @@ class DeterministicSessionHarness final { {"same COM firmware reboot invalidates old START and profile authority", samePortFirmwareRebootResetsAuthority()}, {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, - {"binary PROFILE excludes PTP/SHOW/SET and buffers live setpoints", binaryProfileReservesSerialConsole()}, + {"binary PROFILE coalesces PTP/SHOW without internal toast", binaryProfileReservesSerialConsole()}, + {"PTP STOP during exclusive CFG/BIN defers proof, never invents OFF", ptpStopReadbackDeferredDuringExclusive()}, {"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()}, {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, + {"CFG transport exclusivity cannot trigger false health loss", healthProbeDefersDuringScenario()}, {"control health misses -> bounded failure", healthTimeoutIsBounded()}, {"worker teardown -> joins cleanly", workerTeardownCompletes()}, }; @@ -1139,8 +1141,10 @@ class DeterministicSessionHarness final { if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; auto& d = f.device; if (!d.stopPtp()) return false; + d.ptpRunning_ = true; // preserve RUN until the new STOP has proof d.processLine(ptpStoppedStatus()); // stale SHOW reply, before STOP ACK - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpRunning_) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=0 PdelayFrames=0 TXfail=0")); @@ -1543,15 +1547,57 @@ class DeterministicSessionHarness final { d.profileDeploying_ = true; d.profileArmed_ = false; d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; - const bool ptpBlocked = !d.sendPtpShow(); - const bool showBlocked = !d.sendShow(); + const QString initialError = d.lastError_; + const bool ptpDeferred = d.sendPtpShow(); + const bool showDeferred = d.sendShow(); + const bool coalesced = d.sendPtpShow() && d.sendShow() && + d.deferredPtpShow_ && d.deferredShow_ && + d.lastError_ == initialError; const bool setBlocked = !d.setEnabled(QStringLiteral("IA"), true); d.frequencyPending_ = true; d.pendingFrequencyHz_ = 60.0; - const bool deferred = d.flushLiveCommands() && d.frequencyPending_; + const bool deferredMutation = d.flushLiveCommands() && d.frequencyPending_; d.liveFlushTimer_.stop(); - return ptpBlocked && showBlocked && setBlocked && deferred && - d.lastError_.contains(QStringLiteral("owns the device console")); + // Busy telemetry is normal, but an unsafe mutation remains rejected. + return ptpDeferred && showDeferred && coalesced && setBlocked && + deferredMutation && d.lastError_.contains(QStringLiteral("being prepared")) && + !d.lastError_.contains(QStringLiteral("owns the device console")); + } + + static bool ptpStopReadbackDeferredDuringExclusive() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.ptpAvailable_ = true; + d.ptpRunning_ = true; + d.ioWorkerReady_ = true; + d.profileDeploying_ = true; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + const QString previousError = d.lastError_; + if (!d.stopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback || + !d.deferredPtpShow_ || !d.ptpRunning_ || d.lastError_ != previousError) + return false; + d.processLine(ptpStoppedStatus()); // stale status before fresh readback + if (!d.ptpRunning_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + // When the lock is released, exactly one readback is submitted to + // the existing serial owner; no readback is accepted as STOP proof. + d.profileDeploying_ = false; + d.resetBinaryProfileTransfer(); + d.flushDeferredReadbacks(); + if (d.deferredPtpShow_ || !d.ptpRunning_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + d.markPtpStopUnconfirmed(QStringLiteral("Injected STOP timeout: Copy diagnostics.")); + return d.ptpRunning_ && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + d.lastError_.contains(QStringLiteral("Copy diagnostics.")); } static bool serialPacingDoesNotRemoveSafetySlot() { @@ -1633,6 +1679,26 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + static bool healthProbeDefersDuringScenario() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.controlResponsive_ = true; + d.healthProbeOutstanding_ = true; + d.missedHealthReplies_ = 0; + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.serviceControlHealth(); + d.serviceControlHealth(); + // Known exclusive queue occupancy cannot mimic two missing COM replies. + const bool quietBusy = d.controlResponsive_ && d.healthProbeOutstanding_ && + d.missedHealthReplies_ == 0 && d.connected_; + d.scenarioStage_ = DeviceController::ScenarioStage::idle; + d.serviceControlHealth(); + return quietBusy && !d.controlResponsive_ && + d.missedHealthReplies_ == 1; // real timeout remains detectable + } + static bool healthTimeoutIsBounded() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 926083fa0..94dbf721a 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -144,9 +144,17 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] { if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback && !sendPtpShow()) { - markPtpStopUnconfirmed(QStringLiteral("Could not verify board PTP STOP status.")); + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP could not be verified from the board. Check USB connection / Copy diagnostics.")); } }); + // Retry a coalesced readback once after transport authority is released. + const auto drainDeferred = [this] { + if (deferredShow_ || deferredProfileShow_ || deferredPtpShow_) + QTimer::singleShot(0, this, [this] { flushDeferredReadbacks(); }); + }; + connect(this, &DeviceController::profileStateChanged, this, drainDeferred); + connect(this, &DeviceController::scenarioStateChanged, this, drainDeferred); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); @@ -482,6 +490,9 @@ void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; resetPtpStartup(); resetScenarioStart(); + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -659,6 +670,9 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; resetBinaryProfileTransfer(); resetScenarioStart(); resetPtpState(); @@ -687,6 +701,9 @@ void DeviceController::handlePortClosed(const QString& portName) { profileDeploying_ = false; profileArmed_ = false; profileWasReused_ = false; + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; resetBinaryProfileTransfer(); deviceVerified_ = false; clearIdentity(); @@ -1370,7 +1387,9 @@ bool DeviceController::beginPtpStopVerification() { markPtpStopUnconfirmed(QStringLiteral("Could not queue safety PTP STOP.")); return false; } - ptpStartTimer_.start(5000); + // Absolute STOP readback deadline; STOP itself uses the worker safety lane. + ptpStartTimer_.start(profileDeploying_ || scenarioStage_ != ScenarioStage::idle + ? 12000 : 5000); return true; } @@ -1493,16 +1512,20 @@ void DeviceController::advancePtpStartup(const QString& line) { ptpStatus_ = QStringLiteral("Checking that board PTP is stopped..."); emit ptpStateChanged(); if (!sendPtpShow()) { - markPtpStopUnconfirmed(QStringLiteral("Could not request PTP STOP readback.")); + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP readback could not be queued. Check USB connection / Copy diagnostics.")); return; } - ptpStartTimer_.start(5000); + // Preserve the absolute deadline from STOP dispatch; do not + // restart it on ACK while another transaction owns the console. } return; } if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { const auto readback = kPtpStatusExpression.match(line); - if (!readback.hasMatch()) return; + // A queued pre-STOP SHOW can arrive while post-STOP readback is + // still deferred. It cannot verify the requested STOP. + if (!readback.hasMatch() || deferredPtpShow_) return; if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { resetPtpStartup(); ptpRunning_ = false; @@ -1684,6 +1707,21 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { + const bool single = !exclusive && commands.size() == 1; + const QString onlyCommand = single ? commands.constFirst() : QString{}; + const bool readback = onlyCommand == QStringLiteral("SHOW") || + onlyCommand == QStringLiteral("PROFILE SHOW") || + onlyCommand == QStringLiteral("PTP SHOW"); + const auto deferReadback = [this, &onlyCommand] { + bool* pending = onlyCommand == QStringLiteral("SHOW") ? &deferredShow_ : + onlyCommand == QStringLiteral("PROFILE SHOW") ? &deferredProfileShow_ : &deferredPtpShow_; + if (!*pending) { + *pending = true; + appendLog(QStringLiteral("·"), + QStringLiteral("Coalesced %1 until exclusive serial authority is released.").arg(onlyCommand)); + } + return true; + }; if (profileDeploying_) { const bool exactlyOne = !exclusive && commands.size() == 1; const QString command = exactlyOne ? commands.constFirst() : QString{}; @@ -1694,9 +1732,8 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool const bool safety = command == QStringLiteral("STOP") || command == QStringLiteral("PTP STOP"); if (!exactlyOne || (!protocolReply && !safety)) { - setError(QStringLiteral( - "Canonical binary profile transaction owns the device console; " - "concurrent polling and live mutations were deferred/rejected.")); + if (readback) return deferReadback(); + setError(QStringLiteral("Device profile is being prepared; wait before changing live setpoints.")); return false; } } @@ -1707,11 +1744,11 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool (commands.constFirst() == QStringLiteral("STOP") || commands.constFirst() == QStringLiteral("PTP STOP") || commands.constFirst() == QStringLiteral("HEARTBEAT") || - commands.constFirst() == QStringLiteral("SHOW") || commands.constFirst().startsWith(QStringLiteral("CFG SHOW ")) || commands.constFirst().startsWith(QStringLiteral("START "))); if (!stagingBatch && !safeCommand) { - setError(QStringLiteral("SV transaction pending: concurrent configuration rejected.")); + if (readback) return deferReadback(); + setError(QStringLiteral("SV configuration is being verified; wait before changing live setpoints.")); return false; } } @@ -1731,6 +1768,24 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool return queued; } +void DeviceController::flushDeferredReadbacks() { + if (profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + scenarioStage_ != ScenarioStage::idle || + !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) + return; + // At most one of each telemetry/readback type. No competing serial writer. + if (std::exchange(deferredPtpShow_, false)) { + if (!sendQuietCommand(QStringLiteral("PTP SHOW")) && + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP readback could not be sent. Check USB connection / Copy diagnostics.")); + } + if (std::exchange(deferredShow_, false)) + static_cast(sendQuietCommand(QStringLiteral("SHOW"))); + if (std::exchange(deferredProfileShow_, false)) + static_cast(sendQuietCommand(QStringLiteral("PROFILE SHOW"))); +} + void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; const quint64 generation = sessionGeneration_; @@ -2110,6 +2165,9 @@ void DeviceController::processLine(const QString& rawLine) { if (newBoot) { resetScenarioStart(); resetBinaryProfileTransfer(); + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; @@ -2145,13 +2203,22 @@ void DeviceController::processLine(const QString& rawLine) { match = kPtpStatusExpression.match(line); if (match.hasMatch()) { ptpAvailable_ = true; - ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + const bool verifyingStop = ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback; + // A pending STOP is not STOPPED. Ignore unsolicited/stale SHOW state + // while waiting for the fresh post-STOP verification request. The + // stop state machine below is the only writer of verified STOPPED. + if (!verifyingStop) { + ptpRunning_ = match.captured(1).compare( + QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : + QStringLiteral("Lab TX stopped"); + } ptpAnnounceSent_ = match.captured(2); ptpSyncSent_ = match.captured(3); ptpFollowUpSent_ = match.captured(4); ptpPdelayFrames_ = match.captured(5); ptpTxFailures_ = match.captured(6); - ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : QStringLiteral("Lab TX stopped"); emit ptpStateChanged(); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 0402b896a..c0244f32b 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -394,6 +394,7 @@ class DeviceController : public QObject { void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); bool beginPtpStopVerification(); + void flushDeferredReadbacks(); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); @@ -435,6 +436,10 @@ class DeviceController : public QObject { bool profileArmed_{false}; bool profileWasReused_{false}; bool profileDeploying_{false}; + // One coalesced readback of each kind; no second serial writer or retry loop. + bool deferredShow_{false}; + bool deferredProfileShow_{false}; + bool deferredPtpShow_{false}; BinaryProfileStage binaryProfileStage_{BinaryProfileStage::idle}; BinaryProfileTransfer binaryProfile_{}; bool ptpAvailable_{false}; diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 01db9a8ce..a10be566c 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -252,10 +252,11 @@ class StudioDeviceController : public DeviceController { return; } - // Profile deployment is an exclusive transport transaction. Health - // observation is deferred instead of interleaving another command or - // treating intentional queue exclusivity as a missed response. - if (profileDeploying()) return; + // Canonical BIN and CFG scenario transactions both own the serial + // console. Suppress only probe accounting during known exclusivity, + // never the worker's independent safety heartbeat/STOP. Resume + // measured liveness normally as soon as the transaction completes. + if (profileDeploying() || scenarioApplying()) return; if (healthProbeOutstanding_) { ++missedHealthReplies_; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index a8320ac1a..d893e1937 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include @@ -69,6 +70,8 @@ struct UiBounds { const auto current = findUiBounds(window, "studioCurrentMatrix"); const auto tray = findUiBounds(window, "studioTimingTray"); const auto quality = findUiBounds(window, "studioQualityRow"); + const auto phasor = findUiBounds(window, "studioPhasorDock"); + const auto waveform = findUiBounds(window, "studioWaveformDock"); const std::array, 6> areas{{ {"toolbar", bar}, {"injection workspace", surface}, {"voltage matrix", voltage}, {"current matrix", current}, @@ -84,6 +87,33 @@ struct UiBounds { return false; } } + // Real Windows evidence must contain both actual plots. Checking only + // the left table let a 100%-empty right workspace pass every DPI gate. + // Report visible state + bounds to distinguish hidden from zero-size docks. + if (surface.area.width() > 470 || + !phasor.visible || !waveform.visible || + phasor.area.width() < 180 || waveform.area.width() < 180 || + phasor.area.height() < 170 || waveform.area.height() < 170 || + std::abs(phasor.area.height() - waveform.area.height()) > 24) { + qCritical().noquote() << "Studio split width/chart balance violation" << size + << "surface=" << surface.area + << "phasor-visible=" << phasor.visible << "phasor=" << phasor.area + << "wave-visible=" << waveform.visible << "wave=" << waveform.area; + return false; + } + // A child can pass the viewport test while overflowing its *parent* + // (small controls were clipped by the neighbouring plot at 1366px). + for (const auto& [label, item] : std::array, 4>{{ + {"voltage", voltage}, {"current", current}, + {"timing", tray}, {"quality", quality} + }}) { + if (!surface.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, -tolerance, -tolerance))) { + qCritical().noquote() << "Studio injection child overflows parent" << size + << label << item.area << surface.area; + return false; + } + } // Operator layout: voltage above current. Validate the new actual // vertical hierarchy, not the obsolete side-by-side geometry. if (!(voltage.area.bottom() <= current.area.top() + tolerance @@ -100,7 +130,8 @@ struct UiBounds { qreal previousRight = tray.area.left(); for (const char* name : controls) { const auto control = findUiBounds(window, name); - if (!control.visible || control.area.width() < 35 || + const qreal minimumWidth = std::string_view{name} == "studioTimingDetails" ? 30.0 : 35.0; + if (!control.visible || control.area.width() < minimumWidth || control.area.left() + tolerance < previousRight || !tray.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) .contains(control.area.adjusted(tolerance, tolerance, @@ -631,16 +662,15 @@ int main(int argc, char* argv[]) { } // Exercise desktop, supported minimum, and extended desktop layout on // the real rendering engine. This branch never touches a serial port. - constexpr std::array sizes{{QSize{1240, 760}, QSize{960, 620}, - QSize{1440, 900}}}; - // Virtual Qt platforms can resize to arbitrary logical viewports, - // but cannot support grabWindow. One native 960x620 pass captures - // actual Windows pixels separately without requesting an oversized - // desktop window. + constexpr std::array sizes{{QSize{960, 680}, QSize{1024, 748}, + QSize{1152, 760}, QSize{1366, 800}, QSize{1920, 1000}}}; + // All five logical viewport geometries are exercised by Qt Quick. + // One smaller native viewport captures real Windows pixels; optional + // software screenshots of larger viewports carry no native claim. const bool nativeCapture = !qEnvironmentVariableIsEmpty("ARSTACK_STUDIO_QA_NATIVE_ONLY"); const int caseCount = nativeCapture ? 1 : static_cast(sizes.size()); const auto caseSize = [sizes, nativeCapture](const int step) { - return nativeCapture ? sizes[1] : sizes.at(static_cast(step)); + return nativeCapture ? sizes[0] : sizes.at(static_cast(step)); }; const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); if (!screenshots.isEmpty() && !QDir{}.mkpath(screenshots)) { @@ -680,10 +710,14 @@ int main(int argc, char* argv[]) { QStringLiteral("studio-%1x%2.png").arg(target.width()).arg(target.height())); const QImage frame = mainWindow->grabWindow(); if (frame.isNull() || !frame.save(path)) { - qCritical().noquote() << "GUI evidence capture failed:" << path; - uiTimer->stop(); - QCoreApplication::exit(22); - return; + if (nativeCapture) { + qCritical().noquote() << "Native GUI evidence capture failed:" << path; + uiTimer->stop(); + QCoreApplication::exit(22); + return; + } + qWarning().noquote() << "Offscreen screenshot unavailable:" << path + << "(viewport bounds still checked)"; } } ++*step; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index aa459e8ae..e54123a1a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -195,13 +195,29 @@ def main() -> None: and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml") and 'startWatchdog' not in source("WorkflowBar.qml"), "SV STOP must remain actionable while the acknowledged scenario transaction is pending") + # A prior operator-ui QSettings default hid both plot docks on every new + # installation. This passed the geometry test because absent plots were + # optional. Both must now be visible on fresh installs and migrated once. + dock_flow = source("WorkflowBar.qml") + require('property bool phasorVisible: true' in dock_flow + and 'property bool waveformVisible: true' in dock_flow + and 'property int dockLayoutVersion: 0' in dock_flow + and 'operatorSettings.dockLayoutVersion < 2' in dock_flow + and 'operatorSettings.dockLayoutVersion = 2' in dock_flow + and 'controller.waveformDockVisible = true' in dock_flow + and 'objectName: "studioPhasorDock"' in main_qml + and 'objectName: "studioWaveformDock"' in main_qml, + "both chart docks must display on clean startup and migrate once from legacy hidden defaults") require("function flushPendingEdits()" in matrix - and "magnitudeApplyTimer.stop()" in matrix - and "phaseApplyTimer.stop()" in matrix + and "function commitRowField(column)" in matrix + and "magnitudeApplyTimer" not in matrix + and "phaseApplyTimer" not in matrix + and "onEditingFinished: signalRow.commitRowField(0)" in matrix + and "onEditingFinished: signalRow.commitRowField(1)" in matrix and "currentMatrix.flushPendingEdits()" in main_qml and "voltageMatrix.flushPendingEdits()" in main_qml and "if (!scenario) return" in source("WorkflowBar.qml"), - "START must commit last visible I/V angle before ordered scenario serialization") + "START must flush each operator draft once, never after a 90ms typing timer") require("Layout.fillHeight: true" in main_qml and "Layout.maximumHeight: root.compactLayout ? 352 : 384" not in main_qml and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), @@ -377,9 +393,9 @@ def main() -> None: assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview assert "No ESP32 board detected · Preview mode only" in main_qml require("Layout.preferredHeight: 57" in main_qml - and "SplitView.maximumHeight: 265" in main_qml - and "SplitView.fillHeight: false" in main_qml, - "compact timing strip and bounded phasor panel must avoid dead space") + and "SplitView.maximumHeight: 265" not in main_qml + and 'Math.max(170, (workspaceSplit.height - 9) / 2)' in main_qml, + "compact timing strip must share height evenly between phasor and waveform") require("TIME SYNC / SV SIMULATION" not in tray and "StateBadge {" not in tray, "device/PTP status must not be duplicated in the control tray") @@ -543,9 +559,15 @@ def main() -> None: responsive_main = source("Main.qml") ribbon_layout = source("ModernRibbon.qml") start_flow = source("WorkflowBar.qml") - require("width: 1150" in responsive_main - and "SplitView.maximumWidth: 750" in responsive_main - and "SplitView.preferredWidth: root.compactLayout ? 450 : 470" in responsive_main + require("width: 1024" in responsive_main + and "height: 748" in responsive_main + and "SplitView.maximumWidth: 465" in responsive_main + and "SplitView.preferredWidth: 425" in responsive_main + and "SplitView.fillWidth: false" in responsive_main + and "SplitView.minimumWidth: 390" in responsive_main + and 'text: "0"; implicitWidth: 44' not in responsive_main + and 'text: "50"; implicitWidth: 44' not in responsive_main + and 'text: "60"; implicitWidth: 44' not in responsive_main and "electric_bolt_run.svg" in ribbon_layout and "electric_bolt_stop.svg" in ribbon_layout and "assets/material/electric_bolt_run.svg" in @@ -601,6 +623,29 @@ def main() -> None: "PTP SOURCE physical RUNNING must survive unqualified clock freshness; " "canonical timing authority must remain independently fail-closed") + # The GUI's normal transient conditions are never shown as operator faults. + # Validate both intended (positive) paths and removed (negative) paths. + require("Coalesced %1 until exclusive serial authority is released." in device_cpp + and "flushDeferredReadbacks()" in device_cpp + and "std::exchange(deferredPtpShow_, false)" in device_cpp + and "if (readback) return deferReadback();" in device_cpp + and "Canonical binary profile transaction owns" not in device_cpp + and "Could not request PTP STOP readback." not in device_cpp + and "ptpStartTimer_.start(profileDeploying_" in device_cpp, + "busy device polling must defer quietly; verified STOP has an absolute deadline") + require('toolTipText: "Time sync & SV lock details"' in timing_ui + and 'Accessible.name: "Time sync & SV lock details"' in timing_ui + and 'objectName: "studioTimingDetails"' in timing_ui + and 'text: "Details"' not in timing_ui, + "PTP Details must be an accessible icon-only button") + require("signalRow.magnitudeDirty = true" in matrix + and "signalRow.phaseDirty = true" in matrix + and "matrix.controller.editSignal(" in matrix + and "field.invalidInput = true" in matrix + and "return false // keep the draft visible" in matrix + and "!/^[+-]?" in main_qml, + "numeric editors must validate local draft and commit only once at edit completion") + print("Studio operator workspace contract: PASS")