library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen: Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen, Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text (1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs gegen den lokalen Server. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
121 lines
5.7 KiB
Python
121 lines
5.7 KiB
Python
"""The library rules of the web (docs/library.md, 53-library.js): filters, folders, search,
|
|
sort, the Weiterlesen card."""
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from diora_tui import library as lib
|
|
from diora_tui.library import Row
|
|
|
|
NOW = 1_760_000_000_000
|
|
DAY = 86_400_000
|
|
|
|
|
|
def row(title, **kw):
|
|
return Row(key=kw.pop("key", title), title=title, **kw)
|
|
|
|
|
|
@pytest.fixture
|
|
def rows():
|
|
return [
|
|
row("Das Kapital 2", author="Marx", folders=["Philosophie", "Politik"], pages=900, uploaded_at="2026-01-02", read_at=NOW - 2 * DAY, fraction=0.3),
|
|
row("Das Kapital 10", author="Marx", folders=["Politik"], pages=300, uploaded_at="2026-03-01"),
|
|
row("Ödipus", author="Sophokles", folders=[], pages=0, uploaded_at="2026-02-01", is_read=True, read_at=NOW - 9 * DAY, fraction=1.0),
|
|
row("Zauberberg", author="", folders=["Roman"], pages=700, uploaded_at="2026-04-01", read_at=NOW - 60_000, fraction=0.5),
|
|
row("Jacobin 24", folders=["Zeitschriften"], pages=80, uploaded_at="2026-05-01"),
|
|
row("Heft", folders=[], magazine=True, uploaded_at="2026-05-02"),
|
|
]
|
|
|
|
|
|
def titles(rs):
|
|
return [r.title for r in rs]
|
|
|
|
|
|
def test_sections_split_books_from_magazines(rows):
|
|
assert titles(lib.section(rows, True)) == ["Jacobin 24", "Heft"]
|
|
assert "Jacobin 24" not in titles(lib.section(rows, False))
|
|
assert lib.is_magazine(row("x", folders=["Magazine"])) and not lib.is_magazine(row("x", folders=["Zeitschriften"], magazine=False))
|
|
|
|
|
|
def test_status_counts_and_filters(rows):
|
|
books = lib.section(rows, False)
|
|
assert lib.status_counts(books) == {"all": 4, "started": 2, "unread": 1, "read": 1}
|
|
assert titles(lib.shown(books, status="started", sort="title")) == ["Das Kapital 2", "Zauberberg"]
|
|
assert titles(lib.shown(books, status="unread")) == ["Das Kapital 10"]
|
|
assert titles(lib.shown(books, status="read")) == ["Ödipus"]
|
|
|
|
|
|
def test_folders_filter_and_order(rows):
|
|
books = lib.section(rows, False)
|
|
assert titles(lib.shown(books, folder="Politik", sort="title")) == ["Das Kapital 2", "Das Kapital 10"]
|
|
assert titles(lib.shown(books, folder="")) == ["Ödipus"] # without a folder
|
|
# the user's order first, the rest by name
|
|
assert lib.folder_list(books, ["Roman", "Politik"]) == [("Roman", 1), ("Politik", 2), ("Philosophie", 1)]
|
|
assert lib.folder_list(books, None) == [("Philosophie", 1), ("Politik", 2), ("Roman", 1)]
|
|
|
|
|
|
def test_search_ignores_case_and_accents_and_reads_author(rows):
|
|
assert titles(lib.shown(rows, q="odipus")) == ["Ödipus"]
|
|
assert titles(lib.shown(rows, q="MARX", sort="title")) == ["Das Kapital 2", "Das Kapital 10"]
|
|
assert titles(lib.shown(rows, q="sophokles ödipus")) == [] # one run of text, as the web matches
|
|
|
|
|
|
def test_sorts(rows):
|
|
books = lib.section(rows, False)
|
|
assert titles(lib.shown(books, sort="recent")) == ["Zauberberg", "Das Kapital 2", "Ödipus", "Das Kapital 10"]
|
|
assert titles(lib.shown(books, sort="title")) == ["Das Kapital 2", "Das Kapital 10", "Ödipus", "Zauberberg"] # numbers by value
|
|
assert titles(lib.shown(books, sort="author")) == ["Das Kapital 2", "Das Kapital 10", "Ödipus", "Zauberberg"] # no author last
|
|
assert titles(lib.shown(books, sort="added")) == ["Zauberberg", "Das Kapital 10", "Ödipus", "Das Kapital 2"]
|
|
assert titles(lib.shown(books, sort="short")) == ["Das Kapital 10", "Zauberberg", "Das Kapital 2", "Ödipus"] # unknown length last
|
|
assert titles(lib.shown(books, sort="long")) == ["Das Kapital 2", "Zauberberg", "Das Kapital 10", "Ödipus"]
|
|
|
|
|
|
def test_continue_card_takes_the_last_read_unfinished_book(rows):
|
|
going, recent = lib.continue_rows(lib.section(rows, False))
|
|
assert going.title == "Zauberberg"
|
|
assert titles(recent) == ["Das Kapital 2", "Ödipus"] # the others read lately, finished ones too
|
|
only_done = [r for r in rows if r.is_read]
|
|
assert lib.continue_rows(only_done) == (None, [])
|
|
assert lib.continue_rows([row("neu")]) == (None, [])
|
|
|
|
|
|
def test_continue_card_looks_only_at_the_seven_read_last():
|
|
many = [row(f"b{i}", read_at=NOW - i * 1000, is_read=(i < 7)) for i in range(10)]
|
|
assert lib.continue_rows(many) == (None, []) # the 7 read last are all finished: no card
|
|
|
|
|
|
def test_length_of_a_book():
|
|
assert lib.pages_of_chars(0) == 0
|
|
assert lib.pages_of_chars(10) == 1
|
|
assert lib.pages_of_chars(1800 * 3 + 900) == 4 # rounds half up like Math.round
|
|
assert lib.pages_of_chars(1800 * 3 + 899) == 3
|
|
|
|
|
|
@pytest.mark.parametrize("ms,expected", [
|
|
(NOW - 30_000, "gerade eben"),
|
|
(NOW - 5 * 60_000, "vor 5 Minuten"),
|
|
(NOW - DAY * 3 - 3_600_000, "vor 3 Tagen"),
|
|
])
|
|
def test_ago(ms, expected):
|
|
assert lib.ago(ms, NOW) == expected
|
|
|
|
|
|
def test_percent():
|
|
assert lib.percent(row("a", fraction=0.425)) == 43
|
|
assert lib.percent(row("a", is_read=True)) == 100
|
|
|
|
|
|
def test_build_rows_joins_files_and_server_books(tmp_path):
|
|
f = tmp_path / "0001 - Eins.epub"
|
|
f.write_bytes(b"")
|
|
local = tmp_path / "lokal.epub"
|
|
local.write_bytes(b"")
|
|
remote = {"s1": {"server_id": 1, "title": "Eins", "author": "A", "is_read": False, "uploaded_at": "x", "folders": ["F"], "pages": 0},
|
|
"s2": {"server_id": 2, "title": "Zwei", "is_read": True, "pages": 50}}
|
|
states = {"s1": SimpleNamespace(read_at=5, fraction=0.25, device="Handy")}
|
|
key_of = lambda p: "s1" if p.name.startswith("0001") else "hash" # noqa: E731
|
|
rs = {r.key: r for r in lib.build_rows([f, local], remote, states, {"s1": 12}, key_of)}
|
|
assert set(rs) == {"s1", "s2", "hash"}
|
|
assert (rs["s1"].title, rs["s1"].path, rs["s1"].folders, rs["s1"].fraction, rs["s1"].pages) == ("Eins", f, ["F"], 0.25, 12)
|
|
assert rs["s2"].path is None and rs["s2"].is_read and rs["s2"].pages == 50
|
|
assert rs["hash"].title == "lokal" and rs["hash"].server_id is None
|