修复 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
+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):
import json
window = make_window()
panel = window.calibration_panel.multipoint
_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._refresh()
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()
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._align()
assert len(emitted) == 1
panel.campaign.stop("test finished")
@@ -15,10 +15,23 @@ from __future__ import annotations
from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1]
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():
from orisys_autocal.preferences import path
@@ -5,6 +5,8 @@ import json
from copy import deepcopy
import pytest
from review_test_helpers import wait_review
from PySide6.QtWidgets import QPushButton
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()
assert dialog.grab().save(str(tmp_path / "overview-response.png"))
dialog.findChild(QPushButton, "overview_save").click()
wait_review(dialog)
saved = next(root.glob("derived/*/review.json"))
dialog.findChild(QAction, "overview_clear_plan").trigger()
assert dialog.table.rowCount() == 3
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), ""))
dialog.findChild(QAction, "overview_load").trigger()
wait_review(dialog)
assert dialog.table.rowCount() == 4
dialog.close()
@@ -174,6 +178,7 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi
window.calibration_panel.review_button.click()
assert window.workspace.current_key() == "records"
page = window.workspace.page("records")
wait_review(page)
assert len(page.overview.rows) == 3
assert not page.overview.isWindow()
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()
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root))
page.open_button.click()
wait_review(page)
first = page.overview
page.load_session(root / "not-a-session")
wait_review(page)
assert page.overview is first and page.message.text()
page.open_button.click()
wait_review(page)
assert page.overview is not first and not page.message.text()
window.show()
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 pytest
from review_test_helpers import wait_review
from orisys_autocal.calibration_session import write_json
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))
assert dialog.sites.currentIndex() == 0
dialog.save_button.click()
wait_review(dialog)
assert len(list(session.glob("derived/*/review.json"))) == 1
dialog.show()
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]["confirmed_fit"] is not None
dialog.save_button.click()
wait_review(dialog)
saved = next(session.glob("derived/*/review.json"))
fresh = session_review_dialog.SessionReviewDialog(session)
monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName",
lambda *args: (str(saved), ""))
fresh.load_button.click()
wait_review(fresh)
assert fresh.review["sites"] == dialog.review["sites"]
dialog.close()
fresh.close()
@@ -8,7 +8,9 @@ from pathlib import Path
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 = (
"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"]
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:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle: