test: cover app and playback controller mixin behavior

This commit is contained in:
2026-02-18 03:17:48 +01:00
parent 4bc9b3fd3f
commit e88dcee155
8 changed files with 791 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
from dataclasses import dataclass
from auditui.app.actions import AppActionsMixin
@dataclass(slots=True)
class FakeTable:
"""Minimal table shim exposing cursor and row count."""
row_count: int
cursor_row: int = 0
class FakePlayback:
"""Playback stub with togglable boolean return values."""
def __init__(self, result: bool) -> None:
"""Store deterministic toggle result for tests."""
self._result = result
self.calls: list[str] = []
def toggle_playback(self) -> bool:
"""Return configured result and record call."""
self.calls.append("toggle")
return self._result
def seek_forward(self, _seconds: float) -> bool:
"""Return configured result and record call."""
self.calls.append("seek_forward")
return self._result
class DummyActionsApp(AppActionsMixin):
"""Mixin host with just enough state for action method tests."""
def __init__(self) -> None:
"""Initialize fake app state used by action helpers."""
self.messages: list[str] = []
self.current_items: list[dict] = []
self.download_manager = object()
self.library_client = type(
"Library", (), {"extract_asin": lambda self, item: item.get("asin")}
)()
self.playback = FakePlayback(True)
self.filter_text = "hello"
self._refreshed = 0
self._table = FakeTable(row_count=0, cursor_row=0)
def update_status(self, message: str) -> None:
"""Collect status messages for assertions."""
self.messages.append(message)
def query_one(self, selector: str, _type: object) -> FakeTable:
"""Return the fake table used in selection tests."""
assert selector == "#library_table"
return self._table
def _refresh_filtered_view(self) -> None:
"""Record refresh invocations for filter tests."""
self._refreshed += 1
def _start_playback_async(self, asin: str) -> None:
"""Capture async playback launch argument."""
self.messages.append(f"start:{asin}")
def test_get_selected_asin_requires_non_empty_table() -> None:
"""Ensure selection fails gracefully when table has no rows."""
app = DummyActionsApp()
app._table = FakeTable(row_count=0)
assert app._get_selected_asin() is None
assert app.messages[-1] == "No books available"
def test_get_selected_asin_returns_current_row_asin() -> None:
"""Ensure selected row index maps to current_items ASIN."""
app = DummyActionsApp()
app._table = FakeTable(row_count=2, cursor_row=1)
app.current_items = [{"asin": "A1"}, {"asin": "A2"}]
assert app._get_selected_asin() == "A2"
def test_action_play_selected_starts_async_playback() -> None:
"""Ensure play action calls async starter with selected ASIN."""
app = DummyActionsApp()
app._table = FakeTable(row_count=1, cursor_row=0)
app.current_items = [{"asin": "ASIN"}]
app.action_play_selected()
assert app.messages[-1] == "start:ASIN"
def test_action_toggle_playback_shows_hint_when_no_playback() -> None:
"""Ensure toggle action displays no-playback hint on false return."""
app = DummyActionsApp()
app.playback = FakePlayback(False)
app.action_toggle_playback()
assert app.messages[-1] == "No playback active. Press Enter to play a book."
def test_action_seek_forward_shows_hint_when_seek_fails() -> None:
"""Ensure failed seek action reuses no-playback helper status."""
app = DummyActionsApp()
app.playback = FakePlayback(False)
app.action_seek_forward()
assert app.messages[-1] == "No playback active. Press Enter to play a book."
def test_action_clear_filter_resets_filter_and_refreshes() -> None:
"""Ensure clearing filter resets text and refreshes filtered view."""
app = DummyActionsApp()
app.action_clear_filter()
assert app.filter_text == ""
assert app._refreshed == 1
assert app.messages[-1] == "Filter cleared"
def test_apply_filter_coerces_none_to_empty_string() -> None:
"""Ensure apply_filter normalizes None and refreshes list view."""
app = DummyActionsApp()
app._apply_filter(None)
assert app.filter_text == ""
assert app._refreshed == 1

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
from auditui.app.library import AppLibraryMixin
from auditui.app import library as library_mod
class DummyLibraryApp(AppLibraryMixin):
"""Mixin host exposing only members used by AppLibraryMixin."""
def __init__(self) -> None:
"""Initialize in-memory app state and call tracking."""
self.all_items: list[dict] = []
self.show_all_mode = False
self._search_text_cache: dict[int, str] = {1: "x"}
self.messages: list[str] = []
self.call_log: list[tuple[str, tuple]] = []
self.library_client = None
def _prime_search_cache(self, items: list[dict]) -> None:
"""Store a marker so callers can assert this method was reached."""
self.call_log.append(("prime", (items,)))
def show_all(self) -> None:
"""Record show_all invocation for assertion."""
self.call_log.append(("show_all", ()))
def show_unfinished(self) -> None:
"""Record show_unfinished invocation for assertion."""
self.call_log.append(("show_unfinished", ()))
def update_status(self, message: str) -> None:
"""Capture status messages."""
self.messages.append(message)
def call_from_thread(self, func, *args) -> None:
"""Execute callback immediately to simplify tests."""
func(*args)
def _thread_status_update(self, message: str) -> None:
"""Capture worker-thread status update messages."""
self.messages.append(message)
def test_on_library_loaded_refreshes_cache_and_shows_unfinished() -> None:
"""Ensure loaded items reset cache and default to unfinished view."""
app = DummyLibraryApp()
items = [{"asin": "a"}, {"asin": "b"}]
app.on_library_loaded(items)
assert app.all_items == items
assert app._search_text_cache == {}
assert app.messages[-1] == "Loaded 2 books"
assert app.call_log[-1][0] == "show_unfinished"
def test_on_library_loaded_uses_show_all_mode() -> None:
"""Ensure loaded items respect show_all mode when enabled."""
app = DummyLibraryApp()
app.show_all_mode = True
app.on_library_loaded([{"asin": "a"}])
assert app.call_log[-1][0] == "show_all"
def test_on_library_error_formats_message() -> None:
"""Ensure library errors are surfaced through status updates."""
app = DummyLibraryApp()
app.on_library_error("boom")
assert app.messages == ["Error fetching library: boom"]
def test_fetch_library_calls_on_loaded(monkeypatch) -> None:
"""Ensure fetch_library forwards fetched items through call_from_thread."""
app = DummyLibraryApp()
class Worker:
"""Simple worker shim exposing cancellation state."""
is_cancelled = False
class LibraryClient:
"""Fake client returning a deterministic item list."""
def fetch_all_items(self, callback):
"""Invoke callback and return one item."""
callback("progress")
return [{"asin": "x"}]
app.library_client = LibraryClient()
monkeypatch.setattr(library_mod, "get_current_worker", lambda: Worker())
AppLibraryMixin.fetch_library.__wrapped__(app)
assert app.all_items == [{"asin": "x"}]
assert "Loaded 1 books" in app.messages
def test_fetch_library_handles_expected_exception(monkeypatch) -> None:
"""Ensure fetch exceptions call on_library_error with error text."""
app = DummyLibraryApp()
class Worker:
"""Simple worker shim exposing cancellation state."""
is_cancelled = False
class BrokenClient:
"""Fake client raising an expected fetch exception."""
def fetch_all_items(self, callback):
"""Raise the same exception family handled by mixin."""
del callback
raise ValueError("bad fetch")
app.library_client = BrokenClient()
monkeypatch.setattr(library_mod, "get_current_worker", lambda: Worker())
AppLibraryMixin.fetch_library.__wrapped__(app)
assert app.messages[-1] == "Error fetching library: bad fetch"

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import cast
from auditui.app.progress import AppProgressMixin
from textual.events import Key
from textual.widgets import DataTable
@dataclass(slots=True)
class FakeKeyEvent:
"""Minimal key event carrying key value and prevent_default state."""
key: str
prevented: bool = False
def prevent_default(self) -> None:
"""Mark event as prevented."""
self.prevented = True
@dataclass(slots=True)
class FakeStatic:
"""Minimal static widget with text and visibility fields."""
display: bool = False
text: str = ""
def update(self, value: str) -> None:
"""Store rendered text value."""
self.text = value
@dataclass(slots=True)
class FakeProgressBar:
"""Minimal progress bar widget storing latest progress value."""
progress: float = 0.0
def update(self, progress: float) -> None:
"""Store progress value for assertions."""
self.progress = progress
@dataclass(slots=True)
class FakeContainer:
"""Minimal container exposing display property."""
display: bool = False
class DummyPlayback:
"""Playback shim exposing only members used by AppProgressMixin."""
def __init__(self) -> None:
"""Initialize playback state and update counters."""
self.is_playing = False
self._status: str | None = None
self._progress: tuple[str, float, float] | None = None
self.saved_calls = 0
def check_status(self):
"""Return configurable status check message."""
return self._status
def get_current_progress(self):
"""Return configurable progress tuple."""
return self._progress
def update_position_if_needed(self) -> None:
"""Record periodic save invocations."""
self.saved_calls += 1
class DummyProgressApp(AppProgressMixin):
"""Mixin host that records action dispatch and widget updates."""
def __init__(self) -> None:
"""Initialize fake widgets and playback state."""
self.playback = DummyPlayback()
self.focused = object()
self.actions: list[str] = []
self.messages: list[str] = []
self.progress_info = FakeStatic()
self.progress_bar = FakeProgressBar()
self.progress_container = FakeContainer()
def action_seek_backward(self) -> None:
"""Record backward seek action dispatch."""
self.actions.append("seek_backward")
def action_toggle_playback(self) -> None:
"""Record toggle playback action dispatch."""
self.actions.append("toggle")
def update_status(self, message: str) -> None:
"""Capture status messages for assertions."""
self.messages.append(message)
def query_one(self, selector: str, _type: object):
"""Return fake widgets by selector used by progress mixin."""
return {
"#progress_info": self.progress_info,
"#progress_bar": self.progress_bar,
"#progress_bar_container": self.progress_container,
}[selector]
def test_on_key_dispatches_seek_when_playing() -> None:
"""Ensure left key is intercepted and dispatched to seek action."""
app = DummyProgressApp()
app.playback.is_playing = True
event = FakeKeyEvent("left")
app.on_key(cast(Key, event))
assert event.prevented is True
assert app.actions == ["seek_backward"]
def test_on_key_dispatches_space_when_table_focused() -> None:
"""Ensure space is intercepted and dispatched when table is focused."""
app = DummyProgressApp()
app.focused = DataTable()
event = FakeKeyEvent("space")
app.on_key(cast(Key, event))
assert event.prevented is True
assert app.actions == ["toggle"]
def test_check_playback_status_hides_progress_after_message() -> None:
"""Ensure playback status message triggers hide-progress behavior."""
app = DummyProgressApp()
app.playback._status = "Finished"
app._check_playback_status()
assert app.messages[-1] == "Finished"
assert app.progress_info.display is False
assert app.progress_container.display is False
def test_update_progress_renders_visible_progress_row() -> None:
"""Ensure valid progress data updates widgets and makes them visible."""
app = DummyProgressApp()
app.playback.is_playing = True
app.playback._progress = ("Chapter", 30.0, 60.0)
app._update_progress()
assert app.progress_bar.progress == 50.0
assert app.progress_info.display is True
assert app.progress_container.display is True

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from auditui.app.progress import AppProgressMixin
class DummyPlayback:
"""Playback stub exposing periodic update method."""
def __init__(self) -> None:
"""Initialize call counter."""
self.saved_calls = 0
def update_position_if_needed(self) -> None:
"""Increment call counter for assertions."""
self.saved_calls += 1
class DummyProgressApp(AppProgressMixin):
"""Minimal app host containing playback dependency only."""
def __init__(self) -> None:
"""Initialize playback stub."""
self.playback = DummyPlayback()
def test_save_position_periodically_delegates_to_playback() -> None:
"""Ensure periodic save method delegates to playback updater."""
app = DummyProgressApp()
app._save_position_periodically()
assert app.playback.saved_calls == 1

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from auditui.app import state as state_mod
class DummyApp:
"""Lightweight app object for state initialization tests."""
def __init__(self) -> None:
"""Expose update_status to satisfy init dependencies."""
self.messages: list[str] = []
def update_status(self, message: str) -> None:
"""Collect status updates for assertions."""
self.messages.append(message)
def test_init_state_without_auth_or_client(monkeypatch) -> None:
"""Ensure baseline state is initialized when no auth/client is provided."""
app = DummyApp()
playback_args: list[tuple[object, object]] = []
class FakePlayback:
"""Playback constructor recorder for init tests."""
def __init__(self, notify, library_client) -> None:
"""Capture arguments passed by init_auditui_state."""
playback_args.append((notify, library_client))
monkeypatch.setattr(state_mod, "PlaybackController", FakePlayback)
state_mod.init_auditui_state(app)
assert app.library_client is None
assert app.download_manager is None
assert app.all_items == []
assert app.current_items == []
assert app.filter_text == ""
assert app.show_all_mode is False
assert playback_args and playback_args[0][1] is None
def test_init_state_with_auth_and_client_builds_dependencies(monkeypatch) -> None:
"""Ensure init constructs library, downloads, and playback dependencies."""
app = DummyApp()
auth = object()
client = object()
class FakeLibraryClient:
"""Fake library client constructor for dependency wiring checks."""
def __init__(self, value) -> None:
"""Store constructor argument for assertions."""
self.value = value
class FakeDownloadManager:
"""Fake download manager constructor for dependency wiring checks."""
def __init__(self, auth_value, client_value) -> None:
"""Store constructor arguments for assertions."""
self.args = (auth_value, client_value)
class FakePlayback:
"""Fake playback constructor for dependency wiring checks."""
def __init__(self, notify, library_client) -> None:
"""Store constructor arguments for assertions."""
self.notify = notify
self.library_client = library_client
monkeypatch.setattr(state_mod, "LibraryClient", FakeLibraryClient)
monkeypatch.setattr(state_mod, "DownloadManager", FakeDownloadManager)
monkeypatch.setattr(state_mod, "PlaybackController", FakePlayback)
state_mod.init_auditui_state(app, auth=auth, client=client)
assert isinstance(app.library_client, FakeLibraryClient)
assert isinstance(app.download_manager, FakeDownloadManager)
assert isinstance(app.playback, FakePlayback)
assert app.library_client.value is client
assert app.download_manager.args == (auth, client)
assert app.playback.library_client.value is client