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.
219 lines
6.3 KiB
Python
219 lines
6.3 KiB
Python
"""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
|