diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index fb92d1db8..63345e5a7 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -54,10 +54,12 @@ jobs: run: | cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' } - cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness --parallel 2 + cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness --parallel 2 if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' } & "$PWD\build-studio-preview\Release\arstack_session_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } + & "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'Native clipboard readback regression failed' } # Differentiate product code regressions from missing deployed DLLs. $nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' $probe = Start-Process -FilePath $nativeGui -ArgumentList '--check-reference-template' -Wait -PassThru diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index afd4ccb0a..97cbf821d 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -81,7 +81,7 @@ jobs: shell: powershell run: | cmake --build build-arstack-studio --config Release ` - --target arstack_session_harness arstack_windows_ownership_harness --parallel + --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness --parallel - name: S8A deterministic session harness on Windows timeout-minutes: 1 @@ -98,6 +98,16 @@ jobs: throw "S8A Windows deterministic harness failed with exit code $exitCode." } + - name: Native Windows clipboard roundtrip regression + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_clipboard_harness.exe' + & $exe + if ($LASTEXITCODE -ne 0) { + throw "Native Windows OS clipboard readback failed: exit $LASTEXITCODE" + } + - name: S8B cross-process ownership and serial-loss harness timeout-minutes: 1 shell: powershell @@ -216,7 +226,7 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness arstack_capture_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression @@ -240,6 +250,14 @@ jobs: timeout 20s ./build-arstack-studio/arstack_session_harness \ 2>&1 | tee /tmp/arstack-studio-session-harness.log + - name: Native GUI clipboard content/readback regression + shell: bash + run: | + set -o pipefail + timeout 15s env QT_QPA_PLATFORM=offscreen \ + ./build-arstack-studio/arstack_clipboard_harness \ + 2>&1 | tee /tmp/arstack-studio-clipboard-harness.log + - name: Native SV PCAP capture and CSV decoder regression shell: bash run: | diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 4fa8163e1..993df310e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -55,6 +55,16 @@ qt_add_executable(arstack_session_harness ) # Offline production wire decoder regression (no Npcap or hardware required). +# Isolated platform GUI clipboard verification (same source linked in Studio). +# No QML stubs, no fake service: tests actual QGuiApplication OS clipboard. +qt_add_executable(arstack_clipboard_harness + src/ClipboardHarness.cpp + src/StudioClipboard.cpp + src/StudioClipboard.hpp +) +target_link_libraries(arstack_clipboard_harness PRIVATE Qt6::Core Qt6::Gui) +target_compile_features(arstack_clipboard_harness PRIVATE cxx_std_20) + qt_add_executable(arstack_capture_harness src/SvCaptureHarness.cpp src/SvCaptureController.cpp diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 552ba4f11..1fb9741fc 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -12,6 +12,7 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool technicalDetailsVisible: false + property string copyFeedback: "" readonly property string firmwareDisplayStatus: panel.session && panel.session.firmwareUpdateResult.length > 0 && !panel.session.updatingFirmware @@ -298,7 +299,21 @@ SurfacePanel { text: "Copy diagnostics" toolTipText: "Logs may include device identifiers; review before sharing." enabled: panel.session !== null - onClicked: StudioClipboard.copyText(panel.session.diagnosticsText()) + onClicked: { + const evidence = panel.session.diagnosticsText() + if (StudioClipboard.copyText(evidence)) { + panel.copyFeedback = "Firmware diagnostics copied." + } else { + panel.copyFeedback = "Clipboard unavailable. Open technical details to review diagnostics." + } + } + } + Label { + visible: panel.copyFeedback.length > 0 + text: panel.copyFeedback + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 } Item { Layout.fillWidth: true } } diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 37b259886..0e325a96e 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 SurfacePanel { id: panel @@ -12,6 +13,7 @@ SurfacePanel { property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] property bool advancedVisible: false + property string diagnosticFallbackText: "" readonly property bool startPending: panel.device && panel.device.ptpStarting function roleIndex(role) { @@ -98,6 +100,30 @@ SurfacePanel { onTriggered: if (panel.session) panel.session.requestPtpRefresh() } + Dialog { + id: diagnosticFallback + parent: Overlay.overlay + anchors.centerIn: Overlay.overlay + modal: true + title: "PTP diagnostics · select and copy" + standardButtons: Dialog.Close + width: Math.min(760, Overlay.overlay.width - 32) + height: Math.min(480, Overlay.overlay.height - 32) + contentItem: ScrollView { + clip: true + TextArea { + id: diagnosticFallbackEditor + readOnly: true + selectByMouse: true + wrapMode: TextEdit.NoWrap + font.family: panel.monoFont + font.pixelSize: 11 + text: panel.diagnosticFallbackText + } + } + onOpened: diagnosticFallbackEditor.selectAll() + } + ScrollView { anchors.fill: parent contentWidth: availableWidth @@ -333,8 +359,20 @@ SurfacePanel { text: "Copy diagnostics" enabled: panel.session && panel.device.deviceVerified onClicked: { - StudioClipboard.copyText(panel.session.diagnosticsText()) - if (panel.controller) panel.controller.showMessage("PTP and firmware diagnostics copied.", false) + const evidence = panel.session.diagnosticsText() + if (StudioClipboard.copyText(evidence)) { + if (panel.controller) + panel.controller.showMessage("PTP diagnostics copied to clipboard.", false) + } else { + // Windows may temporarily deny clipboard ownership. + // Keep the complete text selectable in Studio instead + // of claiming success or asking for external scripts. + panel.diagnosticFallbackText = evidence + diagnosticFallback.open() + if (panel.controller) + panel.controller.showMessage( + "Clipboard unavailable. Diagnostic text is open: select and copy it.", true) + } } } Item { Layout.fillWidth: true } diff --git a/apps/arstack_studio/src/ClipboardHarness.cpp b/apps/arstack_studio/src/ClipboardHarness.cpp new file mode 100644 index 000000000..b5107e620 --- /dev/null +++ b/apps/arstack_studio/src/ClipboardHarness.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Native GUI clipboard contract: exercise the SAME production adapter as Studio. +#include "StudioClipboard.hpp" + +#include +#include +#include + +int main(int argc, char** argv) { + QGuiApplication app(argc, argv); + StudioClipboard clipboard; + auto* osClipboard = QGuiApplication::clipboard(); + if (osClipboard == nullptr) return 2; + + if (clipboard.copyText(QString{})) return 3; // no fabricated success + constexpr auto kHeader = "ARStack Studio field diagnostics"; + const QString evidence = QString::fromUtf8( + "ARStack Studio field diagnostics\n" + "Installed firmware: df73fea0b68e7f47\n" + "PTP START error: PTPROLE rejected: stop PTP before changing operating role\n" + "SVSYNC event: ETHERNET_LINK_LOSS\n"); + if (!evidence.startsWith(QString::fromLatin1(kHeader))) return 4; + if (!clipboard.copyText(evidence)) return 5; + if (osClipboard->text(QClipboard::Clipboard) != evidence) return 6; + + const QString second = QString::fromUtf8( + "PTP diagnostic: SOURCE running\n" + "MAC phase ±2 us\n" + "SVSYNC ACQUIRED\n"); + if (!clipboard.copyText(second)) return 7; + if (osClipboard->text(QClipboard::Clipboard) != second) return 8; + if (osClipboard->text(QClipboard::Clipboard) == evidence) return 9; + return 0; +} diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5611f9dbe..0ee5cb050 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -52,6 +52,10 @@ class DeterministicSessionHarness final { {"PTP source rejection survives verified safety STOP", ptpSourceReasonSurvivesSafetyStop()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"PTP stale board role rejection triggers one verified recovery", ptpStaleRuntimeOneRecovery()}, + {"PTP stale runtime recovery cannot loop on second rejection", ptpStaleRuntimeNoRetryLoop()}, + {"PTP runtime recovery requires STOPPED evidence", ptpStaleRuntimeStopUnverified()}, + {"PTP user STOP cancels automatic recovery intent", ptpStaleRuntimeStopCancelsRecovery()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, @@ -820,6 +824,94 @@ class DeterministicSessionHarness final { "PTP status=STOPPED Announce=0 Sync=0 FollowUp=0 PdelayFrames=0 TXfail=0"); } + static bool ptpStaleRuntimeOneRecovery() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + // Board was still transmitting, but desktop had cached OFF. + d.processLine(QStringLiteral( + "E (246315) ar_smv_profile: PTPROLE rejected: stop PTP before changing operating role")); + if (!d.ptpStarting() || !d.ptpRoleRecoveryAttempted_ || + !d.ptpRestartAfterStop_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + // Unrelated role ACK and pre-STOP telemetry must never advance. + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=61 Sync=61 FollowUp=61 PdelayFrames=0 TXfail=1")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack || + d.ptpRestartAfterStop_ || !d.ptpRoleRecoveryAttempted_) + return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE"))); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + return !d.ptpStarting() && d.ptpRunning_ && + d.ptpRole_ == QStringLiteral("SOURCE") && + !d.ptpRoleRecoveryAttempted_; + } + + static bool ptpStaleRuntimeNoRetryLoop() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + const QString stale = QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role"); + d.processLine(stale); + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) + return false; + d.processLine(stale); + return d.ptpStarting() && !d.ptpRestartAfterStop_ && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + d.ptpLastStartFailure_.contains(QStringLiteral("manual STOP")) && + !f.session.requestStartPtpConfigured( + QStringLiteral("SOURCE"), ptpValidProfile()); + } + + static bool ptpStaleRuntimeStopUnverified() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role")); + d.processLine(QStringLiteral("PTP stop requested")); + // Timeout is deterministic; no fake STOPPED readback accepted. + if (!QMetaObject::invokeMethod( + &d.ptpStartTimer_, "timeout", Qt::DirectConnection)) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::stop_unconfirmed || + !d.ptpStarting() || !d.ptpRestartAfterStop_ || + f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + return d.ptpStatus_.contains(QStringLiteral("unverified")); + } + + static bool ptpStaleRuntimeStopCancelsRecovery() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role")); + if (!d.ptpRestartAfterStop_) return false; + if (!f.session.requestStopPtp() || d.ptpRestartAfterStop_) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + d.processLine(QStringLiteral("PTPROLE accepted")); + return !d.ptpStarting() && + d.ptpStartStage_ == DeviceController::PtpStartStage::idle && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + static bool ptpGuidedStopCancelsStart() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index e20ab7e3e..2549234a9 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1324,17 +1324,25 @@ void DeviceController::resetPtpStartup() noexcept { pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; ptpRuntimeRunningSeen_ = false; + ptpRestartAfterStop_ = false; + ptpRoleRecoveryAttempted_ = false; } -bool DeviceController::beginPtpStopVerification() { +bool DeviceController::beginPtpStopVerification(const bool resumeStart) { ptpStartTimer_.stop(); ptpStopPollTimer_.stop(); ptpStartStage_ = PtpStartStage::awaiting_stop_ack; - pendingPtpRole_.clear(); - pendingPtpConfig_.clear(); + ptpRestartAfterStop_ = resumeStart && !pendingPtpRole_.isEmpty() && + !pendingPtpConfig_.isEmpty(); + if (!ptpRestartAfterStop_) { + pendingPtpRole_.clear(); + pendingPtpConfig_.clear(); + } ptpRuntimeRoleSeen_ = false; ptpRuntimeRunningSeen_ = false; - ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); + ptpStatus_ = ptpRestartAfterStop_ + ? QStringLiteral("PTP was already active on board; verifying STOP before retry...") + : QStringLiteral("Stopping PTP; waiting for board confirmation..."); emit ptpStateChanged(); // Emergency STOP has one reserved slot in the existing serial owner and // eliminates unsent START commands, even when the normal queue is full. @@ -1402,6 +1410,8 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, return false; } ptpLastStartFailure_.clear(); + ptpRestartAfterStop_ = false; + ptpRoleRecoveryAttempted_ = false; pendingPtpRole_ = role; pendingPtpConfig_ = command; ptpStartStage_ = PtpStartStage::awaiting_role_ack; @@ -1436,8 +1446,24 @@ void DeviceController::advancePtpStartup(const QString& line) { const auto readback = kPtpStatusExpression.match(line); if (!readback.hasMatch()) return; if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { - resetPtpStartup(); ptpRunning_ = false; + if (ptpRestartAfterStop_) { + // A single, evidence-triggered recovery of the same requested + // configuration. The board's verified STOPPED readback is a + // mandatory barrier before sending any role mutation. + ptpRestartAfterStop_ = false; + ptpStopPollTimer_.stop(); + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral("PTP stopped; applying selected role..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(pendingPtpRole_))) { + failPtpStartup(QStringLiteral("Could not queue PTP role after verified STOP."), false); + return; + } + ptpStartTimer_.start(5000); + return; + } + resetPtpStartup(); ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); emit ptpStateChanged(); } else if (!ptpStopPollTimer_.isActive()) { @@ -1454,6 +1480,31 @@ void DeviceController::advancePtpStartup(const QString& line) { line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive); if (rejected) { + // If firmware explicitly says an earlier PTP runtime still owns the + // board, reconcile the physical state ONCE via verified STOP. Never + // retry on generic config faults, never retry without STOPPED, and + // never allow a second automatic recovery loop. + const bool boardStillRunning = + line.contains(QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role"), + Qt::CaseInsensitive); + if (ptpStartStage_ == PtpStartStage::awaiting_role_ack && + boardStillRunning) { + const QString cause = QStringLiteral("Board PTP remained ACTIVE after one verified " + "STOP; manual STOP is required before START."); + ptpLastStartFailure_ = cause; + if (!ptpRoleRecoveryAttempted_) { + ptpRoleRecoveryAttempted_ = true; + if (!beginPtpStopVerification(true)) + markPtpStopUnconfirmed( + QStringLiteral("Could not queue STOP for stale PTP runtime.")); + } else { + // A second ACTIVE response means authority is inconsistent. + // Do not fabricate OFF or launch another retry loop. + markPtpStopUnconfirmed(cause); + } + return; + } const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; failPtpStartup(QStringLiteral("Board rejected command: %1").arg(line), rollback); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index f81ed4947..c2c6723f8 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -380,7 +380,7 @@ class DeviceController : public QObject { [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); - bool beginPtpStopVerification(); + bool beginPtpStopVerification(bool resumeStart = false); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); @@ -438,6 +438,8 @@ class DeviceController : public QObject { QString pendingPtpConfig_; bool ptpRuntimeRoleSeen_{false}; bool ptpRuntimeRunningSeen_{false}; + bool ptpRestartAfterStop_{false}; + bool ptpRoleRecoveryAttempted_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; diff --git a/apps/arstack_studio/src/StudioClipboard.cpp b/apps/arstack_studio/src/StudioClipboard.cpp index ee2e3c2fd..05bc2e40b 100644 --- a/apps/arstack_studio/src/StudioClipboard.cpp +++ b/apps/arstack_studio/src/StudioClipboard.cpp @@ -4,7 +4,10 @@ #include #include -void StudioClipboard::copyText(const QString& text) const { - if (auto* clipboard = QGuiApplication::clipboard(); clipboard != nullptr) - clipboard->setText(text); +bool StudioClipboard::copyText(const QString& text) const { + if (text.isEmpty()) return false; + auto* clipboard = QGuiApplication::clipboard(); + if (clipboard == nullptr) return false; + clipboard->setText(text, QClipboard::Clipboard); + return clipboard->text(QClipboard::Clipboard) == text; } diff --git a/apps/arstack_studio/src/StudioClipboard.hpp b/apps/arstack_studio/src/StudioClipboard.hpp index 35c105ccb..a4645edf6 100644 --- a/apps/arstack_studio/src/StudioClipboard.hpp +++ b/apps/arstack_studio/src/StudioClipboard.hpp @@ -9,5 +9,7 @@ class StudioClipboard final : public QObject { Q_OBJECT public: using QObject::QObject; - Q_INVOKABLE void copyText(const QString& text) const; + // Return success only if the OS clipboard can read back the exact value. + // Never claim a successful diagnostic copy merely because setText returned. + Q_INVOKABLE bool copyText(const QString& text) const; }; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index bb6822df8..b6887ee15 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -477,6 +477,21 @@ def main() -> None: 'ptpRuntimeRunningSeen_' in device_control_cpp and 'if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_' in device_control_cpp, "PTP errors must originate on board and two-order runtime proofs must be joined") + ptp_source = source("PtpExpertPanel.qml") + ptp_device = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + clipboard_cpp = (STUDIO / "src/StudioClipboard.cpp").read_text(encoding="utf-8") + qt_workflow = (STUDIO.parent.parent / ".github/workflows/arstack-studio-qt.yml").read_text(encoding="utf-8") + require("ptpRestartAfterStop_" in ptp_device and + "beginPtpStopVerification(true)" in ptp_device and + "PTPROLE rejected: stop PTP before changing operating role" in ptp_device and + "manual STOP is required" in ptp_device, + "PTP stale board runtime must follow bounded STOP + readback + role recovery") + require("import ARStack.Studio 1.0" in ptp_source and + "StudioClipboard.copyText(evidence)" in ptp_source and + "diagnosticFallback.open()" in ptp_source and + "clipboard->text(QClipboard::Clipboard) == text" in clipboard_cpp and + "arstack_clipboard_harness" in qt_workflow, + "Time Sync must use registered clipboard and verify a native OS readback") print("Studio operator workspace contract: PASS")