Compare commits

...

1 Commits

Author SHA1 Message Date
c81907ccfe test(magazine): cover driving seek controls 2026-09-26 19:39:00 +02:00
3 changed files with 47 additions and 4 deletions

View File

@ -52,7 +52,7 @@ Two self-hosted Docker instances, both port 7890:
| Studio (phase wizard) | — | `studio.js` | mature |
| TTS engines / routing | `routes/tts.py`, `core/routing.py` | `engines.js` | mature |
| STT | `routes/stt.py` | — | mature |
| **Magazines** | `core/magazine.py`, `routes/magazine.py` | `magazine.js`, `s-magazine.html` | **P0 + P1** |
| **Magazines** | `core/magazine.py`, `routes/magazine.py` | `magazine.js`, `s-magazine.html` | **P0 + P1**; generated-MP3 playlist and driving-mode seek/±15 s transport |
## Data model

View File

@ -258,9 +258,12 @@ works regardless.
`#mag-driving` is a full-screen overlay, not a separate playback path: it
reuses whatever is already playing or queued, so entering it never
interrupts an article in progress. Two oversized controls (skip, play/pause)
and no other interactive surface, matching the "no interaction required"
behaviour the manual originally specified as a P2 goal.
interrupts an article in progress. Its touch-sized transport exposes the
previous/next article, back/forward 15 seconds, play/pause, and direct seeking
on the shared playlist `<audio>` element. Seeking is bounded to the current
track. Playback speed and the optional synchronized text/image companion are
browser-local preferences; none of these controls changes server-side article
data or creates a second playback state.
### Automatic sidebar/advert exclusion

View File

@ -217,6 +217,46 @@ test.describe('Magazine reader', () => {
await expect(exportPicker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
});
test('driving mode seeks and skips 15 seconds within the current article', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-driving-open').click();
await expect(page.locator('#mag-driving')).toBeVisible();
// Use a deterministic two-minute track so the transport controls can be
// exercised without depending on the browser's media decoder clock.
await page.locator('#mag-playlist-audio').evaluate((audio) => {
audio.pause();
Object.defineProperties(audio, {
duration: { configurable: true, value: 120 },
currentTime: { configurable: true, writable: true, value: 60 },
});
});
const currentTime = () => page.locator('#mag-playlist-audio').evaluate((audio) => audio.currentTime);
await page.getByRole('button', { name: 'Back 15 seconds', exact: true }).click();
await expect.poll(currentTime).toBe(45);
await page.getByRole('button', { name: 'Forward 15 seconds', exact: true }).click();
await expect.poll(currentTime).toBe(60);
await page.locator('#mag-playlist-audio').evaluate((audio) => { audio.currentTime = 5; });
await page.getByRole('button', { name: 'Back 15 seconds', exact: true }).click();
await expect.poll(currentTime).toBe(0);
await page.locator('#mag-playlist-audio').evaluate((audio) => { audio.currentTime = 115; });
await page.getByRole('button', { name: 'Forward 15 seconds', exact: true }).click();
await expect.poll(currentTime).toBe(120);
const seek = page.getByRole('slider', { name: 'Position in article' });
await seek.evaluate((input) => {
input.value = '250';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect.poll(currentTime).toBe(30);
});
test('library lists a magazine and opens its contents tree', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });