"""Tests for the tomo_type-4 angle file parser (OMNY_shared/angle_file.py).""" import pytest from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( AngleEntry, AngleFileError, describe_opposite_pairs, describe_summary, read_angle_file, summarize_angles, ) def _e(angle, subtomo=1, step=None, exp_time=None, frames_per_trigger=None): """Shorthand for building the expected AngleEntry list in assertions.""" return AngleEntry( angle=angle, subtomo=subtomo, step=step, exp_time=exp_time, frames_per_trigger=frames_per_trigger, ) def _write(tmp_path, text, name="angles.txt"): path = tmp_path / name path.write_text(text) return str(path) def test_one_column_defaults_to_subtomo_1(tmp_path): path = _write(tmp_path, "0\n45.5\n360\n") result = read_angle_file(path) assert result.angles == [_e(0.0), _e(45.5), _e(360.0)] assert result.is_complete is False def test_two_columns_whitespace_and_comma_separators(tmp_path): path = _write(tmp_path, "0 1\n10\t1\n190.5, 2\n270,3\n") assert read_angle_file(path).angles == [_e(0.0), _e(10.0), _e(190.5, 2), _e(270.0, 3)] def test_comments_and_blank_lines_ignored_and_order_kept(tmp_path): path = _write(tmp_path, "# header\n\n90 2 # trailing comment\n \n30 1\n") assert read_angle_file(path).angles == [_e(90.0, 2), _e(30.0, 1)] def test_tilde_expansion(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) _write(tmp_path, "12\n") assert read_angle_file("~/angles.txt").angles == [_e(12.0)] def test_angle_range_matches_rotation_stage_overtravel(tmp_path): """-2 and 362 (the stage's physical overtravel limits) must parse, not just the nominal 0-360 sweep.""" path = _write(tmp_path, "-2\n-0.1\n0\n360.01\n362\n") assert read_angle_file(path).angles == [ _e(-2.0), _e(-0.1), _e(0.0), _e(360.01), _e(362.0), ] def test_end_marks_complete(tmp_path): path = _write(tmp_path, "0\n10\nEND\n") result = read_angle_file(path) assert result.angles == [_e(0.0), _e(10.0)] assert result.is_complete is True def test_missing_end_is_incomplete(tmp_path): path = _write(tmp_path, "0\n10\n") assert read_angle_file(path).is_complete is False @pytest.mark.parametrize("marker", ["END", "end", "EnD"]) def test_end_case_insensitive(tmp_path, marker): path = _write(tmp_path, f"0\n{marker}\n") assert read_angle_file(path).is_complete is True def test_commented_end_is_not_sentinel(tmp_path): path = _write(tmp_path, "0\n# END\n10\n") result = read_angle_file(path) assert result.angles == [_e(0.0), _e(10.0)] assert result.is_complete is False def test_end_with_trailing_comment(tmp_path): path = _write(tmp_path, "0\nEND # done\n") assert read_angle_file(path).is_complete is True def test_content_after_end_raises(tmp_path): path = _write(tmp_path, "0\nEND\n10\n") with pytest.raises(AngleFileError, match="line 3.*line 2"): read_angle_file(path) def test_blank_and_comment_after_end_allowed(tmp_path): path = _write(tmp_path, "0\nEND\n\n# trailing note\n") result = read_angle_file(path) assert result.angles == [_e(0.0)] assert result.is_complete is True @pytest.mark.parametrize( "text, match", [ ("-2.1\n", "outside"), ("362.01\n", "outside"), ("abc\n", "invalid angle"), ("10 x\n", "invalid sub-tomogram"), ("10 0\n", ">= 1"), ("10 1 5\n", "expected"), ("# only comments\n\n", "no angles"), ], ) def test_invalid_content_raises_with_reason(tmp_path, text, match): path = _write(tmp_path, text) with pytest.raises(AngleFileError, match=match): read_angle_file(path) def test_error_reports_line_number(tmp_path): path = _write(tmp_path, "# c\n10\nbad\n") with pytest.raises(AngleFileError, match="line 3"): read_angle_file(path) def test_missing_file_and_empty_path_raise(tmp_path): with pytest.raises(AngleFileError, match="Cannot read"): read_angle_file(str(tmp_path / "nope.txt")) with pytest.raises(AngleFileError, match="No angle file"): read_angle_file("") def test_angle_file_error_is_a_value_error(): assert issubclass(AngleFileError, ValueError) def test_summary_counts(): summary = summarize_angles([_e(0.0), _e(45.0), _e(100.0, 3)]) assert summary["n"] == 3 assert summary["min"] == 0.0 assert summary["max"] == 100.0 assert summary["n_subtomos"] == 2 assert summary["n_overridden"] == 0 assert summary["opposite_pairs"] == [] assert describe_summary(summary) == "3 angles, 0.00-100.00 deg, 2 sub-tomograms" assert describe_opposite_pairs(summary) is None def test_summary_detects_180_opposite_and_exact_duplicates(): entries = [_e(10.0), _e(190.0), _e(50.0), _e(50.0, 2), _e(120.0)] pairs = summarize_angles(entries)["opposite_pairs"] assert [(a, b) for a, b, _, _ in pairs] == [(0, 1), (2, 3)] def test_summary_detects_pair_across_the_mod_180_wrap(): # 179.99 and 0.0 are 0.01 apart mod 180; so are 359.99 and 0.0 pairs = summarize_angles([_e(0.0), _e(90.0), _e(359.99)])["opposite_pairs"] assert [(a, b) for a, b, _, _ in pairs] == [(0, 2)] def test_summary_no_false_positives_for_equally_spaced_360(): # 0, 7, 14, ... 353: 360/7 is not an integer, so no 180-degree pairs entries = [_e(float(a)) for a in range(0, 360, 7)] assert summarize_angles(entries)["opposite_pairs"] == [] def test_summary_flags_every_pair_for_equally_spaced_360_with_even_count(): entries = [_e(float(a)) for a in range(0, 360, 10)] assert len(summarize_angles(entries)["opposite_pairs"]) == 18 # ── per-line acquisition overrides (step=/exptime=/frames=) ──────────────── def test_overrides_parsed_and_sticky_across_lines(tmp_path): path = _write( tmp_path, "0\n" "10 1\n" "20 1 step=0.5\n" "30 1\n" "40 2 step=2 exptime=5 frames=3\n" "50 2\n", ) result = read_angle_file(path) assert result.angles == [ _e(0.0), _e(10.0), _e(20.0, step=0.5), _e(30.0, step=0.5), _e(40.0, 2, step=2.0, exp_time=5.0, frames_per_trigger=3), _e(50.0, 2, step=2.0, exp_time=5.0, frames_per_trigger=3), ] def test_overrides_independent_sticky_state(tmp_path): """Each of step/exptime/frames carries forward independently -- setting one doesn't reset or require the others.""" path = _write(tmp_path, "0 1 step=1\n10 1 exptime=2\n20 1 frames=4\n") result = read_angle_file(path) assert result.angles == [ _e(0.0, step=1.0), _e(10.0, step=1.0, exp_time=2.0), _e(20.0, step=1.0, exp_time=2.0, frames_per_trigger=4), ] def test_overrides_compose_with_subtomo_and_comma_separators(tmp_path): path = _write(tmp_path, "10, 2, step=0.3\n") result = read_angle_file(path) assert result.angles == [_e(10.0, 2, step=0.3)] def test_overrides_compose_with_end_sentinel(tmp_path): path = _write(tmp_path, "0 1 step=0.5\n10\nEND\n") result = read_angle_file(path) assert result.angles == [_e(0.0, step=0.5), _e(10.0, step=0.5)] assert result.is_complete is True @pytest.mark.parametrize( "text, match", [ ("10 foo=1\n", "unknown key 'foo'"), ("10 step=1 step=2\n", "duplicate key 'step'"), ("10 step=\n", "malformed"), ("10 =5\n", "malformed"), ("10 step=abc\n", "invalid step"), ("10 frames=abc\n", "invalid frames"), ("10 step=0.01\n", r"outside \[0.025, 20\]"), ("10 step=21\n", r"outside \[0.025, 20\]"), ("10 exptime=0.0001\n", r"outside \[0.001, 100\]"), ("10 exptime=200\n", r"outside \[0.001, 100\]"), ("10 frames=0\n", r"outside \[1, 100\]"), ("10 frames=101\n", r"outside \[1, 100\]"), ], ) def test_override_errors(tmp_path, text, match): path = _write(tmp_path, text) with pytest.raises(AngleFileError, match=match): read_angle_file(path) def test_summary_reports_override_count(): entries = [_e(0.0), _e(10.0, step=0.5), _e(20.0, exp_time=2.0)] summary = summarize_angles(entries) assert summary["n_overridden"] == 2 assert "2 with per-line overrides" in describe_summary(summary) def test_summary_omits_override_note_when_none_overridden(): summary = summarize_angles([_e(0.0), _e(10.0)]) assert summary["n_overridden"] == 0 assert "overrides" not in describe_summary(summary)