Compare commits

..

7 Commits

Author SHA1 Message Date
c9a8764286 feat: add test config 2026-01-06 12:58:11 +01:00
1976b5d88c test: cover filter debounce 2026-01-06 12:58:00 +01:00
a8e3972f34 test: cover email extraction 2026-01-06 12:57:55 +01:00
eea6f26bcf test: cover table utils 2026-01-06 12:57:46 +01:00
ca70661bf6 test: cover library parsing and mark as finish 2026-01-06 12:57:36 +01:00
7930bf6941 test: cover cache and url helpers 2026-01-06 12:57:16 +01:00
6d3e818b01 test: cover filter/search helpers 2026-01-06 12:57:07 +01:00
7 changed files with 448 additions and 0 deletions

35
tests/conftest.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import sys
from pathlib import Path
from types import ModuleType
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
import audible # noqa: F401
except ModuleNotFoundError:
audible_stub = ModuleType("audible")
class Authenticator: # minimal stub for type usage
pass
class Client: # minimal stub for type usage
pass
audible_stub.Authenticator = Authenticator
audible_stub.Client = Client
activation_bytes = ModuleType("audible.activation_bytes")
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
return b""
activation_bytes.get_activation_bytes = get_activation_bytes
sys.modules["audible"] = audible_stub
sys.modules["audible.activation_bytes"] = activation_bytes

50
tests/test_app_filter.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from auditui.app import Auditui
from auditui.search_utils import build_search_text, filter_items
class StubLibrary:
def extract_title(self, item: dict) -> str:
return item.get("title", "")
def extract_authors(self, item: dict) -> str:
return item.get("authors", "")
def test_get_search_text_is_cached() -> None:
class Dummy:
def __init__(self) -> None:
self._search_text_cache: dict[int, str] = {}
self.library_client = StubLibrary()
item = {"title": "Title", "authors": "Author"}
dummy = Dummy()
first = Auditui._get_search_text(dummy, item)
second = Auditui._get_search_text(dummy, item)
assert first == "title author"
assert first == second
assert len(dummy._search_text_cache) == 1
def test_filter_items_uses_cache() -> None:
library = StubLibrary()
cache: dict[int, str] = {}
items = [
{"title": "Alpha", "authors": "Author One"},
{"title": "Beta", "authors": "Author Two"},
]
def cached(item: dict) -> str:
cache_key = id(item)
if cache_key not in cache:
cache[cache_key] = build_search_text(item, library)
return cache[cache_key]
result = filter_items(items, "beta", cached)
assert result == [items[1]]
def test_build_search_text_without_library() -> None:
item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]}
assert build_search_text(item, None) == "title a, b"

48
tests/test_downloads.py Normal file
View File

@@ -0,0 +1,48 @@
from pathlib import Path
import pytest
from auditui import downloads
from auditui.constants import MIN_FILE_SIZE
def test_sanitize_filename() -> None:
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
assert dm._sanitize_filename('a<>:"/\\|?*b') == "a_________b"
def test_validate_download_url() -> None:
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
assert dm._validate_download_url("https://example.com/file") is True
assert dm._validate_download_url("http://example.com/file") is True
assert dm._validate_download_url("ftp://example.com/file") is False
def test_cached_path_and_remove(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
dm.cache_dir = tmp_path
monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book")
safe_name = dm._sanitize_filename("My Book")
cached_path = tmp_path / f"{safe_name}.aax"
cached_path.write_bytes(b"0" * MIN_FILE_SIZE)
assert dm.get_cached_path("ASIN123") == cached_path
assert dm.is_cached("ASIN123") is True
messages: list[str] = []
assert dm.remove_cached("ASIN123", notify=messages.append) is True
assert not cached_path.exists()
assert messages and "Removed from cache" in messages[-1]
def test_cached_path_ignores_small_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
dm.cache_dir = tmp_path
monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book")
safe_name = dm._sanitize_filename("My Book")
cached_path = tmp_path / f"{safe_name}.aax"
cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1))
assert dm.get_cached_path("ASIN123") is None

129
tests/test_library.py Normal file
View File

