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');
|
|
|
|
|
await tab.locator('[data-note-body]').fill('# Idee\nIdeologie zeigt sich im Gehirn [@amodio2007neural, S. 3] und bei [@muster0jdas].');
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
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/`);
|
|
|
|
|
});
|