diora-web/tui/tests/test_reader_annotations.py
mrwnslz e246a32212 TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).

Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00

137 lines
5.3 KiB
Python

"""Marking text, bookmarks and the list, in the reader."""
import asyncio
import pytest
from textual.widgets import Input, ListView
from diora_tui import annotations, epub as epub_mod, progress
from diora_tui.annotation_screens import AnnotationsScreen, NoteScreen
from diora_tui.app import DioraTuiApp
from diora_tui.library_screen import ChoiceScreen
from diora_tui.reader_screen import ReaderScreen
@pytest.fixture
def world(tmp_path, monkeypatch):
monkeypatch.setattr(progress, "_default", progress.ProgressStore(tmp_path / "p.json"))
store = annotations.AnnotationStore(tmp_path / "ann")
monkeypatch.setattr(annotations, "_default", store)
lib = tmp_path / "lib"
lib.mkdir()
return lib, store
def book(epub):
body = "<h1>Anfang</h1>\n" + "\n".join(f"<p>\n Absatz {i} eins zwei drei vier fünf sechs sieben.\n</p>" for i in range(30))
return epub([body], title="Marken")
async def open_reader(app, pilot, path):
app.push_screen(ReaderScreen(path))
for _ in range(80):
await pilot.pause(0.1)
s = app.screen
if isinstance(s, ReaderScreen) and s.book is not None and s._last_anchor is not None:
await pilot.pause(0.2)
return s
raise AssertionError("reader did not load")
def run(lib, epub, scenario):
path = book(epub)
async def go():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
reader = await open_reader(app, pilot, path)
await scenario(app, reader, pilot, epub_mod._book_id(path))
asyncio.run(go())
def painted_with(reader, color_hex):
wanted = color_hex.lstrip("#").lower()
return any(seg.style and seg.style.bgcolor and seg.style.bgcolor.get_truecolor().hex.lstrip("#").lower() == wanted
for strip in reader.book_layout.row_strips for seg in strip)
def test_mark_text_then_list_edit_and_delete(world, epub):
lib, store = world
async def scenario(app, reader, pilot, key):
await pilot.press("v")
assert reader._sel is not None and reader._sel["phase"] == "start"
await pilot.press("l", "l") # two words on
await pilot.press("enter") # start
assert reader._sel["phase"] == "end"
await pilot.press("l", "l", "l")
await pilot.press("enter") # end: pick a colour
assert isinstance(app.screen, ChoiceScreen)
await pilot.press("down", "enter") # Grün
await pilot.pause(0.2)
assert reader._sel is None
hs = store.get(key).highlights
assert len(hs) == 1 and hs[0]["color"] == "green" and hs[0]["type"] == "highlight"
a = hs[0]["anchor"]
assert a["startBlockIndex"] == a["endBlockIndex"] and a["quote"] and a["startChar"] < a["endChar"]
assert a["quote"] == reader._marks.maps(a["startBlockIndex"]).raw[a["startChar"]:a["endChar"]]
assert painted_with(reader, "#2ecc71")
assert store.get(key).dirty["highlights"]
await pilot.press("b")
bm = store.get(key).bookmarks
assert len(bm) == 1 and bm[0]["anchor"].endswith("c0.xhtml") and bm[0]["label"] == "c0"
await pilot.press("a")
assert isinstance(app.screen, AnnotationsScreen)
lv = app.screen.query_one(ListView)
assert len(app.screen.rows) == 2 and app.screen.rows[0].kind == "bookmarks"
await pilot.press("down", "e") # edit the note of the highlight
assert isinstance(app.screen, NoteScreen)
app.screen.query_one(Input).value = "klug"
await pilot.press("enter")
await pilot.pause(0.2)
assert store.get(key).highlights[0]["note"] == "klug"
await pilot.press("d") # delete the highlight
await pilot.pause(0.2)
assert store.get(key).highlights == [] and not painted_with(reader, "#2ecc71")
await pilot.press("escape")
assert isinstance(app.screen, ReaderScreen)
run(lib, epub, scenario)
def test_note_on_a_word_and_escape_leaves_the_selection(world, epub):
lib, store = world
async def scenario(app, reader, pilot, key):
await pilot.press("v", "l")
await pilot.press("escape")
assert reader._sel is None and isinstance(app.screen, ReaderScreen)
assert store.get(key).highlights == []
await pilot.press("v", "l", "n") # n while marking: a freeform note on this word
assert isinstance(app.screen, NoteScreen)
app.screen.query_one(Input).value = "merken"
await pilot.press("enter")
await pilot.pause(0.2)
h = store.get(key).highlights
assert len(h) == 1 and h[0]["type"] == "note" and h[0]["note"] == "merken" and h[0]["color"] is None
a = h[0]["anchor"]
assert reader._marks.maps(a["startBlockIndex"]).raw[a["startChar"]:a["endChar"]] == a["quote"]
run(lib, epub, scenario)
def test_jump_from_the_list_is_not_reading(world, epub):
lib, store = world
async def scenario(app, reader, pilot, key):
store.add(key, "bookmarks", annotations.new_bookmark("c0", "OEBPS/c0.xhtml", 0.5))
await pilot.press("a", "enter")
await pilot.pause(0.3)
assert isinstance(app.screen, ReaderScreen)
reader._save_progress()
assert progress.default().get(key) is None # nothing written by the jump
run(lib, epub, scenario)