"""Pre-scan angle inspection, driven entirely by fake hardware. The feature's defining constraint is that it reads nothing back from the scope — the operator looks at the instrument. These tests pin that, the scope state the app is responsible for putting the instrument into, and the motion sequence across angles. """ import random import pytest from core.angle_inspect import AngleInspector, InspectCallbacks from core.rotation import RotationAxis, RotationSettings from core.scan_engine import AXIS_X, AXIS_Y from core.scan_geometry import build_plan from core.scope_inspect import ( BIAS_CHANNELS, BIAS_POSITION_DIV, BIAS_SCALE_V_DIV, BIAS_WINDOW_V, INSPECT_TRIG_LEVEL_V, inspect_channel_profiles, ) from core.scope_sras import SRAS_CHANNELS from fakes import FakeScope, FakeStage, FakeT3R, Trace SPF = 8 def make_plan(num_angles=3): return build_plan(40.0, 30.0, 2.0, 1.0, num_angles, 0.25, laser_freq_hz=20000.0, velocity_mm_s=100.0) def build(num_angles=3, seed=1234, callbacks=None, rotator_open=True): trace = Trace() scope = FakeScope(trace, samples_per_frame=SPF) stage = FakeStage(trace, scope=scope) t3r = FakeT3R(trace, is_open=rotator_open) rotator = RotationAxis(t3r, RotationSettings()) plan = make_plan(num_angles) insp = AngleInspector(stage, scope, rotator, plan, callbacks=callbacks or InspectCallbacks(), rng=random.Random(seed)) return insp, trace, plan def writes(trace): return [c[1] for c in trace.of("write")] # ── The defining constraint ────────────────────────────────────────────────── def test_inspection_never_reads_a_waveform_back(): """The operator reads the scope; the app must not pull data off it. If this fails, someone has added a transfer path to a feature whose whole premise is that there isn't one. """ insp, trace, plan = build() insp.start() for i in range(plan.n_angles): insp.goto_angle(i) insp.new_point() insp.stop() forbidden = {"transfer_fastframe", "transfer_fastframe_bulk", "transfer_curve", "set_data_source", "query_wfmoutpre"} assert forbidden.isdisjoint(set(trace.names())) assert "CURVe?" not in writes(trace) # ── Scope configuration ────────────────────────────────────────────────────── def test_start_sets_an_edge_trigger_on_ch2_above_the_scan_level(): insp, trace, _ = build() insp.start() assert "TRIGger:A:TYPe EDGE" in writes(trace) assert trace.of("set_trigger_source")[-1][1] == 2 assert trace.of("set_trigger_slope")[-1][1] == "RISE" ch, level = trace.of("set_trigger_level")[-1][1:3] assert (ch, level) == (2, INSPECT_TRIG_LEVEL_V) assert INSPECT_TRIG_LEVEL_V >= 2.0 def test_start_disables_fastframe_averaging_and_the_logic_trigger(): """Everything the scan needs and inspection must not inherit.""" insp, trace, _ = build() insp.start() assert trace.of("set_fastframe_state")[-1][1] is False assert trace.of("set_acquire_mode")[-1][1] == "SAMPLE" w = writes(trace) assert not any("LOGIc" in cmd or "LOGICPattern" in cmd for cmd in w) def test_start_leaves_the_acquisition_free_running(): """The display has to keep updating while the operator looks at it.""" insp, trace, _ = build() insp.start() w = writes(trace) assert "ACQuire:STOPAfter RUNSTop" in w assert w.index("ACQuire:STOPAfter RUNSTop") < w.index("ACQuire:STATE RUN") assert "ACQuire:STATE STOP" not in w def test_bias_channels_are_directly_comparable(): """CH3/CH4 must share scale and position or the eye comparison is a lie.""" profiles = inspect_channel_profiles() a, b = (profiles[ch] for ch in BIAS_CHANNELS) assert a.scale_v_div == b.scale_v_div assert a.position_div == b.position_div # Same front end as the scan records — only the display changes. for ch in BIAS_CHANNELS: assert profiles[ch].termination_ohm == SRAS_CHANNELS[ch].termination_ohm assert profiles[ch].coupling == SRAS_CHANNELS[ch].coupling assert profiles[ch].bandwidth_hz == SRAS_CHANNELS[ch].bandwidth_hz @pytest.mark.parametrize("n_divisions", [8, 10]) def test_bias_window_shows_zero_to_700mv_with_headroom(n_divisions): """0–700 mV must fit on screen, above ground, on either graticule size. Ground sits BIAS_POSITION_DIV divisions below centre, so the visible window runs from (-N/2 - pos)*scale to (+N/2 - pos)*scale. """ half = n_divisions / 2 bottom = (-half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV top = (half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV assert bottom < 0.0, "no room below ground for undershoot" assert top > BIAS_WINDOW_V, "700 mV is clipped or sitting on the top edge" # The point of moving the trace down: most of the screen is above ground. assert abs(bottom) < top def test_ch1_keeps_the_acquisition_front_end(): """What you see at a point is what a scan would record there.""" assert inspect_channel_profiles()[1] == SRAS_CHANNELS[1] # ── Stage and rotation ─────────────────────────────────────────────────────── def test_start_parks_on_the_first_angle(): insp, _, plan = build() point = insp.start() assert point.angle_idx == 0 assert point.angle_deg == plan.per_angle[0].angle_deg assert insp.current_point == point def test_the_gate_is_off_for_the_whole_inspection(): """Nothing here is gated, and an armed output keeps driving the line.""" insp, trace, _ = build() insp.start() insp.goto_angle(2) insp.new_point() assert trace.count("set_trigger_gate_off") >= 1 assert trace.count("set_trigger_trigout_maxv") == 0 assert [c[2] for c in trace.of("arm_scan_gate") if c[2]] == [] def test_points_land_on_the_scan_grid(): """A point the scan would never sample tells you nothing about the scan.""" insp, _, plan = build() insp.start() for i in range(plan.n_angles): pa = plan.per_angle[i] for _ in range(5): pt = insp.new_point() if insp.angle_idx == i else insp.goto_angle(i) assert pt.angle_idx == i assert pt.y_mm in pa.y_positions assert pa.x_start <= pt.x_mm <= pa.x_start + pa.x_delta def test_goto_angle_rotates_then_moves(): insp, trace, plan = build() insp.start() trace.calls.clear() insp.goto_angle(2) # t3r_rotate carries the delta, so assert the resulting absolute angle. assert trace.count("t3r_rotate") == 1, "expected exactly one rotation" assert insp._rotator.current_deg == pytest.approx(plan.per_angle[2].angle_deg) moves = trace.of("move_axis_absolute") assert [m[1] for m in moves] == [AXIS_Y, AXIS_X], "Y then X, as the scan does" def test_new_point_re_rolls_without_rotating(): """Distinguishing a bad spot from a bad angle depends on not rotating.""" insp, trace, _ = build() insp.start() insp.goto_angle(1) trace.calls.clear() first = insp.current_point second = insp.new_point() assert second.angle_idx == first.angle_idx == 1 assert (second.x_mm, second.y_mm) != (first.x_mm, first.y_mm) assert trace.count("t3r_rotate") == 0, "new_point must not rotate" assert [m[1] for m in trace.of("move_axis_absolute")] == [AXIS_Y, AXIS_X] def test_next_and_prev_wrap_around(): insp, _, plan = build(num_angles=3) insp.start() assert insp.next_angle().angle_idx == 1 assert insp.next_angle().angle_idx == 2 assert insp.next_angle().angle_idx == 0, "should wrap forward" assert insp.prev_angle().angle_idx == plan.n_angles - 1, "should wrap back" def test_angle_labels_cover_every_angle(): insp, _, plan = build(num_angles=9) labels = insp.angle_labels() assert len(labels) == 9 assert labels[0].startswith("Angle 1/9") # ── Guards ─────────────────────────────────────────────────────────────────── def test_multi_angle_inspection_requires_the_rotator(): insp, _, _ = build(num_angles=3, rotator_open=False) with pytest.raises(RuntimeError, match="T3R rotation stage"): insp.start() def test_single_angle_inspection_works_without_the_rotator(): insp, _, _ = build(num_angles=1, rotator_open=False) point = insp.start() assert point.angle_idx == 0 def test_navigation_before_start_is_rejected(): insp, _, _ = build() with pytest.raises(RuntimeError, match="not been started"): insp.goto_angle(1) with pytest.raises(RuntimeError, match="not been started"): insp.new_point() def test_out_of_range_angle_is_rejected(): insp, _, _ = build(num_angles=3) insp.start() with pytest.raises(IndexError): insp.goto_angle(3) def test_stop_halts_the_sweep_and_sends_the_rotator_home(): insp, trace, _ = build() insp.start() insp.goto_angle(2) trace.calls.clear() insp.stop() assert "ACQuire:STATE STOP" in writes(trace) assert trace.count("t3r_rotate") == 1, "GR not sent home" assert insp._rotator.current_deg == pytest.approx(0.0) def test_stop_is_idempotent(): insp, trace, _ = build() insp.start() insp.stop() trace.calls.clear() insp.stop() # must not re-issue anything or raise assert trace.calls == [] def test_busy_callback_brackets_every_move(): """The window disables its controls on this, so it has to pair up.""" events = [] insp, _, _ = build(callbacks=InspectCallbacks(on_busy=events.append)) insp.start() insp.goto_angle(1) insp.new_point() insp.stop() assert events, "no busy events emitted" assert events[0] is True and events[-1] is False depth = 0 for e in events: depth += 1 if e else -1 assert depth in (0, 1), f"unbalanced busy events: {events}" assert depth == 0