修复 Autocal 压头配置、姿态保存与复核性能

阻止不完整标定配置生成 Z 目标,同步当前测点姿态,并将复核文件校验移到后台。通过 476 项全量测试、53 项最终定向复验和代码检查。
This commit is contained in:
ykcheungab
2026-10-15 07:51:39 +08:00
parent bcc55a3b97
commit ad049d12e4
16 changed files with 388 additions and 38 deletions
+1
View File
@@ -684,6 +684,7 @@
"col_range_status": "Range/status" "col_range_status": "Range/status"
}, },
"review": { "review": {
"checking": "Reading and verifying recordings in the background…",
"load_selection": "Load saved analysis…", "load_selection": "Load saved analysis…",
"confirm_stable": "This interval was checked for stability", "confirm_stable": "This interval was checked for stability",
"apply_window": "Confirm interval and recompute", "apply_window": "Confirm interval and recompute",
+1
View File
@@ -684,6 +684,7 @@
"col_range_status": "范围/状态" "col_range_status": "范围/状态"
}, },
"review": { "review": {
"checking": "正在后台读取并校验记录,请稍候…",
"load_selection": "载入历史分析…", "load_selection": "载入历史分析…",
"confirm_stable": "此时间段已检查稳定", "confirm_stable": "此时间段已检查稳定",
"apply_window": "确认选中时间段并重算", "apply_window": "确认选中时间段并重算",
@@ -326,6 +326,20 @@ class Campaign:
raise ValueError("the solved pose is outside the stage travel; it is stored but not commanded") raise ValueError("the solved pose is outside the stage travel; it is stored but not commanded")
return {key: float(pose[key]) for key in ("x", "y", "yaw", "pitch", "z")} return {key: float(pose[key]) for key in ("x", "y", "yaw", "pitch", "z")}
def store_solved_pose(self, site_id: str, pose: dict) -> None:
"""Update the current site pose without rewriting past repeat records."""
if self.is_active and site_id != self.pending_alignment_site:
raise RuntimeError("cannot change a pose during an active trial")
site = self.sites[self._index(site_id)]
record = None if self._document is None else next(
(row for row in self._document["sites"] if row["site_id"] == site_id), None)
if site.pose == pose and (record is None or record["pose"] == pose):
return
site.pose = dict(pose)
if record is not None:
record["pose"] = site.pose
self._save()
def set_force_rotation(self, values: list[float]) -> None: def set_force_rotation(self, values: list[float]) -> None:
"""Store the force-sensor-to-tool rotation. There is no default matrix.""" """Store the force-sensor-to-tool rotation. There is no default matrix."""
self._rotation = parse_rotation(list(values)) self._rotation = parse_rotation(list(values))
@@ -623,6 +637,13 @@ class Campaign:
elapsed = (time.perf_counter_ns() - self._hold_started_ns) / 1e9 elapsed = (time.perf_counter_ns() - self._hold_started_ns) / 1e9
return max(0.0, self._hold_s - elapsed) return max(0.0, self._hold_s - elapsed)
@property
def pending_alignment_site(self) -> str:
"""The next point awaiting alignment between recorded trials."""
if self.phase != "liftoff" or self._single_site:
return ""
return self.sites[self._site_index].site_id
def progress(self) -> dict: def progress(self) -> dict:
site = self.sites[self._site_index] if self.sites and self._site_index < len(self.sites) else None site = self.sites[self._site_index] if self.sites and self._site_index < len(self.sites) else None
step = self._steps[self._step_index] if self._step_index < len(self._steps) else None step = self._steps[self._step_index] if self._step_index < len(self._steps) else None
@@ -274,7 +274,6 @@ _MERGED_BLOCKS = (
"safety_limits", "safety_limits",
"sensor_setup", "sensor_setup",
"sequence_profile", "sequence_profile",
"press_axis",
) )
@@ -520,6 +520,61 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
} }
def reselect_review(result: dict, selections: dict) -> dict:
"""Adjust loaded hold traces; saving still verifies all acquired files afresh."""
from copy import deepcopy
updated = dict(result)
updated["sites"] = [{**site, "windows": [dict(window) for window in site["windows"]]}
for site in result["sites"]]
used = set()
for site in updated["sites"]:
for window in site["windows"]:
key = f"{window['trial_id']}:{window['step_index']}"
choice = selections.get(key)
start, end = window["hold_start_host_mono_ns"], window["hold_end_host_mono_ns"]
verified = False
if choice is not None:
used.add(key)
start, end = int(choice["t_start_host_mono_ns"]), int(choice["t_end_host_mono_ns"])
verified = choice["stability_verified"] is True
if not window["hold_start_host_mono_ns"] <= start < end <= window["hold_end_host_mono_ns"]:
raise ValueError(f"selection outside recorded hold: {key}")
trace = [(stamp, value) for stamp, _, value in window["trace"] if start <= stamp < end]
raw = np.array([value for _, value in trace], dtype=float)
if verified and raw.size < 2:
raise ValueError(f"not enough valid samples to confirm: {key}")
status = window["status"]
if status in {"candidate", "insufficient_samples"}:
status = "candidate" if raw.size >= 2 else "insufficient_samples"
window.update(
t_start_host_mono_ns=start, t_end_host_mono_ns=end,
duration_s=(end - start) / 1e9, samples=int(raw.size),
raw_mean=float(raw.mean()) if raw.size else None,
raw_std=float(raw.std()) if raw.size else None,
raw_drift_per_s=float(np.polyfit(
np.array([(stamp - start) / 1e9 for stamp, _ in trace]), raw, 1)[0])
if raw.size >= 2 and len({stamp for stamp, _ in trace}) >= 2 else None,
stability_verified=verified, status=status,
)
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
and w["status"] == "candidate"]
site.update(fit=_fit(training),
confirmed_fit=_fit([w for w in training if w["stability_verified"]]),
quality=_quality(site["windows"]))
if updated.get("source_format") == MULTIPOINT_FORMAT:
site.update(_multipoint_site(site, site["windows"]))
site["blockers"] = sorted({
*[b for b in site["blockers"] if not b.startswith("unverified_window:") and b != "no_fit"],
*[f"unverified_window:{w['step_index']}" for w in site["windows"] if not w["stability_verified"]],
*(["no_fit"] if site["fit"] is None else []),
})
if used != set(selections):
raise ValueError("selection refers to an unavailable hold window")
updated["selections"] = deepcopy(selections)
return updated
def load_review(directory: Path, review_path: Path) -> dict: def load_review(directory: Path, review_path: Path) -> dict:
"""Recompute saved selections only when the acquired inputs still match.""" """Recompute saved selections only when the acquired inputs still match."""
saved = json.loads(review_path.read_text(encoding="utf-8")) saved = json.loads(review_path.read_text(encoding="utf-8"))
@@ -353,8 +353,7 @@ class MultipointPanel(QWidget):
def _store_solved_pose(self, site_id: str, pose: dict) -> None: def _store_solved_pose(self, site_id: str, pose: dict) -> None:
"""Record a fresh solve of a point without retaking the point itself.""" """Record a fresh solve of a point without retaking the point itself."""
site = next(item for item in self.campaign.sites if item.site_id == site_id) self.campaign.store_solved_pose(site_id, pose)
site.pose = pose
def _require_same_point(self, site, point, normal) -> None: def _require_same_point(self, site, point, normal) -> None:
"""Refuse a point the current map no longer puts where the record says. """Refuse a point the current map no longer puts where the record says.
@@ -8,7 +8,7 @@ from bisect import bisect_left, bisect_right
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
from PySide6.QtCore import Qt, QPointF from PySide6.QtCore import Qt, QPointF, Slot
from PySide6.QtGui import QColor, QPainter from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
@@ -24,6 +24,7 @@ from ..record_diagnostics import trial_diagnostics
from ..sensor.uv_outline import recorded_outline from ..sensor.uv_outline import recorded_outline
from .session_review_dialog import SessionReviewDialog from .session_review_dialog import SessionReviewDialog
from .chart_helpers import add_value_axes, clear_chart from .chart_helpers import add_value_axes, clear_chart
from .review_job import ReviewJob
class RecordOverviewPage(QWidget): class RecordOverviewPage(QWidget):
@@ -32,6 +33,7 @@ class RecordOverviewPage(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.overview = None self.overview = None
self._job = None
self.directory = Path.cwd() self.directory = Path.cwd()
self.content_layout = QVBoxLayout(self) self.content_layout = QVBoxLayout(self)
self.open_button = QPushButton(self) self.open_button = QPushButton(self)
@@ -66,8 +68,31 @@ class RecordOverviewPage(QWidget):
self.load_session(Path(path)) self.load_session(Path(path))
def load_session(self, directory: Path): def load_session(self, directory: Path):
if self._job is not None:
return
self._loading_directory = directory
def read():
review = review_session(directory)
diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial)
for trial in review["trials"]}
return review, diagnostics
self.open_button.setEnabled(False)
self.message.setText(t("review.checking"))
self.message.show()
self._job = ReviewJob(read, self._loaded)
self._job.start()
@Slot(object, object)
def _loaded(self, value, error):
self._job = None
self.open_button.setEnabled(True)
if error is not None:
self.message.setText(t("calibration.error").format(detail=error))
self.message.show()
return
directory = self._loading_directory
try: try:
overview = RecordOverviewDialog(directory, self) overview = RecordOverviewDialog(directory, self, review=value[0], diagnostics=value[1])
except (OSError, ValueError, KeyError, TypeError) as exc: except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
self.message.show() self.message.show()
@@ -95,11 +120,13 @@ class RecordOverviewDialog(QDialog):
if self.isWindow(): if self.isWindow():
super().reject() super().reject()
def __init__(self, directory: Path, parent=None): def __init__(self, directory: Path, parent=None, *, review=None, diagnostics=None):
super().__init__(parent) super().__init__(parent)
self.directory = directory self.directory = directory
self.review = review_session(directory) self._job = None
self.diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]} self.review = review if review is not None else review_session(directory)
self.diagnostics = diagnostics if diagnostics is not None else {
trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]}
self.outline, self.outline_state = recorded_outline(self.review) self.outline, self.outline_state = recorded_outline(self.review)
self._uv_bounds = None self._uv_bounds = None
self._uv_label = None self._uv_label = None
@@ -520,25 +547,39 @@ class RecordOverviewDialog(QDialog):
self._refresh() self._refresh()
def _save(self): def _save(self):
try: review, plan = self.review, self.plan
path = save_overview(self.review, self.plan) self._start_job("save", lambda: save_overview(review, plan))
@Slot(object, object)
def _job_finished(self, value, error):
self._job = None
self.setEnabled(True)
if error is not None:
self.message.setText(t("calibration.error").format(detail=error))
self.message.show()
elif self._job_kind == "save":
self._dirty = False self._dirty = False
self._show_response() self._show_response()
self.message.setText(t("overview.saved")) self.message.setText(t("overview.saved"))
self.message.show() self.message.show()
self.message.setToolTip(str(path)) self.message.setToolTip(str(value))
except (OSError, ValueError, KeyError, TypeError) as exc: else:
self.message.setText(t("calibration.error").format(detail=exc)) self.review, self.plan = value
self._dirty = False
self._refresh()
def _start_job(self, kind, action):
if self._job is not None:
return
self._job_kind = kind
self.setEnabled(False)
self.message.setText(t("review.checking"))
self.message.show() self.message.show()
self._job = ReviewJob(action, self._job_finished)
self._job.start()
def _load(self): def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)") path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
if path: if path:
try: directory = self.directory
review, plan = load_overview(self.directory, Path(path)) self._start_job("load", lambda: load_overview(directory, Path(path)))
self.review, self.plan = review, plan
self._dirty = False
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
@@ -0,0 +1,27 @@
"""Run recording reads and integrity checks without blocking Qt's event loop."""
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
class _Result(QObject):
ready = Signal(object, object)
class ReviewJob(QRunnable):
def __init__(self, action, receiver):
super().__init__()
self.action = action
self.result = _Result()
self.result.ready.connect(receiver)
def start(self):
QThreadPool.globalInstance().start(self)
def run(self):
try:
value = self.action()
except Exception as exc:
# Worker failures must return to Qt so controls are always restored.
self.result.ready.emit(None, str(exc))
else:
self.result.ready.emit(value, None)
@@ -6,14 +6,15 @@ from pathlib import Path
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries
from PySide6.QtCore import Qt, QPointF from PySide6.QtCore import Qt, QPointF, Slot
from PySide6.QtGui import QColor, QPainter from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget,
) )
from ..session_review import review_session, save_review, load_review from ..session_review import review_session, save_review, load_review, reselect_review
from .review_job import ReviewJob
from .chart_helpers import add_value_axes, clear_chart from .chart_helpers import add_value_axes, clear_chart
@@ -30,6 +31,8 @@ class SessionReviewDialog(QDialog):
"#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}") "#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}")
self.review = review if review is not None else review_session(directory) self.review = review if review is not None else review_session(directory)
self.directory = directory self.directory = directory
self._job = None
self._job_kind = ""
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.notice = QLabel(t("review.notice"), self) self.notice = QLabel(t("review.notice"), self)
self.notice.setWordWrap(True) self.notice.setWordWrap(True)
@@ -227,9 +230,7 @@ class SessionReviewDialog(QDialog):
} }
row = self.table.currentRow() row = self.table.currentRow()
try: try:
updated = review_session(self.directory, selections=selections) updated = reselect_review(self.review, selections)
if updated["input_sha256"] != self.review["input_sha256"]:
raise ValueError("recorded inputs changed since this review was opened")
self.review = updated self.review = updated
self._show_site() self._show_site()
self.table.selectRow(row) self.table.selectRow(row)
@@ -242,11 +243,8 @@ class SessionReviewDialog(QDialog):
path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"), path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"),
str(self.directory / "derived"), "JSON (*.json)") str(self.directory / "derived"), "JSON (*.json)")
if path: if path:
try: directory = self.directory
self.review = load_review(self.directory, Path(path)) self._start_job("load", lambda: load_review(directory, Path(path)))
self._show_site()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(str(exc))
def _show_uv(self, selected: int) -> None: def _show_uv(self, selected: int) -> None:
clear_chart(self.uv_chart) clear_chart(self.uv_chart)
@@ -280,8 +278,28 @@ class SessionReviewDialog(QDialog):
return "—" if value is None else f"{value:.6g}" return "—" if value is None else f"{value:.6g}"
def _save(self) -> None: def _save(self) -> None:
try: review = self.review
path = save_review(self.review) self._start_job("save", lambda: save_review(review))
self.message.setText(t("review.saved").format(path=path))
except (OSError, ValueError) as exc: def _start_job(self, kind, action):
self.message.setText(t("calibration.error").format(detail=exc)) if self._job is not None:
return
self._job_kind = kind
self._job = ReviewJob(action, self._job_finished)
self.message.setText(t("review.checking"))
for button in (self.save_button, self.load_button, self.apply_button):
button.setEnabled(False)
self._job.start()
@Slot(object, object)
def _job_finished(self, value, error):
self._job = None
for button in (self.save_button, self.load_button, self.apply_button):
button.setEnabled(True)
if error is not None:
self.message.setText(t("calibration.error").format(detail=error))
elif self._job_kind == "load":
self.review = value
self._show_site()
else:
self.message.setText(t("review.saved").format(path=value))
+12
View File
@@ -0,0 +1,12 @@
"""Bounded event-loop waits for recording review tests."""
import time
from PySide6.QtTest import QTest
def wait_review(widget):
deadline = time.monotonic() + 10
while widget._job is not None and time.monotonic() < deadline:
QTest.qWait(10)
assert widget._job is None, "background review did not finish"
@@ -183,6 +183,7 @@ def test_a_point_cannot_be_aligned_in_the_middle_of_a_run(make_window, tmp_path)
def test_next_site_can_be_aligned_between_trials(make_window, tmp_path): def test_next_site_can_be_aligned_between_trials(make_window, tmp_path):
import json
window = make_window() window = make_window()
panel = window.calibration_panel.multipoint panel = window.calibration_panel.multipoint
_add_site(panel, window.finger_stack, site_id="P1") _add_site(panel, window.finger_stack, site_id="P1")
@@ -199,9 +200,18 @@ def test_next_site_can_be_aligned_between_trials(make_window, tmp_path):
panel._selected = "P2" panel._selected = "P2"
panel._refresh() panel._refresh()
assert panel.align_button.isEnabled() assert panel.align_button.isEnabled()
previous = json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text())
window.finger_panel.sensor_z_spin.setValue(12.0)
panel._align() panel._align()
assert len(emitted) == 1 assert len(emitted) == 1
manifest = json.loads((panel.campaign.directory / "session.json").read_text())
assert manifest["sites"][1]["pose"]["z"] == emitted[0]["z"]
panel.campaign.confirm_liftoff()
repeat = json.loads((panel.campaign.directory / "sites/P2/repeat-001/session.json").read_text())
assert repeat["pose"] == manifest["sites"][1]["pose"]
assert json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text()) == previous
panel._selected = "P1" panel._selected = "P1"
panel._align() panel._align()
assert len(emitted) == 1 assert len(emitted) == 1
panel.campaign.stop("test finished")
@@ -15,10 +15,23 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1] APP_ROOT = Path(__file__).resolve().parents[1]
INSTALLED = APP_ROOT / "preference.json" INSTALLED = APP_ROOT / "preference.json"
@pytest.mark.parametrize("stored", [{"calibrated": True}, {"zero_mm": 12.5, "calibrated": True}])
def test_loading_incomplete_press_calibration_does_not_invent_measurements(stored):
import json
from orisys_autocal import preferences
from orisys_autocal.motion.pose_move import PressAxis
Path(preferences.path()).write_text(json.dumps({"press_axis": stored}), encoding="utf-8")
loaded = preferences.load_preferences()
assert PressAxis.from_settings(loaded["press_axis"]).target_for(20) is None
def test_the_preference_path_is_redirected_under_the_scratch_root(): def test_the_preference_path_is_redirected_under_the_scratch_root():
from orisys_autocal.preferences import path from orisys_autocal.preferences import path
@@ -5,6 +5,8 @@ import json
from copy import deepcopy from copy import deepcopy
import pytest import pytest
from review_test_helpers import wait_review
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
from PySide6.QtGui import QAction from PySide6.QtGui import QAction
@@ -155,11 +157,13 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
qt_app.processEvents() qt_app.processEvents()
assert dialog.grab().save(str(tmp_path / "overview-response.png")) assert dialog.grab().save(str(tmp_path / "overview-response.png"))
dialog.findChild(QPushButton, "overview_save").click() dialog.findChild(QPushButton, "overview_save").click()
wait_review(dialog)
saved = next(root.glob("derived/*/review.json")) saved = next(root.glob("derived/*/review.json"))
dialog.findChild(QAction, "overview_clear_plan").trigger() dialog.findChild(QAction, "overview_clear_plan").trigger()
assert dialog.table.rowCount() == 3 assert dialog.table.rowCount() == 3
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), "")) monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), ""))
dialog.findChild(QAction, "overview_load").trigger() dialog.findChild(QAction, "overview_load").trigger()
wait_review(dialog)
assert dialog.table.rowCount() == 4 assert dialog.table.rowCount() == 4
dialog.close() dialog.close()
@@ -174,6 +178,7 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi
window.calibration_panel.review_button.click() window.calibration_panel.review_button.click()
assert window.workspace.current_key() == "records" assert window.workspace.current_key() == "records"
page = window.workspace.page("records") page = window.workspace.page("records")
wait_review(page)
assert len(page.overview.rows) == 3 assert len(page.overview.rows) == 3
assert not page.overview.isWindow() assert not page.overview.isWindow()
window.close() window.close()
@@ -192,10 +197,13 @@ def test_records_tab_browse_error_and_reload(make_window, overview_session, monk
assert page.overview is None and page.path_label.text() assert page.overview is None and page.path_label.text()
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root)) monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root))
page.open_button.click() page.open_button.click()
wait_review(page)
first = page.overview first = page.overview
page.load_session(root / "not-a-session") page.load_session(root / "not-a-session")
wait_review(page)
assert page.overview is first and page.message.text() assert page.overview is first and page.message.text()
page.open_button.click() page.open_button.click()
wait_review(page)
assert page.overview is not first and not page.message.text() assert page.overview is not first and not page.message.text()
window.show() window.show()
qt_app.processEvents() qt_app.processEvents()
+86
View File
@@ -0,0 +1,86 @@
"""Recording integrity work must leave the UI thread free and report failures."""
import threading
from PySide6.QtCore import QObject, QTimer, Slot, QCoreApplication, QEvent, QThreadPool
from PySide6.QtTest import QTest
from orisys_autocal.ui.review_job import ReviewJob
def test_review_job_runs_off_thread_and_keeps_qt_responsive(qt_app):
started, release = threading.Event(), threading.Event()
main_thread = threading.get_ident()
results, ticks = [], []
class Receiver(QObject):
@Slot(object, object)
def receive(self, value, error):
results.append((value, error, threading.get_ident()))
receiver = Receiver()
def action():
started.set()
assert release.wait(3)
return threading.get_ident()
job = ReviewJob(action, receiver.receive)
job.start()
try:
assert started.wait(2)
QTimer.singleShot(0, lambda: ticks.append(True))
QTest.qWait(20)
assert ticks == [True] and results == []
finally:
release.set()
for _ in range(100):
if results:
break
QTest.qWait(10)
assert len(results) == 1
assert results[0][0] != main_thread
assert results[0][1] is None and results[0][2] == main_thread
def test_unexpected_review_job_failure_returns_to_receiver(qt_app):
results = []
class Receiver(QObject):
@Slot(object, object)
def receive(self, value, error):
results.append((value, error))
receiver = Receiver()
def action():
raise RuntimeError("decoder failed")
job = ReviewJob(action, receiver.receive)
job.start()
for _ in range(100):
if results:
break
QTest.qWait(10)
assert results == [(None, "decoder failed")]
def test_review_job_can_finish_after_its_receiver_is_destroyed(qt_app):
started, release, finished = threading.Event(), threading.Event(), threading.Event()
results = []
class Receiver(QObject):
@Slot(object, object)
def receive(self, value, error):
results.append((value, error))
receiver = Receiver()
def action():
started.set()
assert release.wait(3)
finished.set()
return "done"
job = ReviewJob(action, receiver.receive)
job.start()
try:
assert started.wait(2)
receiver.deleteLater()
QCoreApplication.sendPostedEvents(receiver, QEvent.Type.DeferredDelete)
finally:
release.set()
assert QThreadPool.globalInstance().waitForDone(3000)
qt_app.processEvents()
assert finished.is_set() and results == []
@@ -6,6 +6,8 @@ import csv
import json import json
import pytest import pytest
from review_test_helpers import wait_review
from orisys_autocal.calibration_session import write_json from orisys_autocal.calibration_session import write_json
from orisys_autocal.session_review import review_session, save_review from orisys_autocal.session_review import review_session, save_review
@@ -120,6 +122,7 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
dialog.uv_chart.series()[0].clicked.emit(dialog.uv_chart.series()[0].at(0)) dialog.uv_chart.series()[0].clicked.emit(dialog.uv_chart.series()[0].at(0))
assert dialog.sites.currentIndex() == 0 assert dialog.sites.currentIndex() == 0
dialog.save_button.click() dialog.save_button.click()
wait_review(dialog)
assert len(list(session.glob("derived/*/review.json"))) == 1 assert len(list(session.glob("derived/*/review.json"))) == 1
dialog.show() dialog.show()
qt_app.processEvents() qt_app.processEvents()
@@ -177,11 +180,13 @@ def test_operator_confirms_windows_in_dialog_and_reloads_them(qt_app, session, m
assert dialog.review["sites"][0]["windows"][row]["stability_verified"] assert dialog.review["sites"][0]["windows"][row]["stability_verified"]
assert dialog.review["sites"][0]["confirmed_fit"] is not None assert dialog.review["sites"][0]["confirmed_fit"] is not None
dialog.save_button.click() dialog.save_button.click()
wait_review(dialog)
saved = next(session.glob("derived/*/review.json")) saved = next(session.glob("derived/*/review.json"))
fresh = session_review_dialog.SessionReviewDialog(session) fresh = session_review_dialog.SessionReviewDialog(session)
monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName", monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName",
lambda *args: (str(saved), "")) lambda *args: (str(saved), ""))
fresh.load_button.click() fresh.load_button.click()
wait_review(fresh)
assert fresh.review["sites"] == dialog.review["sites"] assert fresh.review["sites"] == dialog.review["sites"]
dialog.close() dialog.close()
fresh.close() fresh.close()
@@ -8,7 +8,9 @@ from pathlib import Path
import pytest import pytest
from orisys_autocal.session_review import review_session, save_review from review_test_helpers import wait_review
from orisys_autocal.session_review import review_session, save_review, reselect_review
REFERENCE_HEADER = ( REFERENCE_HEADER = (
"step_index", "branch", "target_load_n", "t_start_host_mono_ns", "t_end_host_mono_ns", "step_index", "branch", "target_load_n", "t_start_host_mono_ns", "t_end_host_mono_ns",
@@ -33,6 +35,58 @@ CAPTURE_SESSION_ID = 42
BRANCHES = ["zero", "load", "load", "unload", "unload"] BRANCHES = ["zero", "load", "load", "unload", "unload"]
def test_window_adjustment_uses_loaded_traces_and_matches_fresh_review(tmp_path, monkeypatch):
import orisys_autocal.session_review as module
root = _session(tmp_path / "recording")
original = review_session(root)
w = original["sites"][0]["windows"][0]
selections = {f"{w['trial_id']}:{w['step_index']}": {
"t_start_host_mono_ns": w["hold_start_host_mono_ns"] + 10_000_000,
"t_end_host_mono_ns": w["hold_end_host_mono_ns"], "stability_verified": True}}
expected = review_session(root, selections=selections)
monkeypatch.setattr(module, "_rows", lambda *args: pytest.fail("window edit read a file"))
monkeypatch.setattr(module, "_sha256", lambda *args: pytest.fail("window edit hashed a file"))
adjusted = reselect_review(original, selections)
assert adjusted == expected
assert original["selections"] == {} and not w["stability_verified"]
assert adjusted["sites"][0]["windows"][0]["trace"] is w["trace"]
def test_saving_adjusted_review_still_rejects_modified_video(tmp_path):
root = _session(tmp_path / "recording")
adjusted = reselect_review(review_session(root), {})
video = next(root.glob("sites/*/*/capture/raw_0001.mp4"))
video.write_bytes(b"different recorded video")
with pytest.raises(ValueError, match="recorded inputs changed"):
save_review(adjusted)
assert not (root / "derived").exists()
def test_background_save_reports_changed_video_and_recovers_controls(qt_app, tmp_path, monkeypatch):
import threading
from PySide6.QtWidgets import QLabel
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
root = _session(tmp_path / "recording")
dialog = SessionReviewDialog(root)
main_thread = threading.get_ident()
updates = []
set_text = QLabel.setText
def checked_set_text(label, text):
if label is dialog.message:
updates.append(threading.get_ident())
set_text(label, text)
monkeypatch.setattr(QLabel, "setText", checked_set_text)
next(root.glob("sites/*/*/capture/raw_0001.mp4")).write_bytes(b"changed")
dialog.save_button.click()
assert not dialog.save_button.isEnabled() and not dialog.apply_button.isEnabled()
wait_review(dialog)
assert "recorded inputs changed" in dialog.message.text()
assert dialog.save_button.isEnabled() and dialog.apply_button.isEnabled()
assert len(updates) >= 2 and set(updates) == {main_thread}
assert not (root / "derived").exists()
dialog.close()
def _write(path: Path, header, rows) -> None: def _write(path: Path, header, rows) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle: with path.open("w", newline="", encoding="utf-8") as handle: