test: Phase 2 — add 64 tests for extracted pure functions

5 new test files covering the pure-function extractions from Phase 1:
- test_search_controller.py (24): tag building, blacklist filtering, backfill
- test_window_state.py (16): geometry parsing, splitter parsing, hyprctl cmds
- test_media_controller.py (9): prefetch ring-expansion ordering
- test_post_actions.py (10): batch message detection, library membership
- test_popout_controller.py (3): video sync dict shape

All import-pure (no PySide6, no mpv, no httpx). Total suite: 186 tests.
This commit is contained in:
pax 2026-04-10 15:20:57 -05:00
parent b858b4ac43
commit 562c03071b
5 changed files with 576 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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