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>
137 lines
5.3 KiB
Python
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)
|