Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
// The navigation (docs/navigation.md): Home, Lesen (Bücher, Zeitschriften, Nachrichten,
// Blogs), Hören (Hörbücher, Podcasts, Radio, Ambient), on the web page's own bar.
const { test , expect } = require ( '@playwright/test' ) ;
const path = require ( 'path' ) ;
test . use ( { storageState : path . join ( _ _dirname , '.auth' , 'user.json' ) } ) ;
const visiblePanel = page => page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( 'section.tab-panel' ) ] . filter ( p => p . style . display !== 'none' ) . map ( p => p . id ) ) ;
test ( 'wide: every section in the bar under its group, each shows its panel and view' , async ( { page } ) => {
await page . setViewportSize ( { width : 1200 , height : 800 } ) ;
await page . goto ( '/#books' ) ;
const wide = page . locator ( '#tabs .nav-wide' ) ;
await expect ( wide . locator ( '.nav-group-label' ) ) . toHaveText ( [ 'Lesen' , 'Hören' ] ) ;
await expect ( wide . locator ( '[data-nav]' ) ) . toHaveText (
2026-10-19 20:14:17 +02:00
[ 'Home' , 'Bücher' , 'Zeitschriften' , 'Nachrichten' , 'Blogs' , 'Hörbücher' , 'Podcasts' , 'Radio' , 'Ambient' , 'Fokus' , 'Projekte' , 'Suche' , 'Abos & Quellen' ] ) ;
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
await wide . locator ( '[data-nav="blogs"]' ) . click ( ) ;
await expect ( page ) . toHaveURL ( /#blogs$/ ) ;
expect ( await visiblePanel ( page ) ) . toEqual ( [ 'tab-feeds' ] ) ;
expect ( await page . evaluate ( ( ) => localStorage . getItem ( 'diora_feeds_view' ) ) ) . toBe ( 'blogs' ) ;
await expect ( page . locator ( '#tab-feeds .fd-seg' ) ) . toBeHidden ( ) ; // the bar chooses, not the tab
await expect ( page . locator ( '#nav-segments' ) ) . toBeHidden ( ) ;
await wide . locator ( '[data-nav="audiobooks"]' ) . click ( ) ;
expect ( await visiblePanel ( page ) ) . toEqual ( [ 'tab-audiobooks' ] ) ;
await expect ( page . locator ( '#audiobook-shelf' ) ) . toBeVisible ( ) ;
await expect ( wide . locator ( '[data-nav="audiobooks"]' ) ) . toHaveClass ( /active/ ) ;
} ) ;
test ( 'narrow: three groups, the sections as a row on top, each group remembers its last' , async ( { page } ) => {
await page . setViewportSize ( { width : 390 , height : 800 } ) ;
await page . goto ( '/#news' ) ;
const narrow = page . locator ( '#tabs .nav-narrow' ) ;
2026-10-19 20:14:17 +02:00
await expect ( narrow . locator ( '[data-nav]' ) ) . toHaveText ( [ 'Home' , 'Lesen' , 'Hören' , 'Projekte' , '' , '' ] ) ;
await expect ( narrow . locator ( '[data-nav="suche"] svg.dnav-ic' ) ) . toBeVisible ( ) ; // the magnifier
Neue Navigations-Icons (Stil B), mehrere Bücher auf einmal, generiertes Cover pro Buch wählbar
- Icons: eigene SVGs, kräftig und geometrisch (2,4 px, eckige Enden), in
nav.js für alle Leisten (DioraNav.icon, sections()/groups() .svg); der
aktive Bereich gefüllt.
- Hochladen: mehrere Dateien auf einmal (Auswahl oder auf die Bibliothek
ziehen) und aus der Cloud per Häkchen („Alle auswählen“, „n importieren“);
nacheinander mit Fortschritt, ein schon vorhandenes Buch (Titel und Autor)
wird übersprungen.
- Pro Buch: „Generiertes Cover verwenden“ statt des eigenen (im verschlüsselten
Meta, cover: 'generated').
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:21:10 +02:00
await expect ( narrow . locator ( '[data-nav="sources"] svg.dnav-ic' ) ) . toBeVisible ( ) ;
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
await expect ( narrow . locator ( '[data-nav="lesen"]' ) ) . toHaveClass ( /active/ ) ;
const segs = page . locator ( '#nav-segments' ) ;
await expect ( segs . locator ( '[data-nav]' ) ) . toHaveText ( [ 'Bücher' , 'Zeitschriften' , 'Nachrichten' , 'Blogs' ] ) ;
await expect ( segs . locator ( '[data-nav="news"]' ) ) . toHaveClass ( /active/ ) ;
await narrow . locator ( '[data-nav="hoeren"]' ) . click ( ) ;
await segs . locator ( '[data-nav="radio"]' ) . click ( ) ;
expect ( await visiblePanel ( page ) ) . toEqual ( [ 'tab-radio' ] ) ;
await narrow . locator ( '[data-nav="lesen"]' ) . click ( ) ;
await expect ( page ) . toHaveURL ( /#news$/ ) ; // back where Lesen was left
await narrow . locator ( '[data-nav="hoeren"]' ) . click ( ) ;
await expect ( page ) . toHaveURL ( /#radio/ ) ;
} ) ;
test ( 'old links and direct panel switches land in their section' , async ( { page } ) => {
await page . setViewportSize ( { width : 1200 , height : 800 } ) ;
await page . goto ( '/#feeds' ) ;
expect ( await visiblePanel ( page ) ) . toEqual ( [ 'tab-feeds' ] ) ;
await expect ( page . locator ( '#tabs .nav-wide [data-nav].active' ) ) . toHaveText ( /Nachrichten|Blogs/ ) ;
await page . evaluate ( ( ) => showTab ( 'podcasts' ) ) ;
await expect ( page . locator ( '#tabs [data-nav="podcasts"]' ) ) . toHaveClass ( /active/ ) ;
await expect ( page ) . toHaveURL ( /#podcasts$/ ) ;
await page . reload ( ) ;
expect ( await visiblePanel ( page ) ) . toEqual ( [ 'tab-podcasts' ] ) ; // remembered
} ) ;
Abos & Quellen: alle Abos an einem Ort (Navigation Phase 2)
Ein neuer Abschnitt (docs/sources.md, static/js/sources.js) für Podcasts,
YouTube-Kanäle, Hörbücher, Nachrichten und Blogs:
- ein Feld zum Hinzufügen: Adresse (Feed, Seite, YouTube) oder Name eines
Podcasts; die Art ist wählbar (Podcast, Hörbuch, Nachrichten, Blog);
/feeds/discover/ erkennt dafür Podcasts (Folgen mit Ton oder Video);
- eine Liste aller Abos mit Filtern (auch „Probleme“) und Suche;
- je Abo: Inhalt öffnen, Einstellungen (die vorhandenen Editoren), Art ändern,
abbestellen; OPML-Import und -Export.
In der Seitenleiste unten, auf dem Telefon im App-Menü; ⚙ in den Feeds führt
hierher. Abschnitte, die beim Laden schon gezeigt werden, füllen sich jetzt
auch (nav.js zeigt sie nach dem Laden aller Skripte noch einmal).
Podcast-UI aus diora-android 50c6f9c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:23:12 +02:00
2026-10-19 19:09:07 +02:00
test ( 'Suche: books from this device, the rest from the server, a hit opens where it lives' , async ( { page } ) => {
await page . setViewportSize ( { width : 390 , height : 800 } ) ;
await page . route ( '**/search/?q=*' , route => route . fulfill ( { json : {
q : 'kapital' , podcasts : [ { id : 7 , title : 'Kapital-Lesekreis' , author : 'Rote Hilfe' , artwork _url : '' , favorite : true } ] ,
podcasts _total : 1 , audiobooks : [ ] , audiobooks _total : 0 ,
episodes : [ { id : 70 , feed _ _id : 7 , feed _ _title : 'Kapital-Lesekreis' , title : 'Folge 3: Das Kapital, Band 1' , duration _seconds : 3600 } ] ,
sources : [ ] , articles : [ { id : 5 , title : 'Kapital und Krise' , source : 'Ein Blog' , kind : 'blog' , published : null , read : false } ] ,
articles _total : 1 , stations : [ ] } } ) ) ;
await page . goto ( '/#home' ) ;
await page . locator ( '#tabs .nav-narrow [data-nav="suche"]' ) . click ( ) ;
await expect ( page ) . toHaveURL ( /#suche$/ ) ;
expect ( await visiblePanel ( page ) ) . toEqual ( [ 'tab-suche' ] ) ;
await expect ( page . locator ( '#tab-suche .sx-q' ) ) . toBeFocused ( ) ;
await page . evaluate ( ( ) => {
_lastBookListData = [ { id : 901 , title : 'Das Kapital' , author : 'Karl Marx' , type : 'epub' , keyOk : true } ,
{ id : 902 , title : 'Gartenbuch' , author : 'Jemand' , type : 'epub' , keyOk : true } ] ;
} ) ;
await page . locator ( '#tab-suche .sx-q' ) . fill ( 'kapital' ) ;
const groups = page . locator ( '#tab-suche .sx-h' ) ;
await expect ( groups ) . toHaveText ( [ /Bücher/ , /Podcasts/ , /Folgen/ , /Artikel/ ] ) ;
await expect ( page . locator ( '#tab-suche .sx-group' ) . first ( ) . locator ( 'mark' ) ) . toHaveText ( 'Kapital' ) ;
await page . locator ( '#tab-suche .sx-q' ) . press ( 'Enter' ) ;
await page . locator ( '#tab-suche .sx-q' ) . fill ( '' ) ;
await expect ( page . locator ( '#tab-suche .sx-recent .sx-chip' ) ) . toHaveText ( [ 'kapital' ] ) ;
// Not where the app opens next time.
expect ( await page . evaluate ( ( ) => localStorage . getItem ( 'diora_nav_section' ) ) ) . not . toBe ( 'suche' ) ;
} ) ;
2026-10-19 20:14:17 +02:00
test ( 'Projekte: a Forschung project, a paper by DOI, a linked book, a note citing them' , async ( { page } ) => {
await page . setViewportSize ( { width : 1200 , height : 900 } ) ;
await page . route ( '**/projects/doi/?q=*' , route => route . fulfill ( { json : { doi : '10.1038/nn.1234' , csl : {
type : 'article-journal' , title : 'Neural correlates of political ideology' , 'container-title' : 'Nature Neuroscience' ,
author : [ { family : 'Amodio' , given : 'David' } , { family : 'Jost' , given : 'John' } ] , issued : { 'date-parts' : [ [ 2007 , 9 ] ] } ,
DOI : '10.1038/nn1979' , volume : '10' , page : '1246-1247' } } } ) ) ;
await page . goto ( '/#projekte' ) ;
const tab = page . locator ( '#tab-projekte' ) ;
await tab . locator ( '[data-act="new"]' ) . click ( ) ;
await expect ( tab . locator ( '.prj-preset.on' ) ) . toContainText ( 'Forschung' ) ;
await tab . locator ( 'input[name="name"]' ) . fill ( 'Neuropolitik' ) ;
await tab . locator ( 'button[type="submit"]' ) . click ( ) ;
await expect ( tab . locator ( '.prj-h1' ) ) . toHaveText ( 'Neuropolitik' ) ;
await expect ( page ) . toHaveURL ( /#projekte\/\d+$/ ) ;
// The server keeps only ciphertext.
const raw = await page . evaluate ( ( ) => fetch ( '/projects/' ) . then ( r => r . json ( ) ) ) ;
expect ( JSON . stringify ( raw ) ) . not . toContain ( 'Neuropolitik' ) ;
await tab . locator ( '.prj-add input' ) . fill ( 'https://doi.org/10.1038/nn1979' ) ;
await tab . locator ( '.prj-add button' ) . click ( ) ;
await tab . locator ( '[data-add-doi]' ) . click ( ) ;
await expect ( tab . locator ( '.prj-row .prj-title' ) ) . toHaveText ( [ 'Neural correlates of political ideology' ] ) ;
await expect ( tab . locator ( '.prj-key' ) ) . toHaveText ( [ 'amodio2007neural' ] ) ;
// From a book's sheet elsewhere: DioraProjects.link.
await page . evaluate ( ( ) => DioraProjects . link ( 'book' , 4242 , { title : 'Das politische Gehirn' , author : 'Hans Muster' } ) ) ;
await page . locator ( '.prj-picker [data-pick]' ) . filter ( { hasText : 'Neuropolitik' } ) . click ( ) ;
await expect ( tab . locator ( '.prj-row' ) ) . toHaveCount ( 2 ) ;
expect ( await page . evaluate ( ( ) => DioraProjects . of ( 'book' , 4242 ) . map ( p => p . name ) ) ) . toEqual ( [ 'Neuropolitik' ] ) ;
await tab . locator ( '[data-view="notes"]' ) . click ( ) ;
await tab . locator ( '[data-act="new-note"]' ) . click ( ) ;
await tab . locator ( '[data-note-title]' ) . fill ( 'These' ) ;
2026-10-19 20:28:27 +02:00
await tab . locator ( '[data-note-body]' ) . fill ( '# Idee\nIdeologie zeigt sich im Gehirn [@amodio2007neural, S. 3] und bei [@muster0jdas].\n\n| Text | Zeit |\n|---|---|\n| Cobb | 2,5 h |\nDanach.' ) ;
2026-10-19 20:14:17 +02:00
await expect ( tab . locator ( '.prj-saved' ) ) . toHaveText ( 'gespeichert' , { timeout : 5000 } ) ;
await tab . locator ( '[data-act="preview"]' ) . click ( ) ;
await expect ( tab . locator ( '.prj-md .prj-cite' ) . first ( ) ) . toHaveText ( '(Amodio & Jost 2007, S. 3)' ) ;
await expect ( tab . locator ( '.prj-refs li' ) . first ( ) ) . toContainText ( 'Nature Neuroscience' ) ;
2026-10-19 20:28:27 +02:00
await expect ( tab . locator ( '.prj-md td' ) ) . toHaveText ( [ 'Cobb' , '2,5 h' ] ) ;
2026-10-19 20:14:17 +02:00
await tab . locator ( '[data-view="sources"]' ) . click ( ) ;
const [ dl ] = await Promise . all ( [ page . waitForEvent ( 'download' ) , tab . locator ( '[data-export="bib"]' ) . click ( ) ] ) ;
const bib = require ( 'fs' ) . readFileSync ( await dl . path ( ) , 'utf8' ) ;
expect ( bib ) . toContain ( '@article{amodio2007neural,' ) ;
expect ( bib ) . toContain ( 'doi = {10.1038/nn1979}' ) ;
} ) ;
Abos & Quellen: alle Abos an einem Ort (Navigation Phase 2)
Ein neuer Abschnitt (docs/sources.md, static/js/sources.js) für Podcasts,
YouTube-Kanäle, Hörbücher, Nachrichten und Blogs:
- ein Feld zum Hinzufügen: Adresse (Feed, Seite, YouTube) oder Name eines
Podcasts; die Art ist wählbar (Podcast, Hörbuch, Nachrichten, Blog);
/feeds/discover/ erkennt dafür Podcasts (Folgen mit Ton oder Video);
- eine Liste aller Abos mit Filtern (auch „Probleme“) und Suche;
- je Abo: Inhalt öffnen, Einstellungen (die vorhandenen Editoren), Art ändern,
abbestellen; OPML-Import und -Export.
In der Seitenleiste unten, auf dem Telefon im App-Menü; ⚙ in den Feeds führt
hierher. Abschnitte, die beim Laden schon gezeigt werden, füllen sich jetzt
auch (nav.js zeigt sie nach dem Laden aller Skripte noch einmal).
Podcast-UI aus diora-android 50c6f9c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:23:12 +02:00
test ( 'Abos & Quellen: one page for every subscription, reached from the feeds too' , async ( { page } ) => {
await page . setViewportSize ( { width : 1200 , height : 800 } ) ;
await page . goto ( '/#news' ) ;
await page . locator ( '#tab-feeds [data-act="manage"]' ) . first ( ) . click ( ) ;
await expect ( page ) . toHaveURL ( /#sources$/ ) ;
const tab = page . locator ( '#tab-sources' ) ;
await expect ( tab . locator ( '.src-h1' ) ) . toHaveText ( 'Abos & Quellen' ) ;
await expect ( tab . locator ( '.src-add-q' ) ) . toBeVisible ( ) ;
await expect ( tab . locator ( '.src-chip.on' ) ) . toContainText ( 'Nachrichten' ) ; // came from the news
await tab . locator ( '[data-filter="all"]' ) . click ( ) ;
await expect ( tab . locator ( '.src-chip.on' ) ) . toContainText ( 'Alle' ) ;
} ) ;
2026-10-16 20:56:19 +02:00
test ( 'the library: a header with search, sort, layout and +, the add menu' , async ( { page } ) => {
await page . setViewportSize ( { width : 1200 , height : 800 } ) ;
await page . goto ( '/#books' ) ;
const list = page . locator ( '#book-list' ) ;
await expect ( list . locator ( '.lib-h' ) ) . toHaveText ( 'Bücher' ) ;
await expect ( list . locator ( '.lib-q' ) ) . toBeVisible ( ) ;
await expect ( list . locator ( '.lib-sort' ) ) . toBeVisible ( ) ;
await expect ( page . locator ( '#book-drop-zone' ) ) . toBeHidden ( ) ; // adding is behind +
await list . locator ( '[data-lib="add"]' ) . click ( ) ;
await expect ( page . locator ( '.dfo-sheet' ) ) . toContainText ( 'E-Book-Datei' ) ;
await page . locator ( '.dfo-sheet [data-x]' ) . click ( ) ;
await expect ( page . locator ( '.dfo-sheet' ) ) . toHaveCount ( 0 ) ;
await page . goto ( '/#magazines' ) ;
await expect ( list . locator ( '.lib-h' ) ) . toHaveText ( 'Zeitschriften' ) ;
} ) ;
Neue Navigations-Icons (Stil B), mehrere Bücher auf einmal, generiertes Cover pro Buch wählbar
- Icons: eigene SVGs, kräftig und geometrisch (2,4 px, eckige Enden), in
nav.js für alle Leisten (DioraNav.icon, sections()/groups() .svg); der
aktive Bereich gefüllt.
- Hochladen: mehrere Dateien auf einmal (Auswahl oder auf die Bibliothek
ziehen) und aus der Cloud per Häkchen („Alle auswählen“, „n importieren“);
nacheinander mit Fortschritt, ein schon vorhandenes Buch (Titel und Autor)
wird übersprungen.
- Pro Buch: „Generiertes Cover verwenden“ statt des eigenen (im verschlüsselten
Meta, cover: 'generated').
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:21:10 +02:00
test ( 'several books at once; one already there is left out' , async ( { page } ) => {
const files = [ 'Eins' , 'Zwei' ] . map ( n => path . join ( _ _dirname , '.auth' , ` many- ${ n } .epub ` ) ) ;
await page . goto ( '/#books' ) ;
await page . waitForFunction ( ( ) => typeof _importMany === 'function' && Array . isArray ( _lastBookListData ) ) ;
const status = page . locator ( '#book-upload-status' ) ;
await page . locator ( '#book-file-input' ) . setInputFiles ( files ) ;
await expect ( status ) . toContainText ( '2 Bücher hinzugefügt' , { timeout : 30000 } ) ;
await page . waitForFunction ( ( ) => _lastBookListData . filter ( b => / ^ Mehrfach / . test ( b . title ) ) . length === 2 ) ;
await page . locator ( '#book-file-input' ) . setInputFiles ( files ) ;
await expect ( status ) . toContainText ( '2 schon da' , { timeout : 30000 } ) ;
// Out again, so the other tests see the library as before.
const books = await page . evaluate ( ( ) => _lastBookListData . filter ( b => / ^ Mehrfach / . test ( b . title ) ) . map ( b => b . id ) ) ;
for ( const id of books ) await page . request . post ( ` /books/ ${ id } /delete/ ` ) ;
} ) ;
2026-10-19 14:27:14 +02:00
test ( 'editions: two books joined show once with their formats, set apart show twice' , async ( { page } ) => {
await page . setViewportSize ( { width : 1200 , height : 800 } ) ;
const files = [ 'Eins' , 'Zwei' ] . map ( n => path . join ( _ _dirname , '.auth' , ` many- ${ n } .epub ` ) ) ;
await page . goto ( '/#books' ) ;
await page . waitForFunction ( ( ) => typeof _importMany === 'function' && Array . isArray ( _lastBookListData ) ) ;
await page . locator ( '#book-file-input' ) . setInputFiles ( files ) ;
2026-10-19 15:37:11 +02:00
await page . waitForFunction ( ( ) => _lastBookListData . filter ( b => / ^ Mehrfach / . test ( b . title ) && b . id ) . length === 2 , null , { timeout : 30000 } ) ;
2026-10-19 14:27:14 +02:00
const ids = await page . evaluate ( ( ) => _lastBookListData . filter ( b => / ^ Mehrfach / . test ( b . title ) ) . map ( b => b . id ) ) ;
const list = page . locator ( '#book-list' ) ;
// New books: as cards under "Ungelesen" (not in the "Zuletzt gelesen" area on top).
const unread = ( ) => page . evaluate ( ( ) => { _lib . status = 'unread' ; renderBookList ( _lastBookListData ) ; } ) ;
await unread ( ) ;
const cards = ( ) => list . locator ( '.lib-grid [data-lib-book]' ) . filter ( { hasText : 'Mehrfach' } ) ;
await expect ( cards ( ) ) . toHaveCount ( 2 ) ;
// Choose both, join them.
await page . evaluate ( ids => { _lib . selecting = new Set ( ids ) ; renderBookList ( _lastBookListData ) ; } , ids ) ;
await list . locator ( '[data-lib="sel-editions"]' ) . click ( ) ;
await page . waitForFunction ( ids => {
const w = ids . map ( id => _lastBookListData . find ( b => b . id === id ) . work ) ;
return w [ 0 ] && w [ 0 ] === w [ 1 ] ;
} , ids ) ;
await expect ( cards ( ) ) . toHaveCount ( 1 ) ;
await expect ( cards ( ) . locator ( '.lib-ed' ) ) . toHaveText ( 'EPUB' ) ;
// The work survives a reload (it is in the encrypted meta).
await page . reload ( ) ;
await page . waitForFunction ( ( ) => Array . isArray ( _lastBookListData ) && _lastBookListData . length ) ;
await unread ( ) ;
await expect ( cards ( ) ) . toHaveCount ( 1 ) ;
// Ausgaben: both listed; one set apart, and the other is on its own again.
await cards ( ) . locator ( '.lib-more' ) . click ( { force : true } ) ;
await page . locator ( '.dfo-sheet .lib-sheet-item' , { hasText : 'Ausgaben (2)' } ) . click ( ) ;
await expect ( page . locator ( '.dfo-sheet .lib-ed-row' ) ) . toHaveCount ( 2 ) ;
await page . locator ( '.dfo-sheet [data-unlink]' ) . first ( ) . click ( ) ;
await page . waitForFunction ( ids => ids . every ( id => ! _lastBookListData . find ( b => b . id === id ) . work ) , ids ) ;
await expect ( cards ( ) ) . toHaveCount ( 2 ) ;
await page . evaluate ( ( ) => { _lib . status = 'all' ; } ) ;
for ( const id of ids ) await page . request . post ( ` /books/ ${ id } /delete/ ` ) ;
} ) ;
2026-10-19 18:03:49 +02:00
test ( 'a meta change from an old list goes onto the current meta (409, then again)' , async ( { page } ) => {
const files = [ path . join ( _ _dirname , '.auth' , 'many-Eins.epub' ) ] ;
await page . goto ( '/#books' ) ;
await page . waitForFunction ( ( ) => typeof _importMany === 'function' && Array . isArray ( _lastBookListData ) ) ;
await page . locator ( '#book-file-input' ) . setInputFiles ( files ) ;
await page . waitForFunction ( ( ) => _lastBookListData . some ( b => b . title === 'Mehrfach Eins' && b . id && b . meta _iv ) , null , { timeout : 30000 } ) ;
const id = await page . evaluate ( ( ) => _lastBookListData . find ( b => b . title === 'Mehrfach Eins' ) . id ) ;
// Elsewhere the title changes; this page still has the old list.
await page . evaluate ( id => _updateBookMeta ( id , { title : 'Anderswo umbenannt' } ) , id ) ;
await page . evaluate ( id => {
const b = _lastBookListData . find ( x => x . id === id ) ;
b . title = 'Mehrfach Eins' ; b . meta _iv = '000000000000000000000000' ; // the old list
bookMetaCache [ id ] = { ... bookMetaCache [ id ] , title : 'Mehrfach Eins' } ;
} , id ) ;
await page . evaluate ( id => _updateBookMeta ( id , { folders : [ 'Probe' ] } ) , id ) ;
const meta = await page . evaluate ( async id => {
const row = ( await ( await fetch ( '/books/' , { cache : 'no-store' } ) ) . json ( ) ) . find ( b => b . id === id ) ;
return JSON . parse ( new TextDecoder ( ) . decode ( await decryptBytes ( await getOrCreateEncKey ( ) , row . meta _iv , row . meta _ct ) ) ) ;
} , id ) ;
expect ( meta . title ) . toBe ( 'Anderswo umbenannt' ) ;
expect ( meta . folders ) . toEqual ( [ 'Probe' ] ) ;
await page . request . post ( ` /books/ ${ id } /delete/ ` ) ;
} ) ;