"""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