"""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 ( AngleFileError, describe_opposite_pairs, describe_summary, read_angle_file, summarize_angles, ) 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") assert read_angle_file(path) == [(0.0, 1), (45.5, 1), (360.0, 1)] 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) == [(0.0, 1), (10.0, 1), (190.5, 2), (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) == [(90.0, 2), (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") == [(12.0, 1)] @pytest.mark.parametrize( "text, match", [ ("-0.1\n", "outside"), ("360.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([(0.0, 1), (45.0, 1), (100.0, 3)]) assert summary["n"] == 3 assert summary["min"] == 0.0 assert summary["max"] == 100.0 assert summary["n_subtomos"] == 2 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 = [(10.0, 1), (190.0, 1), (50.0, 1), (50.0, 2), (120.0, 1)] 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([(0.0, 1), (90.0, 1), (359.99, 1)])["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 = [(float(a), 1) 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 = [(float(a), 1) for a in range(0, 360, 10)] assert len(summarize_angles(entries)["opposite_pairs"]) == 18