From fa60294a29387974154e3c8ffc53368fcd11852b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:50:10 +0700 Subject: [PATCH 1/8] fix(studio): port tested auto-live editor behavior onto PCAP-proven firmware baseline 7c95 --- apps/arstack_studio/qml/Main.qml | 133 ++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 36 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c3df31bcb..0259539f5 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -49,13 +49,41 @@ ApplicationWindow { return { version: 1, hz: signalFrequency, current: values(currentModel), voltage: values(voltageModel), linked: phaseLink } } + // One full 4I+4V firmware transaction per committed edit, coalesced across + // focus/Enter/context-menu events. Never emit serial commands per keystroke. + Timer { + id: autoLiveApplyTimer + interval: 40 + repeat: false + onTriggered: root.drainAutoLiveEdits() + } + function scheduleAutoLiveEdit() { + if (!device.running || !device.deviceVerified || !workflowBar.injectionRunning) + return + autoLiveQueuedRevision = scenarioRevision + autoLiveApplyTimer.restart() + } + function drainAutoLiveEdits() { + if (autoLiveQueuedRevision < 0) return + if (!device.deviceVerified || !device.running || !workflowBar.injectionRunning) { + autoLiveQueuedRevision = -1 + return + } + // No START or STOP: wait for the previous verified CFG COMMIT+readback + // to complete, then submit the newest snapshot, never stale drafts. + if (!workflowBar.session || !workflowBar.session.liveControlReady || + device.scenarioApplying) return + autoLiveQueuedRevision = -1 + // On rejection, retain pendingApply and surface the error. Do not + // silently retry forever or advance the claimed applied revision. + applyLiveScenario() + } function markScenarioDirty() { if (restoringScenario) return saveScenarioTimer.restart() - // All edits are a draft until the matching command is queued. - // Never label a visible draft as proven transmitted waveform data. pendingApply = true scenarioRevision += 1 + scheduleAutoLiveEdit() } function restoreLocalScenario() { const saved = scenarioSettings.lastScenario @@ -132,6 +160,7 @@ ApplicationWindow { property bool pendingApply: true property int scenarioRevision: 0 property int liveSubmissionRevision: -1 + property int autoLiveQueuedRevision: -1 // Session-only operator intent. Never persist across application launches: // an explicit PTP ON can be restored after a firmware reflash/reconnect // without introducing an uncontrolled power-on publisher. @@ -228,8 +257,24 @@ ApplicationWindow { function onDeviceVerifiedChanged() { root.pendingApply = true root.liveSubmissionRevision = -1 + root.autoLiveQueuedRevision = -1 + autoLiveApplyTimer.stop() root.offlinePreviewRunning = false } + function onRunningChanged() { + // An operator STOP or a firmware fault cancels unsent live edits. + // Reconnect/START must never replay edits from a previous session. + if (!device.running) { + root.autoLiveQueuedRevision = -1 + autoLiveApplyTimer.stop() + } + } + function onScenarioApplicationFinished() { + // The board ACK/readback gate is now idle. If the operator edited + // another field during the transaction, drain that newest edit. + if (root.autoLiveQueuedRevision >= 0 && device.running) + autoLiveApplyTimer.restart() + } function onLiveScenarioApplied() { if (root.liveSubmissionRevision >= 0 && root.liveSubmissionRevision === root.scenarioRevision) { @@ -245,6 +290,14 @@ ApplicationWindow { Connections { target: workflowBar.session + function onStateChanged() { + // If a committed edit was queued while the serial/session + // transport was briefly not ready, deliver it once the + // verified RUNNING session becomes writable again. + if (root.autoLiveQueuedRevision >= 0 && device.running && + !device.scenarioApplying) + autoLiveApplyTimer.restart() + } function onFirmwareUpdateFinished(success) { // Reset during firmware install stops the board's PTP SOURCE. // If the operator explicitly enabled it in this live Studio @@ -588,10 +641,11 @@ ApplicationWindow { : "One or more draft commands could not be queued; retry after checking the board.", !ok) return ok } - function startScenarioSnapshot() { + function startScenarioSnapshot(flushDrafts) { // 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()) { + if (flushDrafts !== false && + (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits())) { showMessage("Invalid signal input: correct highlighted field before Start.", true) return null } @@ -620,7 +674,9 @@ ApplicationWindow { function applyLiveScenario() { if (!workflowBar.injectionRunning || !workflowBar.session || !workflowBar.session.liveControlReady || device.scenarioApplying) return false - const snapshot = startScenarioSnapshot() + // Live must transmit committed model values only. A second text cell + // that is still being typed remains a draft until Enter/focus-out. + const snapshot = startScenarioSnapshot(false) if (!snapshot) return false const revision = scenarioRevision if (!workflowBar.session.requestApplyLiveScenario(snapshot)) { @@ -635,16 +691,34 @@ ApplicationWindow { function setFrequencyValue(value) { if (!validFrequency(value)) return false + if (signalFrequency === value) { + frequencyField.text = value.toFixed(3) + frequencyField.frequencyDirty = false + frequencyField.invalidInput = false + return true + } signalFrequency = value if (value > 0) previousAcFrequency = value frequencyField.text = value.toFixed(3) + frequencyField.frequencyDirty = false frequencyField.invalidInput = false refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetFrequency(value) return true } + function commitFrequencyEditor() { + if (!frequencyField.frequencyDirty) return true + const value = parseOperatorNumber(frequencyField.text) + if (!validFrequency(value)) { + frequencyField.text = signalFrequency.toFixed(3) + frequencyField.invalidInput = false + frequencyField.frequencyDirty = false + showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true) + return false + } + frequencyField.frequencyDirty = false + return setFrequencyValue(value) + } function setWaveformMode(mode) { if (mode === "DC") { @@ -876,7 +950,8 @@ ApplicationWindow { Label { text: root.pendingApply ? (workflowBar.injectionRunning - ? "4I + 4V · Pending · Apply live" + ? (device.scenarioApplying ? "4I + 4V · Applying live..." : + "4I + 4V · Auto-apply pending") : "4I + 4V · Setpoints pending · Start to apply") : "4I + 4V · 4000 samples/s" color: root.pendingApply @@ -1142,18 +1217,13 @@ ApplicationWindow { Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - // START still uses the visible setpoints. - // Apply live is shown only while SV is running. - CalmButton { + Label { visible: workflowBar.injectionRunning - theme: studioTheme - uiFont: root.uiFont - tone: "accent" - text: device.scenarioApplying ? "Verifying..." : "Apply live" - enabled: root.pendingApply && !device.scenarioApplying && - workflowBar.session && workflowBar.session.liveControlReady - toolTipText: "Apply all 4I+4V values without restarting SV" - onClicked: root.applyLiveScenario() + text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO" + color: device.scenarioApplying ? studioTheme.amber : studioTheme.green + font.family: root.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold } } @@ -1217,25 +1287,16 @@ ApplicationWindow { suffixText: "Hz" enabled: true validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 } + property bool frequencyDirty: false onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validFrequency(value)) { - invalidInput = false - root.signalFrequency = value - if (value > 0) root.previousAcFrequency = value - root.refreshPreview() - root.markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply) - workflowBar.session.requestSetFrequency(value) - } else invalidInput = true + frequencyDirty = true + invalidInput = !root.validFrequency( + root.parseOperatorNumber(text)) } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validFrequency(value)) { - text = root.signalFrequency.toFixed(3) - invalidInput = false - root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true) - } else text = value.toFixed(3) + onEditingFinished: root.commitFrequencyEditor() + onAccepted: root.commitFrequencyEditor() + onActiveFocusChanged: { + if (!activeFocus) root.commitFrequencyEditor() } } From b60d7f5cd9584ebca8fc1fad1c5c0c39f16a514d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:50:15 +0700 Subject: [PATCH 2/8] fix(studio): port tested auto-live editor behavior onto PCAP-proven firmware baseline 7c95 --- apps/arstack_studio/qml/WorkflowBar.qml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index c3459ebb0..6fc0d3028 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -23,8 +23,11 @@ SurfacePanel { property bool startIntentPending: false property string startIntentBoot: "" property double startIntentDeadline: 0 + // A RUNNING live CFG transaction is not a START. Keep 4I4V editors + // interactive while firmware verifies it; new edits coalesce and drain. readonly property bool startQueued: Boolean( - startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)) + startIntentPending || (ribbon.device && ribbon.device.scenarioApplying && + !ribbon.device.running)) Timer { id: startIntentExpiry interval: 6500 From 8042c98be1d947bb636135d676a22aae0a67ca60 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:50:33 +0700 Subject: [PATCH 3/8] test(studio): verify AutoLive focus/Enter without altering physical SV/PTP source --- .../tests/check_operator_workspace.py | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a724f2495..5b77226d3 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -126,9 +126,35 @@ def main() -> None: and "model.setProperty(0, \"phase\"" in main_qml and "model.setProperty(1, \"phase\"" in main_qml and "model.setProperty(2, \"phase\"" in main_qml - and "const snapshot = startScenarioSnapshot()" in main_qml + and "const snapshot = startScenarioSnapshot(false)" in main_qml + and "function startScenarioSnapshot(flushDrafts)" in main_qml and "requestApplyLiveScenario(snapshot)" in main_qml, "edited 3-phase angles must enter the same complete atomic 4I4V snapshot, never partial SETs") + # Regression: Enter, Tab or clicking away immediately schedules one + # atomic CFG 4I4V commit with independent firmware readback. The 40ms + # event-turn coalescing does not auto-send while a cell is still typed. + # A newer edit arriving during an in-flight commit is drained on ACK. + require("id: autoLiveApplyTimer" in main_qml + and "interval: 40" in main_qml + and "scheduleAutoLiveEdit()" in main_qml + and "autoLiveQueuedRevision = scenarioRevision" in main_qml + and "function drainAutoLiveEdits()" in main_qml + and "device.scenarioApplying) return" in main_qml + and "function onScenarioApplicationFinished()" in main_qml + and "autoLiveApplyTimer.restart()" in main_qml + and "function onRunningChanged()" in main_qml + and "autoLiveQueuedRevision = -1" in main_qml + and "const snapshot = startScenarioSnapshot(false)" in main_qml + and "requestApplyLiveScenario(snapshot)" in main_qml + and 'text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO"' in main_qml + and 'text: device.scenarioApplying ? "Verifying..." : "Apply live"' not in main_qml + and "function commitFrequencyEditor()" in main_qml + and "onEditingFinished: root.commitFrequencyEditor()" in main_qml + and "onAccepted: root.commitFrequencyEditor()" in main_qml + and "if (!activeFocus) root.commitFrequencyEditor()" in main_qml + and "if (flushDrafts !== false &&" in main_qml, + "running 4I4V edits must auto-apply once after commit without STOP/START, " + "not on each keystroke, and queue the newest revision while ACK pending") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, @@ -198,7 +224,8 @@ def main() -> None: in ribbon_qml and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml") and 'readonly property bool startQueued: Boolean(' in source("WorkflowBar.qml") - and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml") + and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying &&' in source("WorkflowBar.qml") + and '!ribbon.device.running))' 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 @@ -321,7 +348,7 @@ def main() -> None: ribbon = source("ModernRibbon.qml") cmake_icons = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml - and "startScenarioSnapshot()" in main_qml and "offlinePreviewRunning" in main_qml, + and "function startScenarioSnapshot(flushDrafts)" in main_qml and "offlinePreviewRunning" in main_qml, "offline-first scenario must persist and support one-click Start") require("Apply to board" not in main_qml and "requestApplyDraft()" not in main_qml, "operator UI must not expose a redundant Apply-to-board action") @@ -422,13 +449,13 @@ def main() -> None: and "requestApplyLiveScenario(snapshot)" in main_qml and "function onLiveScenarioApplied()" in main_qml and "liveSubmissionRevision === root.scenarioRevision" in main_qml - and "text: device.scenarioApplying ? \"Verifying...\" : \"Apply live\"" in main_qml + and 'text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO"' in main_qml and "return submitScenario(scenario, false);" in device_src and "scenarioLiveUpdate_ = !starting" in device_src and "emit liveScenarioApplied();" in device_src and "signalFrequencyHz_ = scenarioPendingFrequencyHz_" in device_src and "requestApplyLiveScenario(const QVariantMap& scenario)" in session_src, - "RUNNING 4I4V updates must use an explicit one-shot atomic CFG with verified ACK") + "RUNNING 4I4V auto-apply must use a coalesced atomic CFG with verified ACK") require("g_scenario_txn.began_running()" in firmware_live and "Live edit rejected: use atomic CFG transaction while RUNNING" in firmware_live and "if (scenarioLiveUpdate_)" in device_src From b32f8460de7a09cf1ab0bcdc9fc4b4a8acc247fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:53:01 +0700 Subject: [PATCH 4/8] ci(recovery): permit exact paired firmware preview on Draft recovery PR #288 only --- .github/workflows/esp32p4-smv-injector.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b075783c8..0a4d7582d 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -59,7 +59,7 @@ jobs: # On draft consolidation, do not run ordinary expensive standalone builds. # The Studio Native Preview explicitly requests a paired firmware via # workflow_call phase_sync_preview=true; allow only that purposeful build. - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022') }} + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023')) }} runs-on: ubuntu-latest timeout-minutes: 25 env: From 90e2755f1412d7b959b4403653f818a16ea70bb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 08:54:23 +0700 Subject: [PATCH 5/8] ci(policy): explicitly verify narrow Draft #288 firmware recovery exception --- scripts/check_ci_trigger_policy.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 0eadc4b10..d6a21824a 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -101,15 +101,17 @@ def main(): "github.event.pull_request.draft && " "contains(github.event.pull_request.body, '[run-phase-guard]') }}" ) - # Limited testbench artifact exception: only consolidated Draft #286 - # explicitly calling the existing firmware workflow for its exact-head - # Studio preview; no general Draft PR heavy-build bypass. + # Limited exact-head artifact exceptions: consolidated Draft #286 + # and the isolated physically-observed-source recovery Draft #288. + # No general Draft PR heavy-build bypass. preview_gate = ( " if: ${{ github.event_name != 'pull_request' || " "!github.event.pull_request.draft || " "(inputs.phase_sync_preview == true && " + "(github.event.pull_request.head.ref == " + "'feat/ptp-source-policy-fsm-20261022' || " "github.event.pull_request.head.ref == " - "'feat/ptp-source-policy-fsm-20261022') }}" + "'fix/autolive-from-pcap-proven-7c95-20261023')) }}" ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) From f2fc8a50ab96ceee7c1b5e099020596f5bcc2f8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:57:59 +0700 Subject: [PATCH 6/8] fix(studio): carry invalid capture reason through physical PCAP decoder --- apps/arstack_studio/src/SvCaptureController.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index 4355e5b9a..743e58fc7 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -56,7 +56,8 @@ class SvCaptureController : public QObject { const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, const QVariantMap& studioTelemetry, - const QVariantList& studioTelemetrySeries); + const QVariantList& studioTelemetrySeries, + const QString& interruptionReason = QString()); QProcess discovery_; QProcess capture_; QFutureWatcher decoder_; From 84b3ea7764652be77383f9510a42a14d30a25164 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:58:02 +0700 Subject: [PATCH 7/8] fix(studio): export 5 forensic outputs even after mid-capture SV STOP, keep invalid verdict and real frame counts --- .../src/SvCaptureController.cpp | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 0153f2e58..1e2a60d5e 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -373,7 +373,10 @@ void SvCaptureController::sampleTelemetry(const QVariantMap& passiveSnapshot) { "stop cause; packets recorded before STOP are only partial evidence."); abortRequested_ = true; capture_.terminate(); - setStatus(QStringLiteral("CAPTURE INVALID: ") + captureInterruptionReason_); + QTimer::singleShot(2500, &capture_, [this] { + if (capture_.state() != QProcess::NotRunning) capture_.kill(); + }); + setStatus(QStringLiteral("CAPTURE INTERRUPTED: ") + captureInterruptionReason_); } } void SvCaptureController::abortCapture() { @@ -391,9 +394,17 @@ void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus e const QString reason = captureInterruptionReason_.isEmpty() ? QStringLiteral("Capture manually canceled before completion.") : captureInterruptionReason_; - writeInvalidCaptureReport(reason); - setStatus(QStringLiteral("CAPTURE INVALID: ") + reason + - QStringLiteral(" See _report.json.")); + // A partial PCAP still carries independent physical SV/PTP evidence. + // Decode it to all five outputs and preserve INVALID_CAPTURE status; + // never replace genuine wire frame counts with the placeholder zero. + captureInterruptionReason_ = reason; + if (QFileInfo(activePcap_).size() >= 64) { + decodeCapturedFile(); + } else { + writeInvalidCaptureReport(reason); + setStatus(QStringLiteral("CAPTURE INVALID: ") + reason + + QStringLiteral(" See _report.json.")); + } return; } if (exitCode != 0 || exitStatus != QProcess::NormalExit || @@ -414,7 +425,8 @@ void SvCaptureController::decodeCapturedFile() { setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, activePcap_, activeCsv_, activeSeconds_, activeEvents_, activeJson_, activeSecondsRequested_, activeAdapter_, - activeStudioTelemetry_, activeStudioTelemetrySeries_)); + activeStudioTelemetry_, activeStudioTelemetrySeries_, + captureInterruptionReason_)); emit stateChanged(); } void SvCaptureController::openOutputFolder() { @@ -432,7 +444,8 @@ QVariantMap SvCaptureController::decodePcap( const QString& pcapFile, const QString& csvFile, const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, const QVariantMap& studioTelemetry, - const QVariantList& studioTelemetrySeries) { + const QVariantList& studioTelemetrySeries, + const QString& interruptionReason) { QVariantMap result; result.insert(QStringLiteral("json"), jsonFile); result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error.")); @@ -795,10 +808,17 @@ QVariantMap SvCaptureController::decodePcap( const bool usable = parseFailure.isEmpty() && matched > 0; const bool anomalies = missing > 0 || duplicates > 0 || malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0; - report.insert(QStringLiteral("status"), !usable ? QStringLiteral("INVALID_CAPTURE") + report.insert(QStringLiteral("status"), !usable || !interruptionReason.isEmpty() + ? QStringLiteral("INVALID_CAPTURE") : anomalies ? QStringLiteral("CAPTURED_WITH_ANOMALIES") : QStringLiteral("CAPTURED_NOT_ACCEPTED")); - if (!parseFailure.isEmpty()) report.insert(QStringLiteral("error"), parseFailure); + if (!interruptionReason.isEmpty()) { + report.insert(QStringLiteral("error"), interruptionReason); + report.insert(QStringLiteral("capture_interrupted"), true); + report.insert(QStringLiteral("partial_wire_evidence"), true); + } else if (!parseFailure.isEmpty()) { + report.insert(QStringLiteral("error"), parseFailure); + } QFile json(jsonFile); if (!json.open(QIODevice::WriteOnly | QIODevice::Truncate) || json.write(QJsonDocument::fromVariant(report).toJson(QJsonDocument::Indented)) < 0) { @@ -820,8 +840,11 @@ QVariantMap SvCaptureController::decodePcap( }); } result.insert(QStringLiteral("status"), usable - ? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. " - "Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.") + ? (interruptionReason.isEmpty() + ? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. " + "Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.") + : QStringLiteral("INVALID/INTERRUPTED: %1 SV / %2 PTP frames, %3 gaps, %4 sync transitions. " + "Saved five partial-evidence files; do not treat as 120s continuous pass.")) .arg(matched).arg(ptpFrames).arg(missing).arg(syncTransitions) .arg(anomalies ? QStringLiteral("ANOMALIES") : QStringLiteral("NOT PHYSICAL PASS")) : formatError(QStringLiteral("Capture validation failed"), From 8a2428503ee8a1595b03901ac8c1ca2b8343c728 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:58:56 +0700 Subject: [PATCH 8/8] test(studio): interrupted capture must preserve real SV/PTP counts and all five files without claiming FAT pass --- apps/arstack_studio/src/SvCaptureHarness.cpp | 41 +++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index 2d7fb7340..b1b1a2dd8 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -223,8 +223,45 @@ class SvCaptureHarness { result.value("summary").toMap().value("missing_samples").toULongLong() == 1 && result.value("summary").toMap().value("sync_transitions").toULongLong() == 1 && !result.value("summary").toMap().value("physical_acceptance").toBool(); - if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; - return ok; + if (!ok) { + qCritical() << "Failed production PCAP fixture:" << result << parsed; + return false; + } + // Regression from 2026-10-23 FAT: capture requested 120 seconds but + // publisher STOPPED mid-run. Report INVALID_CAPTURE while preserving + // 3 actual SV, 1 PTP, and all four parsed output artifacts. + const QString partialCsv = dir.filePath("partial_samples.csv"); + const QString partialSeconds = dir.filePath("partial_seconds.csv"); + const QString partialEvents = dir.filePath("partial_events.csv"); + const QString partialJson = dir.filePath("partial_report.json"); + const QString interrupted = QStringLiteral( + "SV publisher STOPPED during capture; partial on-wire evidence."); + const auto partialResult = SvCaptureController::decodePcap( + pcap, partialCsv, partialSeconds, partialEvents, partialJson, + 120, QStringLiteral("TEST_ETHERNET"), {}, {}, interrupted); + QFile partialReportFile(partialJson); + if (!partialReportFile.open(QIODevice::ReadOnly)) return false; + const QJsonObject partialReport = + QJsonDocument::fromJson(partialReportFile.readAll()).object(); + const bool partialOk = + partialReport.value("status").toString() == "INVALID_CAPTURE" && + partialReport.value("error").toString() == interrupted && + partialReport.value("capture_interrupted").toBool() && + partialReport.value("partial_wire_evidence").toBool() && + partialReport.value("sv_frames").toDouble() == 3 && + partialReport.value("ptp_l2_frames").toDouble() == 1 && + !partialReport.value("physical_acceptance").toBool() && + QFileInfo::exists(partialCsv) && + QFileInfo::exists(partialSeconds) && + QFileInfo::exists(partialEvents) && + partialResult.value("status").toString().contains( + QStringLiteral("INVALID/INTERRUPTED")) && + partialResult.value("summary").toMap() + .value("sv_frames").toULongLong() == 3; + if (!partialOk) + qCritical() << "Failed partial-capture 5-file regression:" + << partialResult << partialReport; + return partialOk; } };