Compare commits
7 Commits
02c6e4cb88
...
c9a8764286
| Author | SHA1 | Date | |
|---|---|---|---|
| c9a8764286 | |||
| 1976b5d88c | |||
| a8e3972f34 | |||
| eea6f26bcf | |||
| ca70661bf6 | |||
| 7930bf6941 | |||
| 6d3e818b01 |
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal 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
50
tests/test_app_filter.py
Normal 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
48
tests/test_downloads.py
Normal 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
129
tests/test_library.py
Normal 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
80
tests/test_table_utils.py
Normal 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
62
tests/test_ui_email.py
Normal 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
44
tests/test_ui_filter.py
Normal 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"]
|
||||||
Reference in New Issue
Block a user