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,121 @@
from __future__ import annotations
from pathlib import Path
from auditui.playback import controller_lifecycle as lifecycle_mod
from auditui.playback.controller import PlaybackController
class Proc:
"""Process shim used for lifecycle tests."""
def __init__(self, poll_value=None) -> None:
"""Set initial poll result."""
self._poll_value = poll_value
def poll(self):
"""Return process running status."""
return self._poll_value
def _controller() -> tuple[PlaybackController, list[str]]:
"""Build controller and message capture list."""
messages: list[str] = []
return PlaybackController(messages.append, None), messages
def test_start_reports_missing_ffplay(monkeypatch) -> None:
"""Ensure start fails fast when ffplay is unavailable."""
controller, messages = _controller()
monkeypatch.setattr(lifecycle_mod.process_mod, "is_ffplay_available", lambda: False)
assert controller.start(Path("book.aax")) is False
assert messages[-1] == "ffplay not found. Please install ffmpeg"
def test_start_sets_state_on_success(monkeypatch) -> None:
"""Ensure successful start initializes playback state and metadata."""
controller, messages = _controller()
monkeypatch.setattr(lifecycle_mod.process_mod, "is_ffplay_available", lambda: True)
monkeypatch.setattr(
lifecycle_mod.process_mod, "build_ffplay_cmd", lambda *args: ["ffplay"]
)
monkeypatch.setattr(
lifecycle_mod.process_mod, "run_ffplay", lambda cmd: (Proc(None), None)
)
monkeypatch.setattr(
lifecycle_mod,
"load_media_info",
lambda path, activation: (600.0, [{"title": "ch"}]),
)
monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 100.0)
ok = controller.start(
Path("book.aax"), activation_hex="abcd", start_position=10.0, speed=1.2
)
assert ok is True
assert controller.is_playing is True
assert controller.current_file_path == Path("book.aax")
assert controller.total_duration == 600.0
assert messages[-1] == "Playing: book.aax"
def test_prepare_and_start_uses_last_position(monkeypatch) -> None:
"""Ensure prepare flow resumes from saved position when available."""
messages: list[str] = []
lib = type("Lib", (), {"get_last_position": lambda self, asin: 75.0})()
controller = PlaybackController(messages.append, lib)
started: list[tuple] = []
class DM:
"""Download manager shim returning path and activation token."""
def get_or_download(self, asin, notify):
"""Return deterministic downloaded file path."""
return Path("book.aax")
def get_activation_bytes(self):
"""Return deterministic activation token."""
return "abcd"
monkeypatch.setattr(controller, "start", lambda *args: started.append(args) or True)
monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 200.0)
assert controller.prepare_and_start(DM(), "ASIN") is True
assert started and started[0][3] == 75.0
assert "Resuming from 01:15" in messages
def test_toggle_playback_uses_pause_and_resume_paths(monkeypatch) -> None:
"""Ensure toggle dispatches pause or resume based on paused flag."""
controller, _ = _controller()
controller.is_playing = True
controller.playback_process = Proc(None)
called: list[str] = []
monkeypatch.setattr(controller, "pause", lambda: called.append("pause"))
monkeypatch.setattr(controller, "resume", lambda: called.append("resume"))
controller.is_paused = False
assert controller.toggle_playback() is True
controller.is_paused = True
assert controller.toggle_playback() is True
assert called == ["pause", "resume"]
def test_restart_at_position_restores_state_and_notifies(monkeypatch) -> None:
"""Ensure restart logic preserves metadata and emits custom message."""
controller, messages = _controller()
controller.is_playing = True
controller.is_paused = True
controller.current_file_path = Path("book.aax")
controller.current_asin = "ASIN"
controller.activation_hex = "abcd"
controller.total_duration = 400.0
controller.chapters = [{"title": "One"}]
controller.playback_speed = 1.0
monkeypatch.setattr(controller, "_stop_process", lambda: None)
monkeypatch.setattr(lifecycle_mod.time, "sleep", lambda _s: None)
monkeypatch.setattr(controller, "start", lambda *args: True)
paused: list[str] = []
monkeypatch.setattr(controller, "pause", lambda: paused.append("pause"))
assert controller._restart_at_position(120.0, message="Jumped") is True
assert controller.current_asin == "ASIN"
assert controller.chapters == [{"title": "One"}]
assert paused == ["pause"]
assert messages[-1] == "Jumped"

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from auditui.playback import controller_seek_speed as seek_speed_mod
from auditui.playback.controller import PlaybackController
def _controller() -> tuple[PlaybackController, list[str]]:
"""Build controller and in-memory notification sink."""
messages: list[str] = []
return PlaybackController(messages.append, None), messages
def test_seek_notifies_when_target_invalid(monkeypatch) -> None:
"""Ensure seek reports end-of-file condition when target is invalid."""
controller, messages = _controller()
monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 20.0)
controller.seek_offset = 100.0
controller.total_duration = 120.0
monkeypatch.setattr(
seek_speed_mod.seek_mod, "compute_seek_target", lambda *args: None
)
assert controller._seek(30.0, "forward") is False
assert messages[-1] == "Already at end of file"
def test_seek_to_chapter_reports_bounds(monkeypatch) -> None:
"""Ensure chapter seek reports first and last chapter boundaries."""
controller, messages = _controller()
controller.is_playing = True
controller.current_file_path = object()
controller.chapters = [{"title": "One", "start_time": 0.0, "end_time": 10.0}]
monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 1.0)
monkeypatch.setattr(
seek_speed_mod.chapters_mod,
"get_current_chapter_index",
lambda elapsed, chapters: 0,
)
assert controller.seek_to_chapter("next") is False
assert messages[-1] == "Already at last chapter"
assert controller.seek_to_chapter("previous") is False
assert messages[-1] == "Already at first chapter"
def test_save_current_position_writes_positive_values() -> None:
"""Ensure save_current_position persists elapsed time via library client."""
calls: list[tuple[str, float]] = []
library = type(
"Library",
(),
{"save_last_position": lambda self, asin, pos: calls.append((asin, pos))},
)()
controller = PlaybackController(lambda _msg: None, library)
controller.current_asin = "ASIN"
controller.is_playing = True
controller.playback_start_time = 1.0
controller.seek_offset = 10.0
controller._get_current_elapsed = lambda: 15.0
controller._save_current_position()
assert calls == [("ASIN", 25.0)]
def test_update_position_if_needed_honors_interval(monkeypatch) -> None:
"""Ensure periodic save runs only when interval has elapsed."""
controller, _ = _controller()
controller.is_playing = True
controller.current_asin = "ASIN"
controller.library_client = object()
controller.last_save_time = 10.0
controller.position_save_interval = 30.0
saves: list[str] = []
monkeypatch.setattr(
controller, "_save_current_position", lambda: saves.append("save")
)
monkeypatch.setattr(seek_speed_mod.time, "time", lambda: 20.0)
controller.update_position_if_needed()
monkeypatch.setattr(seek_speed_mod.time, "time", lambda: 45.0)
controller.update_position_if_needed()
assert saves == ["save"]
def test_change_speed_restarts_with_new_rate(monkeypatch) -> None:
"""Ensure speed changes restart playback at current position."""
controller, _ = _controller()
controller.playback_speed = 1.0
controller.seek_offset = 5.0
monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 10.0)
seen: list[tuple[float, float, str]] = []
def fake_restart(
position: float, speed: float | None = None, message: str | None = None
) -> bool:
"""Capture restart call parameters."""
seen.append((position, speed or 0.0, message or ""))
return True
monkeypatch.setattr(controller, "_restart_at_position", fake_restart)
assert controller.increase_speed() is True
assert seen and seen[0][0] == 15.0
assert seen[0][1] > 1.0
assert seen[0][2].startswith("Speed: ")

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
from auditui.playback import controller_state as state_mod
from auditui.playback.controller import PlaybackController
class Proc:
"""Simple process shim exposing poll and pid for state tests."""
def __init__(self, poll_value=None) -> None:
"""Store poll return value and fake pid."""
self._poll_value = poll_value
self.pid = 123
def poll(self):
"""Return configured process status code or None."""
return self._poll_value
def _controller() -> tuple[PlaybackController, list[str]]:
"""Build playback controller and collected notifications list."""
messages: list[str] = []
return PlaybackController(messages.append, None), messages
def test_get_current_elapsed_rolls_pause_into_duration(monkeypatch) -> None:
"""Ensure elapsed helper absorbs stale pause_start_time when resumed."""
controller, _ = _controller()
controller.pause_start_time = 100.0
controller.is_paused = False
monkeypatch.setattr(state_mod.time, "time", lambda: 120.0)
monkeypatch.setattr(state_mod.elapsed_mod, "get_elapsed", lambda *args: 50.0)
assert controller._get_current_elapsed() == 50.0
assert controller.paused_duration == 20.0
assert controller.pause_start_time is None
def test_validate_playback_state_stops_when_process_ended() -> None:
"""Ensure state validation stops and reports when process is gone."""
controller, messages = _controller()
controller.playback_process = cast(Any, Proc(poll_value=1))
controller.is_playing = True
controller.current_file_path = Path("book.aax")
ok = controller._validate_playback_state(require_paused=False)
assert ok is False
assert messages[-1] == "Playback process has ended"
def test_send_signal_sets_paused_state_and_notifies(monkeypatch) -> None:
"""Ensure SIGSTOP updates paused state and includes filename in status."""
controller, messages = _controller()
controller.playback_process = cast(Any, Proc())
controller.current_file_path = Path("book.aax")
monkeypatch.setattr(state_mod.process_mod, "send_signal", lambda proc, sig: None)
controller._send_signal(state_mod.signal.SIGSTOP, "Paused", "pause")
assert controller.is_paused is True
assert messages[-1] == "Paused: book.aax"
def test_send_signal_handles_process_lookup(monkeypatch) -> None:
"""Ensure missing process lookup errors are handled with user-facing message."""
controller, messages = _controller()
controller.playback_process = cast(Any, Proc())
def raise_lookup(proc, sig):
"""Raise process lookup error to exercise exception path."""
del proc, sig
raise ProcessLookupError("gone")
monkeypatch.setattr(state_mod.process_mod, "send_signal", raise_lookup)
monkeypatch.setattr(state_mod.process_mod, "terminate_process", lambda proc: None)
controller._send_signal(state_mod.signal.SIGCONT, "Playing", "resume")
assert messages[-1] == "Process no longer exists"