// @ts-check // Tests: S1–S9 — story mode rendering and navigation // Requires demo data: run `make demo-load` before this suite. const { test, expect } = require('@playwright/test'); const TRIP_URL = '/trips/italy-2026-demo'; // stories now surface in the trip feed const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // used for cross-trip hero sanity check // ── S1: Trip feed shows story cards ────────────────────────────────────────── test('S1: trip feed renders at least 3 story cards', async ({ page }) => { await page.goto(TRIP_URL); const cards = page.locator('[data-type="story"]'); await expect(cards.first()).toBeVisible({ timeout: 5000 }); const count = await cards.count(); expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3); }); // ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ── test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => { await page.goto(STORY_GALLERY); // Hero: real image rendered, no placeholder await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0); // Snap-gallery (there are two in this story) const galleries = page.locator('.pgallery'); await expect(galleries.first()).toBeVisible(); expect(await galleries.count(), 'Two snap-galleries').toBe(2); // Chapter-break await expect(page.locator('.chapter-break')).toBeVisible(); // Text-only pull-quote (no background image variant) await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible(); }); // ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ───── test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => { await page.goto(STORY_SCROLLY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); // Two scrolly-sections const scrollySections = page.locator('.scrolly'); await expect(scrollySections.first()).toBeVisible(); expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2); // Pull-quote with background image await expect(page.locator('.pull-quote__bg')).toBeVisible(); }); // ── S4: Scrolly story loads without JS errors (Scrollama CDN) ──────────────── test('S4: scrolly story page loads without JS errors', async ({ page }) => { const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.goto(STORY_SCROLLY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await page.waitForTimeout(1000); expect(errors, 'No JS errors on story page').toHaveLength(0); }); // ── S5: Back button returns to the originating trip page ───────────────────── test('S5: story back button navigates back to the trip page', async ({ page }) => { // Establish history: trip page → story → back await page.goto(TRIP_URL); await page.locator('[data-type="story"]').first().click(); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await page.locator('.story-escape').click(); // story-escape runs history.back() when history exists → back to the trip page await expect(page).toHaveURL(/\/trips\/italy-2026-demo$/); await expect(page.locator('.journal-post').first()).toBeVisible(); }); // ── S6: Demo story — hero image sanity check ───────────────────────────────── test('S6: demo story renders hero image without placeholder', async ({ page }) => { await page.goto(DEMO_STORY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0); }); // ── S7: Story body back link is styled as a back-pill ──────────────────────── test('S7: story body back link has back-pill class', async ({ page }) => { await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn'); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); // Scroll past the hero to reveal the story body await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5)); await page.waitForTimeout(300); const bodyBack = page.locator('.story-footer .back-pill'); await expect(bodyBack).toBeAttached(); await expect(bodyBack).toHaveText(/← Back/); }); // ── S8: Scrolly-section text panels actually render beside the pinned image ─── // The server ships the panel text inside .scrolly__steps-content, which CSS hides // (style.css: `display: none`). Only the inline Scrollama block in story.html.twig // splits it into visible .scrolly-step divs — and it early-returns silently if the // main.js bundle (which sets window.scrollama) hasn't executed yet. S3 asserted the // image column exists; nothing asserted the text column was non-empty. test('S8: scrolly-section builds visible step panels from its slot content', async ({ page }) => { await page.goto(STORY_SCROLLY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); // The bundle must have published scrollama before the inline block ran expect( await page.evaluate(() => typeof window.scrollama !== 'undefined'), 'window.scrollama published by main.js bundle' ).toBe(true); // Every scrolly-section must have produced at least one step const sections = page.locator('.scrolly'); const sectionCount = await sections.count(); expect(sectionCount, 'Two scrolly-sections').toBe(2); for (let i = 0; i < sectionCount; i++) { const section = sections.nth(i); const steps = section.locator('.scrolly-step'); expect( await steps.count(), `scrolly-section ${i} split its slot content into steps` ).toBeGreaterThan(0); } // …and the text must be readable, not left hidden in the raw slot. // Scroll each step into view so its reveal transition completes. const firstStep = page.locator('.scrolly').first().locator('.scrolly-step').first(); await firstStep.scrollIntoViewIfNeeded(); await page.waitForTimeout(800); await expect(firstStep.locator('.scrolly-step__inner')).toBeVisible(); const text = (await firstStep.innerText()).trim(); expect(text.length, 'First step panel renders non-empty text').toBeGreaterThan(20); }); // ── S9: Back-to-top is wired once, by main.js, and pushes a history entry ───── // The inline duplicate in story.html.twig was removed; initBackToTop() in // js/src/main.js now solely owns #story-totop. That makes the button depend on // the bundle having loaded, so assert the observable behaviour end to end. test('S9: story back-to-top reveals on scroll, returns to top, and pushes history', async ({ page }) => { await page.goto(STORY_SCROLLY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); const btn = page.locator('#story-totop'); await expect(btn).toBeAttached(); // Hidden until scrolled past the 0.8 * viewport threshold await expect(btn).not.toHaveClass(/is-visible/); const historyBefore = await page.evaluate(() => history.length); await page.evaluate(() => window.scrollTo(0, window.innerHeight * 2)); await expect(btn).toHaveClass(/is-visible/, { timeout: 3000 }); await btn.click(); await expect .poll(() => page.evaluate(() => window.scrollY), { timeout: 3000 }) .toBeLessThan(10); // main.js's variant pushes a history entry; the removed inline copy did not expect( await page.evaluate(() => history.length), 'Back-to-top pushed a history entry' ).toBeGreaterThan(historyBefore); });