@@ -0,0 +1,129 @@
from auditui.library import LibraryClient
class MockClient:
def __init__(self) -> None:
self.put_calls: list[tuple[str, dict]] = []
self.post_calls: list[tuple[str, dict]] = []
self._post_response: dict = {}
self.raise_on_put = False
def put(self, path: str, body: dict) -> dict:
if self.raise_on_put:
raise RuntimeError("put failed")
self.put_calls.append((path, body))
return {}
def post(self, path: str, body: dict) -> dict:
self.post_calls.append((path, body))
return self._post_response
def get(self, path: str, **kwargs: dict) -> dict:
return {}
def test_extract_title_prefers_product() -> None:
client = MockClient()
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(title="Outer", product_title="Inner")
assert library.extract_title(item) == "Inner"
def test_extract_authors_joins_names() -> None:
client = MockClient()
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(authors=[{"name": "A"}, {"name": "B"}])
assert library.extract_authors(item) == "A, B"
def test_extract_runtime_minutes_from_dict() -> None:
client = MockClient()
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(runtime_min=12)
assert library.extract_runtime_minutes(item) == 12
def test_extract_progress_info_from_listening_status() -> None:
client = MockClient()
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(listening_status={"percent_complete": 25.0})
assert library.extract_progress_info(item) == 25.0
def test_is_finished_with_percent_complete() -> None:
client = MockClient()
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(percent_complete=100)
assert library.is_finished(item)
def test_format_duration_and_time() -> None:
client = MockClient()
library = LibraryClient(client) # type: ignore[arg-type]
assert library.format_duration(61) == "1h01"
assert library.format_time(3661) == "01:01:01"
def test_mark_as_finished_success_updates_item() -> None:
client = MockClient()
client._post_response = {"content_license": {"acr": "token"}}
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(runtime_min=1, listening_status={})
ok = library.mark_as_finished("ASIN", item)
assert ok
assert client.put_calls
path, body = client.put_calls[0]
assert path == "1.0/lastpositions/ASIN"
assert body["acr"] == "token"
assert body["position_ms"] == 60_000
assert item["is_finished"] is True
assert item["listening_status"]["is_finished"] is True
def test_mark_as_finished_fails_without_acr() -> None:
client = MockClient()
client._post_response = {}
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(runtime_min=1)
ok = library.mark_as_finished("ASIN", item)
assert ok is False
def test_mark_as_finished_handles_put_error() -> None:
client = MockClient()
client._post_response = {"content_license": {"acr": "token"}}
client.raise_on_put = True
library = LibraryClient(client) # type: ignore[arg-type]
item = build_item(runtime_min=1)
ok = library.mark_as_finished("ASIN", item)
assert ok is False
def build_item(
*,
title: str | None = None,
product_title: str | None = None,
authors: list[dict] | None = None,
runtime_min: int | None = None,
listening_status: dict | None = None,
percent_complete: int | float | None = None,
) -> dict:
item: dict = {}
if title is not None:
item["title"] = title
if percent_complete is not None:
item["percent_complete"] = percent_complete
if listening_status is not None:
item["listening_status"] = listening_status
product: dict = {}
if product_title is not None:
product["title"] = product_title
if runtime_min is not None:
product["runtime_length"] = {"min": runtime_min}
if authors is not None:
product["authors"] = authors
if product:
item["product"] = product
if runtime_min is not None and "runtime_length_min" not in item:
item["runtime_length_min"] = runtime_min
return item

80
tests/test_table_utils.py Normal file
View File

