Files
ykcheungab bc5dfc035d 修正采集设置对话框输入面板被压缩重叠
对话框把输入面板最大高度限制为 320,低于各行所需的自然高度,Finger 视频与传感器配置两行重叠。改为只限制预览图高度,并新增布局测试;已在 Windows 上目视确认。
2026-10-19 09:42:41 +08:00

758 lines
31 KiB
Python

"""The capture page end to end: one queue, one primary button, one message line.
These are interface tests in the literal sense: they click the widgets an operator
clicks, in the order the controller offers them, and check the files the session
wrote. The platform move is stubbed at the window's move planner (the real move is
hardware-adjacent and already covered elsewhere); everything else runs as shipped.
"""
from __future__ import annotations
import csv
import json
import re
import time
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_app_shared.i18n import t
from orisys_autocal.sensor.uv_flat import FlatSurface
POINT = (4.872, 5.444, 16.176)
NORMAL = (0.595, 0.673, 0.439)
#: The only direct Campaign calls the frozen contract keeps in ``ui/``: the
#: recorder's lifecycle is not a workflow decision and not rendered data.
_ALLOWED_CAMPAIGN_MEMBERS = frozenset({"attach_capture", "flush_close"})
def _campaign_accesses(source: str) -> list[str]:
"""Every ``campaign.<member>`` access in one module's source, in order."""
return [match.group(1) for match in re.finditer(r"campaign\.(\w+)", source)]
def _campaign_offenders(directory) -> list[str]:
"""UI modules reaching past the controller for campaign state or actions."""
offenders = []
for path in sorted(directory.rglob("*.py")):
for member in _campaign_accesses(path.read_text(encoding="utf-8")):
if member not in _ALLOWED_CAMPAIGN_MEMBERS:
offenders.append(f"{path.name}: campaign.{member}")
return offenders
def _flat() -> FlatSurface:
"""A stand-in rasterised surface: every pixel is the same measured point."""
size = 8
positions = np.tile(np.asarray(POINT, dtype=np.float64), (size, size, 1))
normals = np.tile(np.asarray(NORMAL, dtype=np.float64), (size, size, 1))
return FlatSurface(
size_hw=(size, size),
image_rgb=np.zeros((size, size, 3), dtype=np.uint8),
positions_mm=positions,
normals=normals,
face_index=np.zeros((size, size), dtype=np.int32),
uv_min=(0.0, 0.0),
uv_max=(1.0, 1.0),
)
def _pump(app, seconds: float = 0.3) -> None:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
def _page(
window,
qt_app,
tmp_path,
*,
max_force: str = "20",
align: bool = True,
flat: bool = True,
):
"""A capture page whose readiness passes in simulation mode.
``align=True`` (the default) makes the page behave as it does with a platform
attached: the point must be aligned before a session may start, so the tests
that are about the align step keep covering it. ``align=False`` is the page as
it is with nothing connected, and ``flat=False`` leaves out the calibration
bundle too.
"""
window.resize(1440, 900)
window.show()
window.workspace.set_current_key("sensor")
qt_app.processEvents()
page = window.calibration_panel
page.settings.simulate.setChecked(True)
page.settings.sensor_sn.setText("SN1")
page.settings.max_force.setText(max_force)
page.settings.output_path.setText(str(tmp_path))
if align:
# The page treats a simulation as needing no move; these tests are about
# the step that does, so they ask for the platform-attached behaviour.
page._simulating = lambda: False # noqa: SLF001 - the seam under test
if flat:
page.set_flat(_flat(), "bundle")
# A hold of a few hundredths of a second keeps the test fast; the session
# contract is the same.
page.hold.setRange(0.05, 30)
page.hold.setValue(0.05)
page.levels.setText("5")
page.repeats.setValue(1)
return page
def _started_page(window, qt_app, tmp_path, *, sites=("P1", "P2")):
"""Two points added through the page, first aligned, session started."""
_stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path)
for index, site_id in enumerate(sites):
_add_point(page, site_id, index, index, qt_app)
_click_primary(page, qt_app) # align the first point
assert page.controller.primary_action().key == "start_session"
_click_primary(page, qt_app) # start the session
assert page.controller.poll()["phase"] == "load"
return page
def _stub_moves(window, *, succeeds: bool = True) -> list[dict]:
"""Replace the window's move planner: record the plan, report the outcome."""
plans: list[dict] = []
def fake_start(plan, title, *, report=None):
plans.append({"title": title, "roles": [step.role for step in plan.steps]})
if succeeds:
window.calibration_panel.alignment_succeeded()
else:
window.calibration_panel.alignment_failed("refused by the test")
window._start_move = fake_start # noqa: SLF001 - the seam under test is the page
return plans
def _click_primary(page, qt_app, timeout: float = 5.0) -> str:
"""Press the primary button once, after filling whatever it needs first."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
qt_app.processEvents()
action = page.controller.primary_action()
if action.enabled and not (
action.key == "confirm_hold" and page.controller.poll()["remaining_s"] > 0
):
# The page redraws on its own timer; read the button the operator would
# click, not just the controller's answer, or a fast step races the redraw.
page._refresh() # noqa: SLF001 - the widget state is what is under test
if not page.primary_button.isEnabled():
_pump(qt_app, 0.05)
continue
if action.key == "confirm_target":
target = page.controller.model().progress.get("target_n") or 0.0
page.reference.setText(f"{float(target):g}")
page.primary_button.click()
_pump(qt_app, 0.1)
return action.key
_pump(qt_app, 0.05)
raise AssertionError(
f"the primary action never became available: "
f"{page.controller.primary_action()} / {page.message.text()}"
)
def _add_point(page, site_id: str, u: int, v: int, qt_app) -> None:
page.u_spin.setValue(u)
page.v_spin.setValue(v)
page.site_id.setText(site_id)
page.add_point_button.click()
_pump(qt_app, 0.05)
def test_two_point_capture_walkthrough_in_simulation(make_window, qt_app, tmp_path):
"""Align, start, load every step, hold, liftoff, align, and finish - by clicking."""
window = make_window(move_confirm=False)
plans = _stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 1, 1, qt_app)
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
# The first point must be aligned before the session may start.
assert page.controller.primary_action().key == "align_point"
assert t("cap.action.align") == page.primary_button.text()
_click_primary(page, qt_app)
# The press axis is held because its relation to a contact height is not
# calibrated on the test bench; the four movable roles still travel together.
assert plans and plans[-1]["roles"] == ["yaw", "pitch", "x", "y"]
assert page.controller.primary_action().key == "start_session"
assert t("cap.action.start") == page.primary_button.text()
_click_primary(page, qt_app) # start the session
state = page.controller.poll()
assert state["phase"] == "load"
# Starting a capture does not switch the workspace page.
assert window.workspace.current_key() == "sensor"
# The recording options are locked once the session exists.
assert not page.settings.video_check.isEnabled()
assert not page.settings.locked_label.isHidden()
assert page.settings.locked_label.text() == t("cap.recording.locked")
seen: list[str] = []
for _ in range(60):
action = page.controller.primary_action()
seen.append(action.key)
if action.key == "finish_session":
_click_primary(page, qt_app)
break
_click_primary(page, qt_app)
else: # pragma: no cover - a stuck walkthrough is the failure to report
raise AssertionError(f"the session never finished: {seen[-6:]}")
# The hold ends by itself at its planned time, so the button may or may not be
# caught showing "confirm hold" between two ticks; the order of the steps is
# what matters.
assert seen[0] == "confirm_target"
assert seen.count("confirm_target") >= 3
# Two points, so the second one needs its own alignment between them.
assert "align_point" in seen
assert "confirm_liftoff" in seen
directory = page._session_directory() # noqa: SLF001 - the recorded folder under test
assert directory is not None
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["status"] == "complete"
assert [site["site_id"] for site in manifest["sites"]] == ["P1", "P2"]
assert [trial["status"] for trial in manifest["trials"]] == ["complete", "complete"]
assert (directory / "stable_means.csv").is_file()
# A simulated run writes no video; that is the recorded state, not a failure.
assert page.controller.poll()["capture_state"] == "not_recorded"
# The end of the session publishes the candidate summary (P1's flat copy).
deadline = time.monotonic() + 20.0
while not (directory / "summary-latest.json").is_file() and time.monotonic() < deadline:
_pump(qt_app, 0.05)
assert (directory / "summary-latest.json").is_file()
window.close()
def test_a_simulation_runs_with_nothing_connected(make_window, qt_app, tmp_path):
"""No bundle, no platform, no camera: type two points, start, finish, go again."""
window = make_window(move_confirm=False)
plans = _stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path, align=False, flat=False)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 3, 4, qt_app)
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
# Every ready check passes without a bundle, a bus or an input frame.
assert all(check.ok for check in page.controller.readiness()), [
check for check in page.controller.readiness() if not check.ok
]
# No align step stands between the queue and the session.
assert page.controller.primary_action().key == "start_session"
assert t("cap.action.start") == page.primary_button.text()
_click_primary(page, qt_app) # start the session
assert page.controller.poll()["phase"] == "load"
seen: list[str] = []
for _ in range(60):
action = page.controller.primary_action()
seen.append(action.key)
_click_primary(page, qt_app)
if action.key == "finish_session":
break
else: # pragma: no cover - a stuck walkthrough is the failure to report
raise AssertionError(f"the session never finished: {seen[-6:]}")
assert "align_point" not in seen
assert "confirm_liftoff" in seen
assert plans == [] # nothing asked the window to move an axis
directory = page._session_directory() # noqa: SLF001
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["simulate"] is True
assert manifest["status"] == "complete"
assert {site["pose"]["normal_aligned_to"] for site in manifest["sites"]} == {
"simulated"
}
# Closing out leaves the page ready to run the same queue again.
assert page.controller.poll()["phase"] == "idle"
assert page.controller.primary_action().key == "start_session"
window.close()
def test_a_refused_alignment_keeps_start_disabled(make_window, qt_app, tmp_path):
"""A move the operator did not confirm must never unlock "start capture"."""
window = make_window(move_confirm=False)
_stub_moves(window, succeeds=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_click_primary(page, qt_app) # align: the window refuses the move
assert page.controller.primary_action().key == "align_point"
assert page.controller.primary_action().key != "start_session"
assert t("cap.action.start") != page.primary_button.text()
assert t("cap.message.align_failed", detail="refused by the test") in page.message.text()
# A retry whose move is confirmed is what unlocks the session.
_stub_moves(window, succeeds=True)
_click_primary(page, qt_app)
assert page.controller.primary_action().key == "start_session"
window.close()
def test_the_capture_page_never_reaches_past_the_controller():
"""P4 exit condition: ``ui/`` renders controller output, not campaign state.
The guard is a member-access scan rather than a fixed string list: any new
``campaign.<member>`` in a UI module is a finding unless it is one of the two
recorder-lifecycle methods the frozen contract keeps (interfaces 1.11). That
covers ``phase``, ``sites``, ``_*``, every queue edit and every flow action —
including ones added later.
"""
from pathlib import Path
ui = Path(__file__).resolve().parents[1] / "src" / "orisys_autocal" / "ui"
assert _campaign_offenders(ui) == []
def test_the_campaign_guard_catches_a_regression():
"""The guard above must fail on a forbidden access, not merely pass by luck."""
assert _campaign_accesses("x = self.campaign.sites\n") == ["sites"]
assert _campaign_accesses("if self.campaign.phase == 'idle':") == ["phase"]
assert _campaign_accesses("self.campaign._skipped") == ["_skipped"]
assert _campaign_accesses("self.campaign.add_solved_site(a)") == ["add_solved_site"]
assert _campaign_accesses("self.campaign.confirm_reference(1)") == ["confirm_reference"]
assert _campaign_accesses("self.campaign.attach_capture(w)") == ["attach_capture"]
for member in (
"phase",
"sites",
"_skipped",
"add_solved_site",
"update_site",
"remove_site",
"move_site",
"store_solved_pose",
"set_force_rotation",
"confirm_reference",
"complete_hold",
"stop",
"remeasure_step",
"skip_current_point",
):
assert member not in _ALLOWED_CAMPAIGN_MEMBERS, member
def test_unavailable_codecs_are_disabled_with_their_reason(make_window):
"""A codec this machine cannot open is refused here, never silently swapped."""
window = make_window()
settings = window.calibration_panel.settings
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
model = settings.codec.model()
for index in range(settings.codec.count()):
item = model.item(index)
codec = settings.codec.itemData(index)
assert item.isEnabled() is (codec == "mp4v")
assert settings.codec.currentData() == "mp4v"
for codec in ("avc1", "H264"):
assert t("cap.recording.unavailable", codec=codec) in settings.codec_reason.text()
window.close()
def test_the_size_estimate_warns_below_twice_the_planned_volume(make_window, monkeypatch):
from orisys_autocal.ui import capture_settings_dialog as module
window = make_window()
settings = window.calibration_panel.settings
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
settings.video_check.setChecked(True)
monkeypatch.setattr(
module.shutil,
"disk_usage",
lambda path: SimpleNamespace(total=0, used=0, free=1_000_000_000),
)
# One hour of mp4v at the measured bench bitrate is far more than 1 GB.
settings.set_plan(3600.0)
assert t("cap.recording.disk_short") in settings.estimate.text()
assert "12.15 GB" in settings.estimate.text()
monkeypatch.setattr(
module.shutil,
"disk_usage",
lambda path: SimpleNamespace(total=0, used=0, free=10**14),
)
settings.set_plan(3601.0)
assert t("cap.recording.disk_short") not in settings.estimate.text()
# Recording no video makes the size question disappear, not a zero.
settings.video_check.setChecked(False)
assert settings.estimate.text().startswith(t("cap.recording.off"))
window.close()
def test_a_session_recorded_without_video_says_so_in_the_review(qt_app, tmp_path):
from evidence_helpers import multipoint_session
from orisys_autocal.session_review import review_session
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
root = multipoint_session(
tmp_path / "quiet",
sites=("P1",),
repeats=1,
levels=(5.0,),
recording_block={"video": False, "codec": "mp4v", "lossless_frames": False},
)
review = review_session(root)
assert review["recording"]["video"] is False
dialog = RecordOverviewDialog(root, review=review)
try:
assert t("overview.video_not_recorded") in dialog.identity.text()
finally:
dialog.close()
# A session that kept its video says nothing about a missing one.
with_video = multipoint_session(
tmp_path / "loud", sites=("P1",), repeats=1, levels=(5.0,)
)
dialog = RecordOverviewDialog(with_video, review=review_session(with_video))
try:
assert t("overview.video_not_recorded") not in dialog.identity.text()
finally:
dialog.close()
def test_the_page_refuses_start_until_the_ready_checks_pass(make_window, qt_app):
"""Every ✗ check names itself and blocks the start with a stated reason."""
window = make_window(move_confirm=False)
page = window.calibration_panel
window.workspace.set_current_key("sensor")
qt_app.processEvents()
action = page.controller.primary_action()
reason = t(action.blocked_reason)
assert action.enabled is False
assert page.primary_reason.text() == reason
checks = {check.key: check for check in page.controller.readiness()}
assert checks["max_force"].ok is False
assert checks["sensor_sn"].ok is False
for key in ("calibration_bundle", "input_frames", "bus_ready"):
assert checks[key].ok is False
assert page.ready_labels[key].text().startswith("✗")
# Filling one fact turns only that chip over.
page.settings.sensor_sn.setText("SN1")
checks = {check.key: check for check in page.controller.readiness()}
assert checks["sensor_sn"].ok is True
assert page.controller.primary_action().enabled is False
window.close()
def test_a_point_needs_a_maximum_force_and_the_controller_is_told(
make_window, qt_app, tmp_path
):
"""The queue edit is the controller's, including its maximum-force refusal."""
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path, max_force="")
page._refresh() # noqa: SLF001 - the page's own render is the unit under test
page.add_point_button.click()
_pump(qt_app, 0.1)
assert page.controller.model().sites == []
assert page.message.text().endswith(t("cap.block.no_max_force"))
# The dialog value reaches the controller through set_max_force, not a rebuild.
page.settings.max_force.setText("20")
page._refresh() # noqa: SLF001
checks = {check.key: check for check in page.controller.readiness()}
assert checks["max_force"].ok is True
_add_point(page, "P1", 0, 0, qt_app)
assert [row.site_id for row in page.controller.model().sites] == ["P1"]
window.close()
def test_a_skipped_point_is_rendered_from_the_controller_row(
make_window, qt_app, tmp_path
):
"""The skip mark is the controller's; the page only draws ``SiteRow.skipped``."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path)
assert page.controller.model().sites[0].skipped is False
page._refresh() # noqa: SLF001 - render the freshly enabled button
page.skip_button.click()
_pump(qt_app, 0.2)
assert page.controller.model().sites[0].skipped is True
assert page.queue_table.item(0, 4).text() == t("cap.queue.state_skipped")
assert page.message.text().endswith(t("cap.message.skipped", site="P1"))
window.close()
def test_the_stop_button_leaves_the_reason_to_the_controller(
make_window, qt_app, tmp_path
):
"""The stop button passes no reason; the controller records the default one."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
directory = page._session_directory() # noqa: SLF001
page._refresh() # noqa: SLF001 - render the freshly enabled button
page.stop_button.click()
_pump(qt_app, 0.2)
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["reason"] == "operator stopped"
assert page.controller.primary_action().key == "finish_session"
window.close()
def test_closing_the_window_records_the_application_reason(
make_window, qt_app, tmp_path
):
"""Window shutdown names its own reason, so the data can tell the two apart."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
directory = page._session_directory() # noqa: SLF001
window.close()
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert manifest["reason"] == "application closed"
def test_updating_a_point_retakes_it_and_keeps_its_queue_position(
make_window, qt_app, tmp_path
):
"""A retaken point goes through the controller and stays where the operator put it."""
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
for index, site_id in enumerate(("P1", "P2", "P3")):
_add_point(page, site_id, index, index, qt_app)
page.queue_table.selectRow(1) # the operator's row selection
qt_app.processEvents()
assert page._selected_site == "P2" # noqa: SLF001
page.u_spin.setValue(5)
page.v_spin.setValue(5)
page.site_id.setText("P2b")
page.update_point_button.click()
_pump(qt_app, 0.2)
rows = page.controller.model().sites
assert [row.site_id for row in rows] == ["P1", "P2b", "P3"]
assert rows[1].site_id == "P2b"
assert rows[1].uv_px is not None
assert page._selected_site == "P2b" # noqa: SLF001
window.close()
# --------------------------------------------------- P5: geometry, sheet, reach
def _drive_to_liftoff(page) -> None:
"""Finish the first point's repeat directly, leaving the session at liftoff."""
campaign = page.controller.campaign
while campaign.phase == "load":
target = campaign.progress()["target_n"]
campaign.confirm_reference(target)
campaign.complete_hold(
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
)
assert campaign.phase == "liftoff"
def test_changing_the_mounting_resolves_every_queued_pose(make_window, qt_app, tmp_path):
"""stackChanged re-solves the queue and writes the fresh poses back while idle."""
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 1, 1, qt_app)
before = {row.site_id: row.pose["z"] for row in page.controller.model().sites}
spin = window.finger_panel.sensor_z_spin
spin.setValue(spin.value() + 4.0) # the operator changes the mounting height
_pump(qt_app, 0.2)
rows = page.controller.model().sites
assert all(row.reachable for row in rows)
for row in rows:
assert row.pose["z"] != pytest.approx(before[row.site_id]), row.site_id
fresh = page._solve_uv(*row.uv_px)[2] # noqa: SLF001 - the solver's answer
assert row.pose["z"] == pytest.approx(fresh["z"])
window.close()
def test_an_unreachable_point_is_marked_and_cannot_be_aligned(
make_window, qt_app, tmp_path
):
"""A travel limit that makes a point unreachable disables its align, visibly."""
window = make_window(move_confirm=False)
emitted = _stub_moves(window, succeeds=True)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
assert page.controller.model().sites[0].reachable is True
emitted.clear()
window.finger_panel.stack.yaw_limit_deg = 0.0 # the stage can no longer turn
window.finger_panel.stackChanged.emit()
_pump(qt_app, 0.2)
row = page.controller.model().sites[0]
assert row.reachable is False
assert page.queue_table.item(0, 3).text() == t("cap.queue.unreachable")
action = page.controller.primary_action()
assert (action.key, action.enabled) == ("align_point", False)
assert action.blocked_reason == "cap.block.no_pose"
page._align() # noqa: SLF001 - a direct call must be refused too
assert not emitted
assert t("cap.block.pose_unreachable") in page.message.text()
window.close()
def test_a_frozen_queue_is_not_rewritten_when_the_mounting_changes(
make_window, qt_app, tmp_path
):
"""Between trials the queue is frozen: reachability is shown, poses are not stored."""
window = make_window(move_confirm=False)
page = _started_page(window, qt_app, tmp_path)
_drive_to_liftoff(page)
page._refresh() # noqa: SLF001
recorded = page.controller.model().sites[1].pose["z"]
spin = window.finger_panel.sensor_z_spin
spin.setValue(spin.value() + 4.0)
_pump(qt_app, 0.2)
rows = page.controller.model().sites
assert rows[1].pose["z"] == pytest.approx(recorded) # nothing written back
assert page._live_reachable # noqa: SLF001 - the display-only answer exists
fresh = page._solve_uv(*rows[1].uv_px)[2] # noqa: SLF001
assert page._live_reachable[rows[1].site_id] == bool( # noqa: SLF001
fresh["ok"] and fresh["in_range"]
)
window.close()
def test_the_point_sheet_matches_the_documented_fields(make_window, qt_app, tmp_path):
"""P5 exit: the operation table's fields are the data-format document's, in order."""
from orisys_autocal.sensor.uv_flat import mapping_sha256, uv_origin
from orisys_autocal.ui.site_export import OPERATION_TABLE_FIELDS, write_operation_table
document = (
Path(__file__).resolve().parents[1]
/ "docs"
/ "calibration-data-format.zh.md"
).read_text(encoding="utf-8")
# The document spells out the first two blocks; the rest are described as a group.
explicit = (
"order", "site_id", "u_px", "v_px",
"x_sensor_mm", "y_sensor_mm", "z_sensor_mm",
"x_platform_mm", "y_platform_mm", "z_platform_mm",
"yaw_deg", "pitch_deg",
"origin_raster_x_px", "origin_raster_y_px",
)
assert OPERATION_TABLE_FIELDS[: len(explicit)] == explicit
for field in explicit:
assert field in document, f"{field} is not named in the format document"
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
_add_point(page, "P2", 1, 1, qt_app)
target = tmp_path / "points.csv"
write_operation_table(target, page.controller.model().sites, page._flat)
with target.open(encoding="utf-8-sig", newline="") as handle:
header = next(csv.reader(handle))
written = list(csv.DictReader(target.open(encoding="utf-8-sig", newline="")))
assert tuple(header) == OPERATION_TABLE_FIELDS
assert [row["site_id"] for row in written] == ["P1", "P2"]
origin = uv_origin(page._flat)
assert int(written[0]["origin_raster_x_px"]) == origin[0]
assert int(written[0]["origin_raster_y_px"]) == origin[1]
assert int(written[0]["raster_width_px"]) == page._flat.width
assert int(written[0]["raster_height_px"]) == page._flat.height
assert written[0]["u_positive"] == "right"
assert written[0]["v_positive"] == "up"
assert written[0]["mapping_sha256"] == mapping_sha256(page._flat)
for record, row in zip(written, page.controller.model().sites):
assert float(record["x_sensor_mm"]) == pytest.approx(row.position["x"])
assert float(record["x_platform_mm"]) == pytest.approx(row.pose["x"])
assert float(record["yaw_deg"]) == pytest.approx(row.pose["yaw"])
# A recorded coordinate that no longer matches the map refuses the sheet.
page.controller.campaign.sites[0].x += 0.5
with pytest.raises(ValueError):
write_operation_table(tmp_path / "invalid.csv", page.controller.model().sites, page._flat)
assert not (tmp_path / "invalid.csv").exists()
window.close()
def test_the_export_button_writes_the_sheet_and_reports_it(
make_window, qt_app, tmp_path, monkeypatch
):
from orisys_autocal.ui import calibration_panel as module
window = make_window(move_confirm=False)
page = _page(window, qt_app, tmp_path)
_add_point(page, "P1", 0, 0, qt_app)
target = tmp_path / "sheet.csv"
monkeypatch.setattr(
module.QFileDialog,
"getSaveFileName",
lambda *args, **kwargs: (str(target), ""),
)
page.export_sheet_button.click()
_pump(qt_app, 0.1)
assert target.is_file()
assert page.message.text() == t("cap.queue.export_saved", path=str(target))
# The refusal path names the point and writes nothing.
page.controller.campaign.sites[0].x += 0.5
page.export_sheet_button.click()
_pump(qt_app, 0.1)
assert t("cap.queue.export_edited_point", order=1) in page.message.text()
window.close()
def test_a_failed_summary_copy_is_reported_not_called_published(make_window):
"""The copy under summary/ is a convenience; a failure must not read as success."""
window = make_window()
page = window.calibration_panel
detail = "the readable copy under summary/ was not written: no space left"
page._summary_warnings = [detail]
page._summary_finished("/some/published/summary", None)
assert page.message.text() == t("summary.copy_failed").format(detail=detail)
window.close()
def test_a_stored_unavailable_codec_does_not_become_the_selection(make_window):
"""Loading stored options must not re-select a codec the probe disabled."""
window = make_window()
settings = window.calibration_panel.settings
settings.prefs["recording"] = {
"video": True,
"codec": "avc1",
"lossless_frames": False,
}
# The dialog probes first and loads the stored options second; that order is
# what used to put the disabled avc1 back into the combo.
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
settings._load_preferences()
assert settings.codec.currentData() == "mp4v"
assert settings.recording_options().codec == "mp4v"
window.close()
def test_the_settings_dialog_does_not_squeeze_the_input_rows(make_window, qt_app):
"""The input panel keeps its natural height: no row may overlap the next one."""
window = make_window()
page = window.calibration_panel
dialog = page.settings
dialog.resize(720, 700)
dialog.show()
for _ in range(5):
qt_app.processEvents()
panel = window.finger_source_panel
assert panel.height() >= panel.minimumSizeHint().height()
video = panel.video_path.geometry()
config = panel.config_ref.geometry()
assert video.bottom() < config.top(), (video, config)
# The preview stays bounded, so the sections below remain reachable.
assert panel.preview.height() <= 200
dialog.close()
window.close()