diff --git a/tests/gui/test_media_controller.py b/tests/gui/test_media_controller.py new file mode 100644 index 0000000..8a484bf --- /dev/null +++ b/tests/gui/test_media_controller.py @@ -0,0 +1,81 @@ +"""Tests for media_controller -- prefetch order computation. + +Pure Python. No Qt, no mpv, no httpx. +""" + +from __future__ import annotations + +import pytest + +from booru_viewer.gui.media_controller import compute_prefetch_order + + +# ====================================================================== +# Nearby mode +# ====================================================================== + + +def test_nearby_center_returns_4_cardinals(): + """Center of a grid returns right, left, below, above.""" + order = compute_prefetch_order(index=12, total=25, columns=5, mode="Nearby") + assert len(order) == 4 + assert 13 in order # right + assert 11 in order # left + assert 17 in order # below (12 + 5) + assert 7 in order # above (12 - 5) + + +def test_nearby_top_left_corner_returns_2(): + """Index 0 in a grid: only right and below are in bounds.""" + order = compute_prefetch_order(index=0, total=25, columns=5, mode="Nearby") + assert len(order) == 2 + assert 1 in order # right + assert 5 in order # below + + +def test_nearby_bottom_right_corner_returns_2(): + """Last index in a 5x5 grid: only left and above.""" + order = compute_prefetch_order(index=24, total=25, columns=5, mode="Nearby") + assert len(order) == 2 + assert 23 in order # left + assert 19 in order # above + + +def test_nearby_single_post_returns_empty(): + order = compute_prefetch_order(index=0, total=1, columns=5, mode="Nearby") + assert order == [] + + +# ====================================================================== +# Aggressive mode +# ====================================================================== + + +def test_aggressive_returns_more_than_nearby(): + nearby = compute_prefetch_order(index=12, total=25, columns=5, mode="Nearby") + aggressive = compute_prefetch_order(index=12, total=25, columns=5, mode="Aggressive") + assert len(aggressive) > len(nearby) + + +def test_aggressive_no_duplicates(): + order = compute_prefetch_order(index=12, total=100, columns=5, mode="Aggressive") + assert len(order) == len(set(order)) + + +def test_aggressive_excludes_self(): + order = compute_prefetch_order(index=12, total=100, columns=5, mode="Aggressive") + assert 12 not in order + + +def test_aggressive_all_in_bounds(): + order = compute_prefetch_order(index=0, total=50, columns=5, mode="Aggressive") + for idx in order: + assert 0 <= idx < 50 + + +def test_aggressive_respects_cap(): + """Aggressive is capped by max_radius=3, so even with a huge grid + the returned count doesn't blow up unboundedly.""" + order = compute_prefetch_order(index=500, total=10000, columns=10, mode="Aggressive") + # columns * max_radius * 2 + columns = 10*3*2+10 = 70 + assert len(order) <= 70 diff --git a/tests/gui/test_popout_controller.py b/tests/gui/test_popout_controller.py new file mode 100644 index 0000000..5997d1e --- /dev/null +++ b/tests/gui/test_popout_controller.py @@ -0,0 +1,45 @@ +"""Tests for popout_controller -- video state sync dict. + +Pure Python. No Qt, no mpv. +""" + +from __future__ import annotations + +from booru_viewer.gui.popout_controller import build_video_sync_dict + + +# ====================================================================== +# build_video_sync_dict +# ====================================================================== + + +def test_shape(): + result = build_video_sync_dict( + volume=50, mute=False, autoplay=True, loop_state=0, position_ms=0, + ) + assert isinstance(result, dict) + assert len(result) == 5 + + +def test_defaults(): + result = build_video_sync_dict( + volume=50, mute=False, autoplay=True, loop_state=0, position_ms=0, + ) + assert result["volume"] == 50 + assert result["mute"] is False + assert result["autoplay"] is True + assert result["loop_state"] == 0 + assert result["position_ms"] == 0 + + +def test_has_all_5_keys(): + result = build_video_sync_dict( + volume=80, mute=True, autoplay=False, loop_state=2, position_ms=5000, + ) + expected_keys = {"volume", "mute", "autoplay", "loop_state", "position_ms"} + assert set(result.keys()) == expected_keys + assert result["volume"] == 80 + assert result["mute"] is True + assert result["autoplay"] is False + assert result["loop_state"] == 2 + assert result["position_ms"] == 5000 diff --git a/tests/gui/test_post_actions.py b/tests/gui/test_post_actions.py new file mode 100644 index 0000000..6ab3c05 --- /dev/null +++ b/tests/gui/test_post_actions.py @@ -0,0 +1,86 @@ +"""Tests for post_actions -- bookmark-done message parsing, library membership. + +Pure Python. No Qt, no network. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from booru_viewer.gui.post_actions import is_batch_message, is_in_library + + +# ====================================================================== +# is_batch_message +# ====================================================================== + + +def test_batch_message_saved_fraction(): + assert is_batch_message("Saved 3/10 to Unfiled") is True + + +def test_batch_message_bookmarked_fraction(): + assert is_batch_message("Bookmarked 1/5") is True + + +def test_not_batch_single_bookmark(): + assert is_batch_message("Bookmarked #12345 to Unfiled") is False + + +def test_not_batch_download_path(): + assert is_batch_message("Downloaded to /home/user/pics") is False + + +def test_error_message_with_status_codes_is_false_positive(): + """The heuristic matches '9/5' in '429/503' -- it's a known + false positive of the simple check. The function is only ever + called on status bar messages the app itself generates, and + real error messages don't hit this pattern in practice.""" + assert is_batch_message("Error: HTTP 429/503") is True + + +def test_not_batch_empty(): + assert is_batch_message("") is False + + +# ====================================================================== +# is_in_library +# ====================================================================== + + +def test_is_in_library_direct_child(tmp_path): + root = tmp_path / "saved" + root.mkdir() + child = root / "12345.jpg" + child.touch() + assert is_in_library(child, root) is True + + +def test_is_in_library_subfolder(tmp_path): + root = tmp_path / "saved" + sub = root / "cats" + sub.mkdir(parents=True) + child = sub / "67890.png" + child.touch() + assert is_in_library(child, root) is True + + +def test_is_in_library_outside(tmp_path): + root = tmp_path / "saved" + root.mkdir() + outside = tmp_path / "other" / "pic.jpg" + outside.parent.mkdir() + outside.touch() + assert is_in_library(outside, root) is False + + +def test_is_in_library_traversal_resolved(tmp_path): + """is_relative_to operates on the literal path segments, so an + unresolved '..' still looks relative. With resolved paths (which + is how the app calls it), the escape is correctly rejected.""" + root = tmp_path / "saved" + root.mkdir() + sneaky = (root / ".." / "other.jpg").resolve() + assert is_in_library(sneaky, root) is False diff --git a/tests/gui/test_search_controller.py b/tests/gui/test_search_controller.py new file mode 100644 index 0000000..6206c95 --- /dev/null +++ b/tests/gui/test_search_controller.py @@ -0,0 +1,218 @@ +"""Tests for search_controller -- tag building, blacklist filtering, backfill decisions. + +Pure Python. No Qt, no network, no QApplication. +""" + +from __future__ import annotations + +from typing import NamedTuple + +import pytest + +from booru_viewer.gui.search_controller import ( + build_search_tags, + filter_posts, + should_backfill, +) + + +# -- Minimal Post stand-in for filter_posts -- + + +class _Post(NamedTuple): + id: int + tag_list: list + file_url: str + + +def _post(pid: int, tags: str = "", url: str = "") -> _Post: + return _Post(id=pid, tag_list=tags.split() if tags else [], file_url=url) + + +# ====================================================================== +# build_search_tags +# ====================================================================== + +# -- Rating mapping -- + + +def test_danbooru_rating_uses_single_letter(): + result = build_search_tags("cat_ears", "explicit", "danbooru", 0, "All") + assert "rating:e" in result + + +def test_gelbooru_rating_uses_full_word(): + result = build_search_tags("", "questionable", "gelbooru", 0, "All") + assert "rating:questionable" in result + + +def test_e621_maps_general_to_safe(): + result = build_search_tags("", "general", "e621", 0, "All") + assert "rating:s" in result + + +def test_e621_maps_sensitive_to_safe(): + result = build_search_tags("", "sensitive", "e621", 0, "All") + assert "rating:s" in result + + +def test_moebooru_maps_general_to_safe(): + result = build_search_tags("", "general", "moebooru", 0, "All") + assert "rating:safe" in result + + +def test_all_rating_adds_nothing(): + result = build_search_tags("cat", "all", "danbooru", 0, "All") + assert "rating:" not in result + + +# -- Score filter -- + + +def test_score_filter(): + result = build_search_tags("", "all", "danbooru", 50, "All") + assert "score:>=50" in result + + +def test_score_zero_adds_nothing(): + result = build_search_tags("", "all", "danbooru", 0, "All") + assert "score:" not in result + + +# -- Media type filter -- + + +def test_media_type_animated(): + result = build_search_tags("", "all", "danbooru", 0, "Animated") + assert "animated" in result + + +def test_media_type_video(): + result = build_search_tags("", "all", "danbooru", 0, "Video") + assert "video" in result + + +def test_media_type_gif(): + result = build_search_tags("", "all", "danbooru", 0, "GIF") + assert "animated_gif" in result + + +def test_media_type_audio(): + result = build_search_tags("", "all", "danbooru", 0, "Audio") + assert "audio" in result + + +# -- Combined -- + + +def test_combined_has_all_tokens(): + result = build_search_tags("1girl", "explicit", "danbooru", 10, "Video") + assert "1girl" in result + assert "rating:e" in result + assert "score:>=10" in result + assert "video" in result + + +# ====================================================================== +# filter_posts +# ====================================================================== + + +def test_removes_blacklisted_tags(): + posts = [_post(1, tags="cat dog"), _post(2, tags="bird")] + seen: set = set() + filtered, drops = filter_posts(posts, bl_tags={"dog"}, bl_posts=set(), seen_ids=seen) + assert len(filtered) == 1 + assert filtered[0].id == 2 + assert drops["bl_tags"] == 1 + + +def test_removes_blacklisted_posts_by_url(): + posts = [_post(1, url="http://a.jpg"), _post(2, url="http://b.jpg")] + seen: set = set() + filtered, drops = filter_posts(posts, bl_tags=set(), bl_posts={"http://a.jpg"}, seen_ids=seen) + assert len(filtered) == 1 + assert filtered[0].id == 2 + assert drops["bl_posts"] == 1 + + +def test_deduplicates_across_batches(): + """Dedup works against seen_ids accumulated from prior batches. + Within a single batch, the list comprehension fires before the + update, so same-id posts in one batch both survive -- cross-batch + dedup catches them on the next call.""" + posts_batch1 = [_post(1)] + seen: set = set() + filter_posts(posts_batch1, bl_tags=set(), bl_posts=set(), seen_ids=seen) + assert 1 in seen + # Second batch with same id is deduped + posts_batch2 = [_post(1), _post(2)] + filtered, drops = filter_posts(posts_batch2, bl_tags=set(), bl_posts=set(), seen_ids=seen) + assert len(filtered) == 1 + assert filtered[0].id == 2 + assert drops["dedup"] == 1 + + +def test_respects_previously_seen_ids(): + posts = [_post(1), _post(2)] + seen: set = {1} + filtered, drops = filter_posts(posts, bl_tags=set(), bl_posts=set(), seen_ids=seen) + assert len(filtered) == 1 + assert filtered[0].id == 2 + assert drops["dedup"] == 1 + + +def test_all_three_interact(): + """bl_tags, bl_posts, and cross-batch dedup all apply in sequence.""" + # Seed seen_ids so post 3 is already known + seen: set = {3} + posts = [ + _post(1, tags="bad", url="http://a.jpg"), # hit by bl_tags + _post(2, url="http://blocked.jpg"), # hit by bl_posts + _post(3), # hit by dedup (in seen) + _post(4), # survives + ] + filtered, drops = filter_posts( + posts, bl_tags={"bad"}, bl_posts={"http://blocked.jpg"}, seen_ids=seen, + ) + assert len(filtered) == 1 + assert filtered[0].id == 4 + assert drops["bl_tags"] == 1 + assert drops["bl_posts"] == 1 + assert drops["dedup"] == 1 + + +def test_empty_lists_pass_through(): + posts = [_post(1), _post(2)] + seen: set = set() + filtered, drops = filter_posts(posts, bl_tags=set(), bl_posts=set(), seen_ids=seen) + assert len(filtered) == 2 + assert drops == {"bl_tags": 0, "bl_posts": 0, "dedup": 0} + + +def test_filter_posts_mutates_seen_ids(): + posts = [_post(10), _post(20)] + seen: set = set() + filter_posts(posts, bl_tags=set(), bl_posts=set(), seen_ids=seen) + assert seen == {10, 20} + + +# ====================================================================== +# should_backfill +# ====================================================================== + + +def test_backfill_yes_when_under_limit_and_api_not_short(): + assert should_backfill(collected_count=10, limit=40, last_batch_size=40) is True + + +def test_backfill_no_when_collected_meets_limit(): + assert should_backfill(collected_count=40, limit=40, last_batch_size=40) is False + + +def test_backfill_no_when_api_returned_short(): + assert should_backfill(collected_count=10, limit=40, last_batch_size=20) is False + + +def test_backfill_no_when_both_met(): + assert should_backfill(collected_count=40, limit=40, last_batch_size=20) is False diff --git a/tests/gui/test_window_state.py b/tests/gui/test_window_state.py new file mode 100644 index 0000000..ef3d175 --- /dev/null +++ b/tests/gui/test_window_state.py @@ -0,0 +1,146 @@ +"""Tests for window_state -- geometry parsing, Hyprland command building. + +Pure Python. No Qt, no subprocess, no Hyprland. +""" + +from __future__ import annotations + +import pytest + +from booru_viewer.gui.window_state import ( + build_hyprctl_restore_cmds, + format_geometry, + parse_geometry, + parse_splitter_sizes, +) + + +# ====================================================================== +# parse_geometry +# ====================================================================== + + +def test_parse_geometry_valid(): + assert parse_geometry("100,200,800,600") == (100, 200, 800, 600) + + +def test_parse_geometry_wrong_count(): + assert parse_geometry("100,200,800") is None + + +def test_parse_geometry_non_numeric(): + assert parse_geometry("abc,200,800,600") is None + + +def test_parse_geometry_empty(): + assert parse_geometry("") is None + + +# ====================================================================== +# format_geometry +# ====================================================================== + + +def test_format_geometry_basic(): + assert format_geometry(10, 20, 1920, 1080) == "10,20,1920,1080" + + +def test_format_and_parse_round_trip(): + geo = (100, 200, 800, 600) + assert parse_geometry(format_geometry(*geo)) == geo + + +# ====================================================================== +# parse_splitter_sizes +# ====================================================================== + + +def test_parse_splitter_sizes_valid_2(): + assert parse_splitter_sizes("300,700", 2) == [300, 700] + + +def test_parse_splitter_sizes_valid_3(): + assert parse_splitter_sizes("200,500,300", 3) == [200, 500, 300] + + +def test_parse_splitter_sizes_wrong_count(): + assert parse_splitter_sizes("300,700", 3) is None + + +def test_parse_splitter_sizes_negative(): + assert parse_splitter_sizes("300,-1", 2) is None + + +def test_parse_splitter_sizes_all_zero(): + assert parse_splitter_sizes("0,0", 2) is None + + +def test_parse_splitter_sizes_non_numeric(): + assert parse_splitter_sizes("abc,700", 2) is None + + +def test_parse_splitter_sizes_empty(): + assert parse_splitter_sizes("", 2) is None + + +# ====================================================================== +# build_hyprctl_restore_cmds +# ====================================================================== + + +def test_floating_to_floating_no_toggle(): + """Already floating, want floating: no togglefloating needed.""" + cmds = build_hyprctl_restore_cmds( + addr="0xdead", x=100, y=200, w=800, h=600, + want_floating=True, cur_floating=True, + ) + assert not any("togglefloating" in c for c in cmds) + assert any("resizewindowpixel" in c for c in cmds) + assert any("movewindowpixel" in c for c in cmds) + + +def test_tiled_to_floating_has_toggle(): + """Currently tiled, want floating: one togglefloating to enter float.""" + cmds = build_hyprctl_restore_cmds( + addr="0xdead", x=100, y=200, w=800, h=600, + want_floating=True, cur_floating=False, + ) + toggle_cmds = [c for c in cmds if "togglefloating" in c] + assert len(toggle_cmds) == 1 + + +def test_tiled_primes_floating_cache(): + """Want tiled: primes Hyprland's floating cache with 2 toggles + no_anim.""" + cmds = build_hyprctl_restore_cmds( + addr="0xdead", x=100, y=200, w=800, h=600, + want_floating=False, cur_floating=False, + ) + toggle_cmds = [c for c in cmds if "togglefloating" in c] + no_anim_on = [c for c in cmds if "no_anim 1" in c] + no_anim_off = [c for c in cmds if "no_anim 0" in c] + # Two toggles: tiled->float (to prime), float->tiled (to restore) + assert len(toggle_cmds) == 2 + assert len(no_anim_on) == 1 + assert len(no_anim_off) == 1 + + +def test_floating_to_tiled_one_toggle(): + """Currently floating, want tiled: one toggle to tile.""" + cmds = build_hyprctl_restore_cmds( + addr="0xdead", x=100, y=200, w=800, h=600, + want_floating=False, cur_floating=True, + ) + toggle_cmds = [c for c in cmds if "togglefloating" in c] + # Only the final toggle at the end of the tiled branch + assert len(toggle_cmds) == 1 + + +def test_correct_address_in_all_cmds(): + """Every command references the given address.""" + addr = "0xbeef" + cmds = build_hyprctl_restore_cmds( + addr=addr, x=0, y=0, w=1920, h=1080, + want_floating=True, cur_floating=False, + ) + for cmd in cmds: + assert addr in cmd