diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index fb92d1db8..71680a0ee 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -226,7 +226,7 @@ jobs: $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-clipboard', '--check-app-lifecycle')) { $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$($probe.ExitCode)" if ($probe.ExitCode -ne 0) { @@ -274,7 +274,7 @@ jobs: $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-clipboard', '--check-app-lifecycle')) { $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru if ($probe.ExitCode -ne 0) { throw "Installed preview failed $check code $($probe.ExitCode)" } } diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index e51575e24..e58d5171f 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -716,7 +716,19 @@ ApplicationWindow { font.pixelSize: 9 } Item { Layout.fillWidth: true } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Copy diagnostics"; onClicked: StudioClipboard.copyText(workflowBar.session.diagnosticsText()) } + CalmButton { + theme: studioTheme + uiFont: root.uiFont + text: "Copy diagnostics" + onClicked: { + const snapshot = workflowBar.session ? workflowBar.session.diagnosticsText() : "" + if (StudioClipboard.copyText(snapshot)) { + root.showMessage("Diagnostics copied to clipboard.", false) + } else { + root.showMessage("Clipboard unavailable. Select text below and press Ctrl+C.", true) + } + } + } CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Clear"; onClicked: device.clearLog() } } ScrollView { @@ -724,7 +736,13 @@ ApplicationWindow { Layout.fillHeight: true TextArea { readOnly: true - text: device.logText + // Include the complete same session/PTP snapshot used by + // the Copy button, rather than showing only raw serial lines. + // Selectable fall-back remains available if native clipboard + // ownership is denied by Windows or another process. + text: diagnosticsDialog.visible && workflowBar.session + ? workflowBar.session.diagnosticsText() : device.logText + selectByMouse: true color: studioTheme.textSoft selectionColor: studioTheme.accent font.family: root.monoFont diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 37b259886..8d0ad365e 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 @@ -284,6 +285,16 @@ SurfacePanel { font.pixelSize: 11 } + Label { + Layout.fillWidth: true + visible: panel.session && panel.session.firmwareUpdateAvailable + text: "Updated matching ESP32-P4 firmware available in Firmware. Install it for current PTP fixes." + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + RowLayout { Layout.fillWidth: true spacing: 8 @@ -333,8 +344,15 @@ 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 snapshot = panel.session ? panel.session.diagnosticsText() : "" + if (StudioClipboard.copyText(snapshot)) { + if (panel.controller) + panel.controller.showMessage("PTP and firmware diagnostics copied.", false) + } else if (panel.controller) { + panel.controller.showMessage( + "Windows clipboard unavailable. Select diagnostics in the opened window and press Ctrl+C.", true) + panel.controller.openDiagnostics() + } } } Item { Layout.fillWidth: true } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5611f9dbe..628ba4d4e 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -43,6 +43,7 @@ class DeterministicSessionHarness final { {"manual recovery intent stays optional and visible", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"PTP preflight readback guards active board and deferred role commands", ptpBoardRuntimePreflight()}, {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, {"PTP SOURCE invalid 9-3 profile rejects before serial", ptpSourceProfilePreflight()}, @@ -636,7 +637,11 @@ class DeterministicSessionHarness final { seedVerified(f, identity(), QStringLiteral("COM7"), false); f.device.ptpAvailable_ = true; f.device.ioWorkerReady_ = true; // No actual COM opened by this harness. - return f.session.requestStartPtpConfigured(role, ptpValidProfile()); + if (!f.session.requestStartPtpConfigured(role, ptpValidProfile()) || + f.device.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) + return false; + f.device.processLine(ptpStoppedStatus()); + return f.device.ptpStartStage_ == DeviceController::PtpStartStage::awaiting_role_ack; } static bool ptpSourceProfilePreflight() { @@ -664,8 +669,11 @@ class DeterministicSessionHarness final { // A valid SOURCE profile must immediately permit a fresh attempt. if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) || - d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight || !d.ptpLastStartFailure_.isEmpty()) return false; + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) + return false; return true; } @@ -717,6 +725,9 @@ class DeterministicSessionHarness final { if (d.ptpStarting() || d.ptpRunning_) return false; if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) + return false; + d.processLine(ptpStoppedStatus()); d.processLine(QStringLiteral("PTPROLE accepted")); d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); d.processLine(QStringLiteral("PTP configuration accepted")); @@ -771,6 +782,50 @@ class DeterministicSessionHarness final { d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")); } + static bool ptpBoardRuntimePreflight() { + Fixture running; + if (!running.profileReady) return false; + seedVerified(running, identity(), QStringLiteral("COM7"), false); + auto& d = running.device; + d.ptpAvailable_ = true; + d.ioWorkerReady_ = true; + if (!running.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight || + !d.ptpStarting()) return false; + // Stale ROLE and CONFIG chatter may not advance preflight. + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + if (d.ptpStarting() || !d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("already RUNNING")) || + d.ptpStartPhase() != QStringLiteral("IDLE")) return false; + // Do not auto-stop a real transmitter merely because GUI has an old + // local OFF state. Explicit verified STOP is the only recovery. + if (!d.stopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_) return false; + + Fixture timedOut; + if (!timedOut.profileReady) return false; + seedVerified(timedOut, identity(), QStringLiteral("COM7"), false); + timedOut.device.ptpAvailable_ = true; + timedOut.device.ioWorkerReady_ = true; + if (!timedOut.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + if (!QMetaObject::invokeMethod( + &timedOut.device.ptpStartTimer_, "timeout", Qt::DirectConnection)) + return false; + return !timedOut.device.ptpStarting() && + timedOut.device.ptpLastStartFailure_.contains(QStringLiteral("VERIFY_STOPPED")) && + !timedOut.device.ptpRunning_; + } + static bool ptpGuidedStartNeedsAcks() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index e20ab7e3e..17619f2cc 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -135,7 +135,7 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { if (ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; - failPtpStartup(QStringLiteral("PTP acknowledgement timed out. Start cancelled."), rollback); + failPtpStartup(QStringLiteral("PTP %1 timed out; START not verified.").arg(ptpStartPhase()), rollback); }); ptpStopPollTimer_.setSingleShot(true); connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] { @@ -453,6 +453,21 @@ QString DeviceController::profileDeploymentProgressSummary() const { bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; } bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; } QString DeviceController::ptpStatus() const { return ptpStatus_; } +QString DeviceController::ptpStartPhase() const { + switch (ptpStartStage_) { + case PtpStartStage::idle: return QStringLiteral("IDLE"); + case PtpStartStage::awaiting_preflight: return QStringLiteral("VERIFY_STOPPED"); + case PtpStartStage::awaiting_role_ack: return QStringLiteral("ROLE_ACK"); + case PtpStartStage::awaiting_role_readback: return QStringLiteral("ROLE_READBACK"); + case PtpStartStage::awaiting_config: return QStringLiteral("CONFIG_ACK"); + case PtpStartStage::awaiting_start: return QStringLiteral("START_ACK"); + case PtpStartStage::awaiting_runtime: return QStringLiteral("RUNTIME_READBACK"); + case PtpStartStage::awaiting_stop_ack: return QStringLiteral("STOP_ACK"); + case PtpStartStage::awaiting_stop_readback: return QStringLiteral("STOP_READBACK"); + case PtpStartStage::stop_unconfirmed: return QStringLiteral("STOP_UNVERIFIED"); + } + return QStringLiteral("UNKNOWN"); +} QString DeviceController::ptpDomain() const { return ptpDomain_; } QString DeviceController::ptpTransportSpecific() const { return ptpTransportSpecific_; } QString DeviceController::ptpVlan() const { return ptpVlan_; } @@ -1404,11 +1419,14 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, ptpLastStartFailure_.clear(); pendingPtpRole_ = role; pendingPtpConfig_ = command; - ptpStartStage_ = PtpStartStage::awaiting_role_ack; - ptpStatus_ = QStringLiteral("Waiting for board to accept PTP mode..."); + // Verify actual firmware runtime before selecting another role. A stale + // local OFF flag after reconnect must not provoke a rejected PTPROLE or + // force-stop a transmitter still running on the board. + ptpStartStage_ = PtpStartStage::awaiting_preflight; + ptpStatus_ = QStringLiteral("Checking actual board PTP runtime before START..."); emit ptpStateChanged(); - if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role))) { - failPtpStartup(QStringLiteral("Could not queue PTP role selection."), false); + if (!sendPtpShow()) { + failPtpStartup(QStringLiteral("Could not queue PTP preflight readback."), false); return false; } ptpStartTimer_.start(5000); @@ -1417,6 +1435,30 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, void DeviceController::advancePtpStartup(const QString& line) { if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; + if (ptpStartStage_ == PtpStartStage::awaiting_preflight) { + const auto actual = kPtpStatusExpression.match(line); + if (!actual.hasMatch()) return; + if (actual.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) { + ptpLastStartFailure_ = QStringLiteral( + "Board PTP is already RUNNING. Click Stop PTP, wait for verified STOPPED, then retry Start."); + ptpStatus_ = ptpLastStartFailure_; + resetPtpStartup(); + emit ptpStateChanged(); + setError(ptpLastStartFailure_); + return; + } + if (actual.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) != 0) + return; + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral("Board STOPPED confirmed. Selecting PTP mode..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(pendingPtpRole_))) { + failPtpStartup(QStringLiteral("Could not queue PTP role selection after STOPPED readback."), false); + return; + } + ptpStartTimer_.start(5000); + return; + } if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack) { // Firmware emits "PTP stop requested", not "PTP stop accepted". // A stale STOPPED status from an older SHOW cannot confirm this STOP. @@ -1456,7 +1498,8 @@ void DeviceController::advancePtpStartup(const QString& line) { if (rejected) { const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; - failPtpStartup(QStringLiteral("Board rejected command: %1").arg(line), rollback); + const QString step = ptpStartPhase(); + failPtpStartup(QStringLiteral("Board rejected PTP %1 command: %2").arg(step, line), rollback); return; } if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index f81ed4947..a4eb11ad5 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -69,6 +69,7 @@ class DeviceController : public QObject { Q_PROPERTY(bool ptpStarting READ ptpStarting NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpLastStartFailure READ ptpLastStartFailure NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpStartPhase READ ptpStartPhase NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) @@ -158,6 +159,7 @@ class DeviceController : public QObject { [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } [[nodiscard]] QString ptpStatus() const; [[nodiscard]] QString ptpLastStartFailure() const { return ptpLastStartFailure_; } + [[nodiscard]] QString ptpStartPhase() const; [[nodiscard]] QString ptpDomain() const; [[nodiscard]] QString ptpTransportSpecific() const; [[nodiscard]] QString ptpVlan() const; @@ -318,7 +320,7 @@ class DeviceController : public QObject { friend class DeterministicSessionHarness; enum class PtpStartStage { - idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, + idle, awaiting_preflight, awaiting_role_ack, awaiting_role_readback, awaiting_config, awaiting_start, awaiting_runtime, awaiting_stop_ack, awaiting_stop_readback, stop_unconfirmed }; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index e22cdfcf5..aca4cd7a6 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -983,6 +983,7 @@ QString SmartSessionController::diagnosticsText() const { add(QStringLiteral("SMV missed reported"), device_->missed()); add(QStringLiteral("SMV TX failures reported"), device_->txFailures()); add(QStringLiteral("PTP status reported"), device_->ptpStatus()); + add(QStringLiteral("PTP transactional phase"), device_->ptpStartPhase()); add(QStringLiteral("Last PTP START failure (same boot)"), device_->ptpLastStartFailure()); add(QStringLiteral("SV sync internal flight recorder (not physical proof)"), diff --git a/apps/arstack_studio/src/StudioClipboard.cpp b/apps/arstack_studio/src/StudioClipboard.cpp index ee2e3c2fd..dbb728df6 100644 --- a/apps/arstack_studio/src/StudioClipboard.cpp +++ b/apps/arstack_studio/src/StudioClipboard.cpp @@ -4,7 +4,13 @@ #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.trimmed().isEmpty()) return false; + QClipboard* const clipboard = QGuiApplication::clipboard(); + if (clipboard == nullptr) return false; + // Windows may refuse an occupied native clipboard. Qt's setText() has no + // success return, so read back its content before telling the operator + // the diagnostics are available for pasting. + 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..2fa4941eb 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; + // Returns true only when the host clipboard contains the requested snapshot. + // Empty snapshots and unavailable/busy clipboard never report success. + Q_INVOKABLE bool copyText(const QString& text) const; }; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 050d96cf7..a8320ac1a 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -9,6 +9,7 @@ #include "StudioDeviceController.hpp" #include "SvCaptureController.hpp" +#include #include #include #include @@ -533,6 +534,25 @@ int main(int argc, char* argv[]) { QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); #endif + if (hasArgument(argc, argv, "--check-clipboard")) { + // Headless-board independent Windows acceptance: clipboard copy must + // round-trip and an empty report must never be reported as copied. + StudioClipboard clipboard; + const QString sample = QStringLiteral( + "ARStack Studio diagnostics\\nPTP last failure: source_reason=PDELAY_RX_TIMESTAMP_UNAVAILABLE"); + const bool copied = clipboard.copyText(sample); + const bool matches = QGuiApplication::clipboard() != nullptr && + QGuiApplication::clipboard()->text(QClipboard::Clipboard) == sample; + const bool rejectsEmpty = !clipboard.copyText(QString{}); + if (!copied || !matches || !rejectsEmpty) { + qCritical() << "Studio native clipboard regression: FAIL" + << copied << matches << rejectsEmpty; + return 18; + } + qInfo() << "Studio native clipboard regression: PASS"; + return 0; + } + // S6 ownership starts before any firmware process, QML object, or serial // worker exists. A second Studio process therefore cannot race for the // injector COM handle or bypass the supervisor's in-process PortOwner. diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index bb6822df8..41393b6eb 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -450,7 +450,9 @@ def main() -> None: device_control_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require("sendCommand(QStringLiteral(\"PROFILE SHOW\"))" in device_control_cpp and "Could not request verified PTP role readback" in device_control_cpp and - "Board rejected command: %1" in device_control_cpp, + "Board rejected PTP %1 command: %2" in device_control_cpp and + "awaiting_preflight" in device_control_cpp and + "PTP status=(RUNNING|STOPPED)" in device_control_cpp, "PTP role ACK must trigger explicit firmware readback with meaningful failure") require("exactBuildMatch" in firmware_panel and "firmwareBuildId" in firmware_panel and @@ -477,6 +479,20 @@ 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") + clipboard_header = (STUDIO / "src/StudioClipboard.hpp").read_text(encoding="utf-8") + clipboard_cpp = (STUDIO / "src/StudioClipboard.cpp").read_text(encoding="utf-8") + main_cpp = (STUDIO / "src/main.cpp").read_text(encoding="utf-8") + studio_main = source("Main.qml") + require("import ARStack.Studio 1.0" in ptp_panel and + "if (StudioClipboard.copyText(snapshot))" in ptp_panel and + "panel.controller.openDiagnostics()" in ptp_panel and + "Q_INVOKABLE bool copyText" in clipboard_header and + "clipboard->text(QClipboard::Clipboard) == text" in clipboard_cpp and + "--check-clipboard" in main_cpp and + "selectByMouse: true" in studio_main, + "Timing Copy diagnostics must use imported GUI singleton, verify native clipboard, " + "and offer full selectable manual fallback") + print("Studio operator workspace contract: PASS")