@@ -0,0 +1,80 @@
from auditui import table_utils
class StubLibrary:
def extract_title(self, item: dict) -> str:
return item.get("title", "")
def extract_authors(self, item: dict) -> str:
return item.get("authors", "")
def extract_runtime_minutes(self, item: dict) -> int | None:
return item.get("minutes")
def format_duration(
self, value: int | None, unit: str = "minutes", default_none: str | None = None
) -> str | None:
if value is None:
return default_none
return f"{value}m"
def extract_progress_info(self, item: dict) -> float | None:
return item.get("percent")
def extract_asin(self, item: dict) -> str | None:
return item.get("asin")
class StubDownloads:
def __init__(self, cached: set[str]) -> None:
self._cached = cached
def is_cached(self, asin: str) -> bool:
return asin in self._cached
def test_create_title_sort_key_normalizes_accents() -> None:
key_fn, _ = table_utils.create_title_sort_key()
assert key_fn(["École"]) == "ecole"
assert key_fn(["Zoo"]) == "zoo"
def test_create_progress_sort_key_parses_percent() -> None:
key_fn, _ = table_utils.create_progress_sort_key()
assert key_fn(["0", "0", "0", "42.5%"]) == 42.5
assert key_fn(["0", "0", "0", "bad"]) == 0.0
def test_truncate_author_name() -> None:
long_name = "A" * (table_utils.AUTHOR_NAME_MAX_LENGTH + 5)
truncated = table_utils.truncate_author_name(long_name)
assert truncated.endswith("...")
assert len(truncated) <= table_utils.AUTHOR_NAME_MAX_LENGTH
def test_format_item_as_row_with_downloaded() -> None:
library = StubLibrary()
downloads = StubDownloads({"ASIN123"})
item = {
"title": "Title",
"authors": "Author One",
"minutes": 90,
"percent": 12.34,
"asin": "ASIN123",
}
title, author, runtime, progress, downloaded = table_utils.format_item_as_row(
item, library, downloads
)
assert title == "Title"
assert author == "Author One"
assert runtime == "90m"
assert progress == "12.3%"
assert downloaded == "âś“"
def test_format_item_as_row_zero_progress() -> None:
library = StubLibrary()
item = {"title": "Title", "authors": "Author",
"minutes": 30, "percent": 0.0}
_, _, _, progress, _ = table_utils.format_item_as_row(item, library, None)
assert progress == "0%"

62
tests/test_ui_email.py Normal file
View File

@@ -0,0 +1,62 @@
import json
from pathlib import Path
import pytest
from auditui import ui
class DummyApp:
def __init__(self) -> None:
self.client = None
self.auth = None
self.library_client = None
self.all_items = []
self.BINDINGS = []
@pytest.fixture
def dummy_app() -> DummyApp:
return DummyApp()
def test_find_email_in_data() -> None:
screen = ui.StatsScreen()
data = {"a": {"b": ["nope", "user@example.com"]}}
assert screen._find_email_in_data(data) == "user@example.com"
def test_get_email_from_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
) -> None:
screen = ui.StatsScreen()
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"email": "config@example.com"}))
monkeypatch.setattr(ui, "CONFIG_PATH", config_path)
email = screen._get_email_from_config(dummy_app)
assert email == "config@example.com"
def test_get_email_from_auth_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
) -> None:
screen = ui.StatsScreen()
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps({"email": "auth@example.com"}))
monkeypatch.setattr(ui, "AUTH_PATH", auth_path)
email = screen._get_email_from_auth_file(dummy_app)
assert email == "auth@example.com"
def test_get_email_from_auth(dummy_app: DummyApp) -> None:
screen = ui.StatsScreen()
class Auth:
username = "user@example.com"
login = None
email = None
dummy_app.auth = Auth()
assert screen._get_email_from_auth(dummy_app) == "user@example.com"

44
tests/test_ui_filter.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from auditui.ui import FilterScreen
class DummyEvent:
def __init__(self, value: str) -> None:
self.value = value
class FakeTimer:
def __init__(self, callback) -> None:
self.callback = callback
self.stopped = False
def stop(self) -> None:
self.stopped = True
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
seen: list[str] = []
timers: list[FakeTimer] = []
def on_change(value: str) -> None:
seen.append(value)
screen = FilterScreen(on_change=on_change, debounce_seconds=0.2)
def fake_set_timer(_delay: float, callback):
timer = FakeTimer(callback)
timers.append(timer)
return timer
monkeypatch.setattr(screen, "set_timer", fake_set_timer)
screen.on_input_changed(DummyEvent("a"))
screen.on_input_changed(DummyEvent("ab"))
assert len(timers) == 2
assert timers[0].stopped is True
assert timers[1].stopped is False
timers[1].callback()
assert seen == ["ab"]