修复 Autocal 压头配置、姿态保存与复核性能
阻止不完整标定配置生成 Z 目标,同步当前测点姿态,并将复核文件校验移到后台。通过 476 项全量测试、53 项最终定向复验和代码检查。
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user