fix: comparison results never reached QML; restore click-box run selection
This commit is contained in:
+89
-143
@@ -16,6 +16,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
// Properties for DTW Comparison
|
// Properties for DTW Comparison
|
||||||
|
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
|
||||||
property string compareFileA: ""
|
property string compareFileA: ""
|
||||||
property string compareFileB: ""
|
property string compareFileB: ""
|
||||||
property real warpingDistance: -1
|
property real warpingDistance: -1
|
||||||
@@ -43,17 +44,6 @@ Item {
|
|||||||
return path ? path.split("/").pop() : ""
|
return path ? path.split("/").pop() : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Badge colors per wafer type, matching the mockup's file-row chips
|
|
||||||
function badgeFg(t) {
|
|
||||||
return t === "Z" ? "#a78bfa" : t === "B" ? "#93c5fd" : t === "A" ? "#6ee7b7" : "#fde047"
|
|
||||||
}
|
|
||||||
function badgeBg(t) {
|
|
||||||
return t === "Z" ? Qt.rgba(139 / 255, 92 / 255, 246 / 255, 0.15)
|
|
||||||
: t === "B" ? Qt.rgba(59 / 255, 130 / 255, 246 / 255, 0.15)
|
|
||||||
: t === "A" ? Qt.rgba(16 / 255, 185 / 255, 129 / 255, 0.15)
|
|
||||||
: Qt.rgba(245 / 255, 158 / 255, 11 / 255, 0.15)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResults() {
|
function clearResults() {
|
||||||
root.warpingDistance = -1
|
root.warpingDistance = -1
|
||||||
root.frameOffset = 0
|
root.frameOffset = 0
|
||||||
@@ -69,15 +59,26 @@ Item {
|
|||||||
root.compareFileA = ""
|
root.compareFileA = ""
|
||||||
root.compareFileB = ""
|
root.compareFileB = ""
|
||||||
root.comparing = false
|
root.comparing = false
|
||||||
runACombo.currentIndex = -1
|
root.activeBox = 1
|
||||||
runBCombo.currentIndex = -1
|
|
||||||
clearResults()
|
clearResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: file_browser.refreshFiles()
|
Component.onCompleted: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
// Clicking a file in the left rail's browser populates the armed run slot
|
||||||
Connections {
|
Connections {
|
||||||
target: streamController
|
target: streamController
|
||||||
|
function onLoadedFileChanged() {
|
||||||
|
if (streamController.loadedFile && root.activeBox > 0) {
|
||||||
|
if (root.activeBox === 1) {
|
||||||
|
root.compareFileA = streamController.loadedFile
|
||||||
|
root.activeBox = 2 // Auto-advance to box 2
|
||||||
|
} else if (root.activeBox === 2) {
|
||||||
|
root.compareFileB = streamController.loadedFile
|
||||||
|
root.activeBox = 0 // Finished selecting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
function onComparisonResult(result) {
|
function onComparisonResult(result) {
|
||||||
root.comparing = false
|
root.comparing = false
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
@@ -183,124 +184,79 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run selector dropdown fed by the left-rail file browser model
|
// Run slot card: click to arm, then pick a file in the left rail's browser
|
||||||
component RunSelector: ComboBox {
|
component RunSlot: Rectangle {
|
||||||
id: selector
|
id: slotBox
|
||||||
property string placeholder: "Select CSV…"
|
property int boxIndex: 1
|
||||||
|
property string title
|
||||||
|
property string filePath: ""
|
||||||
|
property color accent: Theme.primaryAccent
|
||||||
|
signal cleared()
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 36
|
implicitHeight: 58
|
||||||
model: file_browser.files
|
radius: Theme.radiusSm
|
||||||
currentIndex: -1
|
color: Theme.fieldBackground
|
||||||
|
border.width: root.activeBox === boxIndex ? 2 : 1
|
||||||
|
border.color: root.activeBox === boxIndex ? accent : Theme.cardBorder
|
||||||
|
|
||||||
readonly property var currentFile: (currentIndex >= 0 && currentIndex < file_browser.files.length)
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
? file_browser.files[currentIndex] : null
|
|
||||||
|
|
||||||
background: Rectangle {
|
MouseArea {
|
||||||
radius: Theme.radiusXs
|
anchors.fill: parent
|
||||||
color: Theme.fieldBackground
|
cursorShape: Qt.PointingHandCursor
|
||||||
border.color: selector.popup.visible ? Theme.fieldBorderFocus : Theme.fieldBorder
|
onClicked: root.activeBox = slotBox.boxIndex
|
||||||
border.width: 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contentItem: RowLayout {
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.leftMargin: 8
|
width: 8; height: 8; radius: 4
|
||||||
width: 16; height: 16; radius: 4
|
color: slotBox.accent
|
||||||
visible: selector.currentFile !== null
|
Layout.alignment: Qt.AlignVCenter
|
||||||
color: root.badgeBg(selector.currentFile ? selector.currentFile.waferType : "")
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
Label {
|
Label {
|
||||||
anchors.centerIn: parent
|
text: slotBox.title
|
||||||
text: selector.currentFile ? selector.currentFile.waferType : ""
|
|
||||||
font.pixelSize: 9
|
font.pixelSize: 9
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: root.badgeFg(selector.currentFile ? selector.currentFile.waferType : "")
|
font.letterSpacing: 1.0
|
||||||
|
color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText
|
||||||
}
|
}
|
||||||
}
|
Label {
|
||||||
Label {
|
text: slotBox.filePath !== ""
|
||||||
Layout.fillWidth: true
|
? root.shortName(slotBox.filePath)
|
||||||
Layout.leftMargin: selector.currentFile === null ? 8 : 0
|
: (root.activeBox === slotBox.boxIndex ? "← Select file from left panel…" : "Click to select")
|
||||||
text: selector.currentFile ? selector.currentFile.baseName : selector.placeholder
|
font.pixelSize: Theme.fontXs
|
||||||
font.pixelSize: Theme.fontSm
|
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||||
font.weight: selector.currentFile ? Font.Medium : Font.Normal
|
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||||
color: selector.currentFile ? Theme.fieldText : Theme.fieldPlaceholder
|
elide: Text.ElideMiddle
|
||||||
elide: Text.ElideMiddle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
indicator: Label {
|
|
||||||
x: selector.width - width - 8
|
|
||||||
y: (selector.height - height) / 2
|
|
||||||
text: "▾"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate: ItemDelegate {
|
|
||||||
id: fileDelegate
|
|
||||||
width: selector.width
|
|
||||||
highlighted: selector.highlightedIndex === index
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
color: fileDelegate.highlighted ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
}
|
|
||||||
contentItem: RowLayout {
|
|
||||||
spacing: 8
|
|
||||||
Rectangle {
|
|
||||||
width: 20; height: 20; radius: 4
|
|
||||||
color: root.badgeBg(modelData.waferType)
|
|
||||||
Label {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: modelData.waferType
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
color: root.badgeFg(modelData.waferType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 0
|
|
||||||
Label {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: modelData.baseName
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
color: Theme.headingColor
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: modelData.date || modelData.chamber || ""
|
|
||||||
visible: text !== ""
|
|
||||||
font.pixelSize: 9
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
popup: Popup {
|
Button {
|
||||||
y: selector.height + 4
|
visible: slotBox.filePath !== ""
|
||||||
width: selector.width
|
flat: true
|
||||||
implicitHeight: Math.min(contentItem.implicitHeight + 8, 220)
|
implicitWidth: 24; implicitHeight: 24
|
||||||
padding: 4
|
onClicked: slotBox.cleared()
|
||||||
|
background: Rectangle {
|
||||||
background: Rectangle {
|
radius: Theme.radiusSm
|
||||||
radius: Theme.radiusXs
|
color: parent.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
|
||||||
color: Theme.cardBackground
|
}
|
||||||
border.color: Theme.cardBorder
|
contentItem: Label {
|
||||||
border.width: 1
|
text: "✕"
|
||||||
}
|
color: Theme.bodyColor
|
||||||
contentItem: ListView {
|
font.pixelSize: Theme.fontSm
|
||||||
clip: true
|
horizontalAlignment: Text.AlignHCenter
|
||||||
implicitHeight: contentHeight
|
verticalAlignment: Text.AlignVCenter
|
||||||
model: selector.popup.visible ? selector.delegateModel : null
|
}
|
||||||
currentIndex: selector.highlightedIndex
|
|
||||||
ScrollIndicator.vertical: ScrollIndicator {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,7 +521,7 @@ Item {
|
|||||||
Label {
|
Label {
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
visible: !root.comparing
|
visible: !root.comparing
|
||||||
text: "Pick Run A and Run B in the panel on the right."
|
text: "Click a run box on the right, then select a file from the left panel."
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
}
|
}
|
||||||
@@ -739,37 +695,27 @@ Item {
|
|||||||
|
|
||||||
SectionTitle { text: "RUN SELECTION" }
|
SectionTitle { text: "RUN SELECTION" }
|
||||||
|
|
||||||
ColumnLayout {
|
RunSlot {
|
||||||
Layout.fillWidth: true
|
title: "REFERENCE (RUN A)"
|
||||||
spacing: 4
|
boxIndex: 1
|
||||||
Label {
|
accent: Theme.primaryAccent
|
||||||
text: "Reference CSV (Run A)"
|
filePath: root.compareFileA
|
||||||
font.pixelSize: 9
|
onCleared: {
|
||||||
color: Theme.sideMutedText
|
root.compareFileA = ""
|
||||||
}
|
root.activeBox = 1
|
||||||
RunSelector {
|
root.clearResults()
|
||||||
id: runACombo
|
|
||||||
onActivated: function (index) {
|
|
||||||
root.compareFileA = file_browser.files[index].fileName
|
|
||||||
root.clearResults()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
RunSlot {
|
||||||
Layout.fillWidth: true
|
title: "SESSION (RUN B)"
|
||||||
spacing: 4
|
boxIndex: 2
|
||||||
Label {
|
accent: Theme.themeSkill
|
||||||
text: "Session CSV (Run B)"
|
filePath: root.compareFileB
|
||||||
font.pixelSize: 9
|
onCleared: {
|
||||||
color: Theme.sideMutedText
|
root.compareFileB = ""
|
||||||
}
|
root.activeBox = 2
|
||||||
RunSelector {
|
root.clearResults()
|
||||||
id: runBCombo
|
|
||||||
onActivated: function (index) {
|
|
||||||
root.compareFileB = file_browser.files[index].fileName
|
|
||||||
root.clearResults()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ class SessionController(QObject):
|
|||||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||||
# trend: per-frame avg for live graph
|
# trend: per-frame avg for live graph
|
||||||
trendData = Signal(str) # JSON array of floats
|
trendData = Signal(str) # JSON array of floats
|
||||||
comparisonResult = Signal(object) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
splitResult = Signal(object) # dict with success, segments
|
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||||
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
|
splitResult = Signal(dict) # dict with success, segments
|
||||||
# private: marshal a worker-thread frame onto the main thread
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
_liveFrame = Signal(object) # Frame
|
_liveFrame = Signal(object) # Frame
|
||||||
_liveError = Signal() # worker-thread error ping
|
_liveError = Signal() # worker-thread error ping
|
||||||
@@ -357,10 +359,11 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
result = compare_runs(series_a, series_b)
|
result = compare_runs(series_a, series_b)
|
||||||
|
|
||||||
# Mean temporal shif along the DTW path: positive means run B
|
# Mean temporal shift along the DTW path: positive means run B
|
||||||
# reaches the same profile features lather than run A.
|
# reaches the same profile features later than run A.
|
||||||
path = result["warping_path"]
|
path = result["warping_path"]
|
||||||
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
||||||
|
|
||||||
# Max sensor deviation: walk the DTW-aligned frame pairs and take
|
# Max sensor deviation: walk the DTW-aligned frame pairs and take
|
||||||
# the largest per-sensor abs difference across all sensors, not
|
# the largest per-sensor abs difference across all sensors, not
|
||||||
# just the sensor[0] series used for alignment.
|
# just the sensor[0] series used for alignment.
|
||||||
@@ -422,7 +425,8 @@ class SessionController(QObject):
|
|||||||
self.comparisonResult.emit({
|
self.comparisonResult.emit({
|
||||||
"success": True,
|
"success": True,
|
||||||
"distance": result["distance"],
|
"distance": result["distance"],
|
||||||
"warping_path": result["warping_path"][:50], # limit path size
|
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||||
|
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||||
"frame_offset": frame_offset,
|
"frame_offset": frame_offset,
|
||||||
"max_sensor_deviation": max_sensor_deviation,
|
"max_sensor_deviation": max_sensor_deviation,
|
||||||
"series_a": series_a,
|
"series_a": series_a,
|
||||||
@@ -709,6 +713,9 @@ class SessionController(QObject):
|
|||||||
self._trend_timestamps.pop(0)
|
self._trend_timestamps.pop(0)
|
||||||
self._trend_buffer.pop(0)
|
self._trend_buffer.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_repaint(self) -> None:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,6 +65,41 @@ def test_compare_files_integration(controller):
|
|||||||
assert "series_b" in args
|
assert "series_b" in args
|
||||||
assert args["frame_offset"] == 0
|
assert args["frame_offset"] == 0
|
||||||
|
|
||||||
|
def test_comparison_result_reaches_qml(qapp, controller):
|
||||||
|
"""The result dict must arrive in QML as a real JS object with fields.
|
||||||
|
|
||||||
|
Signal(object) delivers Python dicts to QML as an opaque empty wrapper;
|
||||||
|
the signal must be declared with a dict/QVariantMap signature.
|
||||||
|
"""
|
||||||
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
|
|
||||||
|
engine = QQmlApplicationEngine()
|
||||||
|
engine.rootContext().setContextProperty("streamController", controller)
|
||||||
|
engine.loadData(b"""
|
||||||
|
import QtQuick
|
||||||
|
Item {
|
||||||
|
id: probe
|
||||||
|
property bool gotSuccess: false
|
||||||
|
property real gotDistance: -1
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onComparisonResult(result) {
|
||||||
|
probe.gotSuccess = result.success === true
|
||||||
|
probe.gotDistance = result.distance !== undefined ? result.distance : -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
assert engine.rootObjects(), "probe QML failed to load"
|
||||||
|
root = engine.rootObjects()[0]
|
||||||
|
|
||||||
|
csv_path = "tests/fixtures/sample_stream.csv"
|
||||||
|
controller.compareFiles(csv_path, csv_path)
|
||||||
|
qapp.processEvents()
|
||||||
|
|
||||||
|
assert root.property("gotSuccess") is True
|
||||||
|
assert root.property("gotDistance") >= 0
|
||||||
|
|
||||||
def test_recording_toggle(controller, tmp_path):
|
def test_recording_toggle(controller, tmp_path):
|
||||||
csv_path = str(tmp_path / "rec" / "live_test.csv")
|
csv_path = str(tmp_path / "rec" / "live_test.csv")
|
||||||
controller.startRecording(csv_path, "SN123")
|
controller.startRecording(csv_path, "SN123")
|
||||||
|
|||||||
Reference in New Issue
Block a user