"""Tests for assembling the three-pane comparison video. The property that matters is *alignment*: a variant that produced no composite for a timestamp -- which is exactly what the current pipeline does past its gap limit -- must still occupy that slot, or its pane slides out of step with the others and the comparison is meaningless. """ import os import numpy as np import pytest from PIL import Image import make_comparison_video as mcv from suvi import paths T0 = 1715299200 def write_stream(root, variant, satellite, timestamps, size=(64, 36)): directory = os.path.join(root, variant, f"goes{satellite}") os.makedirs(directory, exist_ok=True) for when in timestamps: img = Image.fromarray(np.full((size[1], size[0], 3), 40, dtype="uint8")) img.save(os.path.join(directory, f"Composite-{when}.jpg"), quality=90) return directory def test_stream_frames_reads_timestamps(tmp_path): times = [T0, T0 + paths.CADENCE, T0 + 2 * paths.CADENCE] directory = write_stream(str(tmp_path), "pristine", 16, times) frames = mcv.stream_frames(directory) assert sorted(frames) == times assert all(os.path.exists(p) for p in frames.values()) def test_stream_frames_ignores_other_files(tmp_path): directory = write_stream(str(tmp_path), "new", 16, [T0]) open(os.path.join(directory, "notes.txt"), "w").write("x") open(os.path.join(directory, "Composite-nonsense.jpg"), "w").write("x") assert list(mcv.stream_frames(directory)) == [T0] def test_stream_frames_on_a_missing_directory(tmp_path): assert mcv.stream_frames(str(tmp_path / "absent")) == {} def test_every_known_variant_is_labelled(): """Variants are now a CLI choice; every name the tool accepts needs a label.""" import render_real assert set(mcv.LABELS) == {"pristine", "today", "new", "learned", "damaged", "optical_flow"} assert set(render_real.FILLERS) <= set(mcv.LABELS) # its stream directories assert all(mcv.LABELS.values()) def test_encode_emits_one_frame_per_slot_including_gaps(tmp_path, monkeypatch): """A pane with holes must still be full length, or the panes drift apart. 'today' legitimately has none for long gaps; those slots have to become black frames occupying the timeline, not vanish from it. """ times = [T0, T0 + 2 * paths.CADENCE] # slot 1 deliberately missing directory = write_stream(str(tmp_path), "today", 16, times) frames = mcv.stream_frames(directory) timeline = [T0 + i * paths.CADENCE for i in range(3)] written = [] class FakeProcess: def __init__(self): self.stdin = self def write(self, data): written.append(("real", len(data))) def save(self, *a, **k): pass def close(self): pass def wait(self): return 0 fake = FakeProcess() monkeypatch.setattr(mcv.subprocess, "Popen", lambda *a, **k: fake) class CountingImage: size = (64, 36) @staticmethod def fromarray(arr): class Blank: def save(self, handle, *a, **k): written.append(("black", 0)) return Blank() @staticmethod def open(path): return CountingImage monkeypatch.setattr(mcv, "Image", CountingImage) present = mcv.encode_stream("ffmpeg", frames, timeline, "out.mp4", 60, 18) assert present == 2 assert len(written) == 3, "a missing slot did not occupy the timeline" assert [kind for kind, _ in written] == ["real", "black", "real"] # ------------------------------------------------------ pristine reads the truth def test_pristine_resolves_deleted_slots_from_the_archive(tmp_path): """The ground-truth pane must show the window as it really is. A slot the case deleted has no overlay path. Resolving pristine through the overlay silently drops those timestamps -- 221 of them in one run -- leaving the reference pane shorter than the panes it exists to be compared against. """ import bench from suvi import cases slot = (16, 171, T0) overlay = cases.Overlay( archive={slot: ("/archive/real.fits", None)}, overrides={}, deleted=frozenset({slot}), ) assert overlay.path(slot) is None, "precondition: the case deleted this slot" assert overlay.truth_path(slot) == "/archive/real.fits" reads = [] def fake_read(path): reads.append(path) return "pixels", None original = bench.fitsio.read_image bench.fitsio.read_image = fake_read try: cache = {} assert bench._resolve_band(overlay, slot, cache, use_truth=True) == "pixels" assert reads == ["/archive/real.fits"] # Without use_truth the slot is genuinely gone, which is right for the # variants that are meant to show the damage. assert bench._resolve_band(overlay, slot, cache, use_truth=False) is None finally: bench.fitsio.read_image = original def test_resolve_band_caches_truth_and_overlay_separately(tmp_path): """One cache serving both would return the wrong frame for one of them.""" import bench from suvi import cases slot = (16, 171, T0) overlay = cases.Overlay( archive={slot: ("/archive/real.fits", None)}, overrides={slot: "/overlay/damaged.fits"}, ) original = bench.fitsio.read_image bench.fitsio.read_image = lambda path: (path, None) try: cache = {} assert bench._resolve_band(overlay, slot, cache, use_truth=True) == "/archive/real.fits" assert bench._resolve_band(overlay, slot, cache, use_truth=False) == "/overlay/damaged.fits" finally: bench.fitsio.read_image = original def test_pane_spec_carries_per_pane_satellites(): assert mcv.parse_panes("damaged:18,new,damaged,learned", 16, "/r") == ( ("damaged", 18, "/r"), ("new", 16, "/r"), ("damaged", 16, "/r"), ("learned", 16, "/r"), ) def test_a_pane_can_come_from_another_render_root(): """A new model renders only its learned stream; the inputs and the optical flow repair of the same case are read from the earlier render.""" panes = mcv.parse_panes("damaged:18,new,damaged,learned@/renders/v7_s128", 16, "/renders/v7") assert panes[3] == ("learned", 16, "/renders/v7_s128") assert panes[0] == ("damaged", 18, "/renders/v7") def test_a_pane_from_another_root_names_it_on_screen(): """Two models' outputs must never be mistakable for one another.""" own = mcv.pane_label("learned", 16, "/renders/v7_s128/", "/renders/v7") shared = mcv.pane_label("learned", 16, "/renders/v7", "/renders/v7") assert own.endswith("| v7_s128") assert "v7" not in shared.split("|")[-1] def test_panes_from_different_roots_with_the_same_variant_stay_distinct(): panes = mcv.parse_panes("learned@/a,learned@/b", 16, "/r") assert len(set(panes)) == 2 def test_pane_spec_rejects_unknown_variants(): with pytest.raises(SystemExit, match="unknown variant"): mcv.parse_panes("pristine,upscaled", 16) # ------------------------------------------------------------------ layout def _run_graph(tmp_path, panes, labels=None): """Run the real filter graph through ffmpeg on tiny clips; return the output size.""" import subprocess ffmpeg = mcv.find_ffmpeg() clips = [] for index in range(len(panes)): clip = str(tmp_path / f"pane{index}.mp4") subprocess.run(f'{ffmpeg} -y -loglevel error -f lavfi -i testsrc=size=192x108:rate=4 ' f'-t 1 -pix_fmt yuv420p "{clip}"', shell=True, check=True) clips.append(clip) font = os.path.join(os.path.dirname(os.path.abspath(mcv.__file__)), "OpenSans-Regular.ttf") out = str(tmp_path / "out.mp4") inputs = " ".join(f'-i "{c}"' for c in clips) graph = mcv.filter_graph(panes, "/r", font, labels) result = subprocess.run(f'{ffmpeg} -y -loglevel error {inputs} -filter_complex "{graph}" ' f'-map "[out]" -pix_fmt yuv420p "{out}"', shell=True, capture_output=True, text=True) assert result.returncode == 0, result.stderr probe = subprocess.run(f'{ffmpeg} -i "{out}"', shell=True, capture_output=True, text=True) import re return tuple(int(v) for v in re.search(r", (\d+)x(\d+)", probe.stderr).groups()) def test_a_single_stream_is_labelled_at_full_size_without_stacking(tmp_path): """The real-data run renders one stream; ffmpeg's stack filters refuse a single input, which is what failed the first GOES-16 year video.""" graph = mcv.filter_graph((("learned", 16, "/r"),), "/r", "font.ttf") assert "stack" not in graph and "scale=" not in graph assert _run_graph(tmp_path, (("learned", 16, "/r"),)) == (192, 108) def test_two_panes_sit_side_by_side_at_half_width(tmp_path): panes = (("new", 16, "/r"), ("learned", 16, "/r")) assert "hstack=inputs=2" in mcv.filter_graph(panes, "/r", "font.ttf") assert _run_graph(tmp_path, panes) == (1920, 540) def test_four_panes_form_a_two_by_two_grid(tmp_path): panes = (("damaged", 18, "/r"), ("new", 16, "/r"), ("damaged", 16, "/r"), ("learned", 16, "/r")) assert "xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0" in mcv.filter_graph(panes, "/r", "f") assert _run_graph(tmp_path, panes) == (1920, 1080) # ------------------------------------------------------------------ labels def test_without_labels_the_defaults_stand(): assert mcv.parse_labels(None, 4) is None def test_labels_replace_the_default_pane_labels(): panes = (("optical_flow", 16, "/a"), ("learned", 16, "/b")) labels = mcv.parse_labels("OLD DETECTION + OPTICAL FLOW, NEW DETECTION + v8", 2) assert labels == ("OLD DETECTION + OPTICAL FLOW", "NEW DETECTION + v8") graph = mcv.filter_graph(panes, "/r", "font.ttf", labels) assert "text='OLD DETECTION + OPTICAL FLOW'" in graph assert "text='NEW DETECTION + v8'" in graph assert "LEARNED" not in graph def test_labels_sit_bottom_centre_clear_of_the_composite_title(): """At the top they hid the timestamp a frame is found again by.""" graph = mcv.filter_graph((("learned", 16, "/r"), ("new", 16, "/r")), "/r", "f") assert graph.count("x=(w-text_w)/2:y=h-text_h-12") == 2 assert "y=12" not in graph def test_one_label_per_pane_is_required(): with pytest.raises(SystemExit, match="names 2 panes; there are 4"): mcv.parse_labels("a,b", 4) @pytest.mark.parametrize("label", [ "it's", 'say "hi"', "a:b", "back\\slash", "$(rm -rf ~)", "`id`", "a;b", "a[0]", "", " ", "new\nline", ]) def test_labels_that_would_break_the_filter_or_the_shell_are_refused(label): with pytest.raises(SystemExit, match="label"): mcv.parse_labels(f"ok,{label}", 2) def test_every_allowed_label_character_survives_ffmpeg(tmp_path): panes = (("optical_flow", 16, "/r"), ("learned", 16, "/r")) labels = mcv.parse_labels("A-z 0.9 + (x) | a/b_c, OLD DETECTION + OPTICAL FLOW", 2) assert _run_graph(tmp_path, panes, labels) == (1920, 540) # ----------------------------------------------------------------- periods JAN1 = 1704067200 # 2024-01-01 00:00 UTC def test_periods_parse_as_utc_and_come_back_in_time_order(): periods = mcv.parse_periods("2024-10-03..2024-10-06, 2024-01-01..2024-01-08") assert periods == ((JAN1, JAN1 + 7 * 86400), (1727913600, 1727913600 + 3 * 86400)) def test_adjacent_periods_are_allowed_but_overlapping_ones_are_not(): assert len(mcv.parse_periods("2024-01-01..2024-01-02,2024-01-02..2024-01-03")) == 2 with pytest.raises(SystemExit, match="overlap"): mcv.parse_periods("2024-01-01..2024-01-05,2024-01-04..2024-01-08") @pytest.mark.parametrize("spec", ["2024-01-01", "2024-01-01..", "..2024-01-02", "2024-13-01..2024-12-01", "a..b", "2024-01-01..2024-01-02..2024-01-03", ""]) def test_malformed_periods_are_refused(spec): with pytest.raises(SystemExit, match="expected YYYY-MM-DD"): mcv.parse_periods(spec) def test_a_period_that_ends_before_it_starts_is_refused(): with pytest.raises(SystemExit, match="ends before"): mcv.parse_periods("2024-01-02..2024-01-01") with pytest.raises(SystemExit, match="ends before"): mcv.parse_periods("2024-01-02..2024-01-02") def test_the_default_timeline_spans_first_to_last_composite_with_gaps_kept(): step = paths.CADENCE streams = {"a": {JAN1: "x", JAN1 + 3 * step: "y"}, "b": {JAN1 + 5 * step: "z"}} assert mcv.build_timeline(streams) == [JAN1 + i * step for i in range(6)] assert mcv.build_timeline({"a": {}}) == [] def test_periods_keep_only_their_own_slots_back_to_back(): """A whole-year pane must not stretch three sample weeks into a year.""" step = paths.CADENCE streams = {"year": {JAN1 + i * step: "x" for i in range(0, 360 * 366, 50)}} periods = ((JAN1, JAN1 + 3 * step), (JAN1 + 86400, JAN1 + 86400 + 2 * step)) assert mcv.build_timeline(streams, periods) == [ JAN1, JAN1 + step, JAN1 + 2 * step, JAN1 + 86400, JAN1 + 86400 + step]