Files
scan-calibrator/tests/test_scan_calibrator.py
T
2026-09-10 17:06:56 +08:00

244 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
from contextlib import redirect_stderr
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock
import interactive_calibrator as wizard
import scan_calibrator as core
def completed(returncode: int, stdout: str = "", stderr: str = ""):
return subprocess.CompletedProcess(["docker"], returncode, stdout, stderr)
class ApiRecoveryTests(unittest.TestCase):
def setUp(self) -> None:
core._HELPER_READY = False
@mock.patch("scan_calibrator.wait_for_scan_http", return_value=True)
@mock.patch("scan_calibrator.subprocess.run")
def test_transient_curl_failure_waits_and_retries(self, run_mock, wait_mock):
core._HELPER_READY = True
run_mock.side_effect = [
completed(52, stderr="curl: (52) Empty reply from server"),
completed(0, stdout='{"ptz": {"pan": 1, "tilt": 2, "zoom": 3}}'),
]
payload = core.api("GET", "/api/v1/ptz", timeout=5)
self.assertEqual(payload["ptz"]["zoom"], 3)
self.assertEqual(run_mock.call_count, 2)
wait_mock.assert_called_once_with()
self.assertFalse(core._HELPER_READY)
@mock.patch("scan_calibrator.wait_for_scan_http", return_value=True)
@mock.patch("scan_calibrator.subprocess.run")
def test_connection_refused_is_retried(self, run_mock, wait_mock):
run_mock.side_effect = [
completed(7, stderr="curl: (7) Failed to connect to 127.0.0.1"),
completed(0, stdout='{"scan": {"state": "idle"}}'),
]
payload = core.api("GET", "/api/v1/scan/status", timeout=5)
self.assertEqual(payload["scan"]["state"], "idle")
wait_mock.assert_called_once_with()
@mock.patch("scan_calibrator.wait_for_scan_http")
@mock.patch("scan_calibrator.subprocess.run")
def test_non_transient_http_error_is_not_retried(self, run_mock, wait_mock):
run_mock.return_value = completed(
22, stderr="curl: (22) The requested URL returned error: 404"
)
with self.assertRaisesRegex(SystemExit, "404"):
core.api("GET", "/missing", timeout=5)
self.assertEqual(run_mock.call_count, 1)
wait_mock.assert_not_called()
@mock.patch("scan_calibrator.time.sleep")
@mock.patch("scan_calibrator.scan_http_ready", side_effect=[False, True])
@mock.patch("scan_calibrator.time.monotonic", side_effect=[0.0, 1.0, 2.0])
def test_health_wait_polls_until_service_recovers(
self, _monotonic_mock, ready_mock, sleep_mock
):
self.assertTrue(core.wait_for_scan_http(timeout=10.0))
self.assertEqual(ready_mock.call_count, 2)
sleep_mock.assert_called_once_with(core.API_RECOVERY_POLL_INTERVAL)
@mock.patch("scan_calibrator.run")
@mock.patch("scan_calibrator.subprocess.run")
def test_overlap_helper_compiles_only_once_per_session(
self, subprocess_run_mock, checked_run_mock
):
subprocess_run_mock.return_value = completed(0)
core.ensure_helper()
core.ensure_helper()
self.assertEqual(checked_run_mock.call_count, 2)
subprocess_run_mock.assert_called_once_with(
["docker", "exec", "smart-cam", "test", "-x", core.HELPER_CONTAINER_BIN],
text=True, capture_output=True, timeout=10,
)
@mock.patch("scan_calibrator.time.sleep")
@mock.patch("scan_calibrator.measure_images")
@mock.patch("scan_calibrator.snapshot", return_value="candidate.jpg")
@mock.patch("scan_calibrator.move")
def test_restore_failure_does_not_mask_measurement_failure(
self, move_mock, _snapshot_mock, measure_mock, _sleep_mock
):
origin = {"pan": 100, "tilt": 200, "zoom": 300}
move_mock.side_effect = [dict(origin), SystemExit("恢复起点失败")]
measure_mock.side_effect = SystemExit("原始测量失败")
stderr = io.StringIO()
with redirect_stderr(stderr):
with self.assertRaisesRegex(SystemExit, "原始测量失败"):
core.measure_delta(
"pan", 50, 1, 0.0, baseline="baseline.jpg", origin=origin
)
self.assertIn("恢复起点失败", stderr.getvalue())
self.assertEqual(move_mock.call_count, 2)
class InteractiveWizardTests(unittest.TestCase):
def test_manual_ptz_accepts_annotated_display_format(self):
self.assertEqual(
wizard.parse_ptz_input(
"pan=21720 (217.20°), tilt=1268 (12.68°), "
"zoom=4580 (45.80×)"
),
{"pan": 21720, "tilt": 1268, "zoom": 4580},
)
def test_manual_ptz_rejects_incomplete_input(self):
with self.assertRaisesRegex(ValueError, "pan=21720"):
wizard.parse_ptz_input("pan=21720, tilt=1268")
@mock.patch("interactive_calibrator.core.print_ptz")
@mock.patch("interactive_calibrator.camera_ptz")
@mock.patch("interactive_calibrator.read_zoom_after_move")
def test_unreachable_average_uses_nearest_higher_zoom(
self, move_mock, camera_mock, _print_mock
):
origin = {"pan": 23052, "tilt": 1298, "zoom": 4510}
camera_mock.return_value = origin
def quantized(_origin, requested):
return {
**origin,
"zoom": 4550 if requested <= 4565 else 4580,
}
move_mock.side_effect = quantized
actual, average = wizard.move_to_scan_zoom({
"top-left": {**origin, "zoom": 4550},
"bottom-right": {**origin, "zoom": 4580},
})
self.assertEqual(average, 4565)
self.assertEqual(actual["zoom"], 4580)
self.assertEqual(move_mock.call_args_list[0].args[1], 4565)
self.assertEqual(move_mock.call_args_list[-1].args[1], 4580)
@mock.patch("interactive_calibrator.core.print_ptz")
@mock.patch("interactive_calibrator.camera_ptz")
@mock.patch("interactive_calibrator.read_zoom_after_move")
def test_step_four_always_sends_zoom_even_when_already_equal(
self, move_mock, camera_mock, _print_mock
):
origin = {"pan": 100, "tilt": 200, "zoom": 4580}
camera_mock.return_value = origin
move_mock.return_value = origin
actual, _average = wizard.move_to_scan_zoom({
"top-left": origin,
"bottom-right": origin,
})
self.assertEqual(actual["zoom"], 4580)
move_mock.assert_called_once_with(origin, 4580)
def test_partial_session_is_saved_to_history(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
history = root / "history"
state_file = root / "calibration.json"
with (
mock.patch.object(wizard, "HISTORY_DIR", history),
mock.patch.object(wizard, "ACTIVE_HISTORY_FILE", None),
mock.patch.object(core, "STATE_FILE", state_file),
):
state = wizard.start_history_session("hanging", None)
state["points"]["top-left"] = {
"pan": 21720,
"tilt": 1268,
"zoom": 4580,
}
wizard.save_working_state(state)
records = wizard.load_history_records()
self.assertEqual(len(records), 1)
self.assertTrue(
wizard.state_item_exists(records[0][1], "points", "top-left")
)
self.assertFalse(
wizard.state_item_exists(records[0][1], "points", "bottom-right")
)
class GenerateZoomTests(unittest.TestCase):
def test_generate_uses_camera_reachable_scan_zoom(self):
state = {
"points": {
"top-left": {"pan": 1000, "tilt": 200, "zoom": 4550},
"bottom-right": {"pan": 900, "tilt": 100, "zoom": 4580},
},
"steps": {
"pan": {
"value": 50,
"overlap": 0.04,
"target_overlap": 0.04,
"overlap_method": "axis_shift_v1_retargeted",
"inliers": 50,
"visual_direction": "decreasing",
},
"tilt": {
"value": 50,
"overlap": 0.04,
"target_overlap": 0.04,
"overlap_method": "axis_shift_v1_retargeted",
"inliers": 50,
},
},
"scan_zoom": 4580,
}
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
with (
mock.patch.object(core, "ROOT", root),
mock.patch.object(core, "load_state", return_value=state),
):
with mock.patch("sys.stdout", new=io.StringIO()):
core.command_generate(argparse.Namespace(mount="desktop"))
result = next((root / "result").glob("scan_config_*.json"))
generated = json.loads(result.read_text(encoding="utf-8"))
self.assertEqual(generated["scan"]["zoom"], 4580)
if __name__ == "__main__":
unittest.main()