/** * Phase 3 mobile — long-press gesture. * * The `longpress` action attaches to `
` in FeedListCard and to * grid cells in VirtualFeed (grid mode). Holding for ≥ 500 ms fires `onlongpress`, * which opens the ContextSheet bottom sheet via the feed page's * `contextTarget` state. * * Setup needs at least one upload to render a card. We post via the * Phase 2 upload-client helper, then drive the gesture with the touch * helper. */ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { longPress } from '../../helpers/touch'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); async function seedUpload(token: string, caption = 'Longpress fixture') { const body = readFileSync(SAMPLE_JPG); const res = await uploadRaw(token, body, { filename: 'lp.jpg', contentType: 'image/jpeg', caption, }); if (res.status !== 201) { const text = await res.text(); throw new Error(`Upload seed failed (${res.status}): ${text}`); } return res.json() as Promise<{ id: string }>; } test.describe('Mobile — long-press gesture', () => { test('long-press on a FeedListCard opens the ContextSheet', async ({ page, guest, signIn }) => { const g = await guest('Lp1'); await seedUpload(g.jwt, 'Hold to open'); await signIn(page, g); await page.goto('/feed'); // Wait for at least one article to render. Caption text appears once feed loads. const card = page.locator('article').filter({ hasText: g.displayName }).first(); await expect(card).toBeVisible({ timeout: 10_000 }); await longPress(page, card, 600); // The ContextSheet is always mounted (it translates off-screen when closed). // Target it by its stable data-testid, gated on aria-modal="true" which the // component sets only while open — unambiguous vs. the centered LightboxModal // (which also has aria-modal) and independent of the animation classes. const sheet = page.locator('[data-testid="context-sheet"][aria-modal="true"]'); await expect(sheet).toBeVisible({ timeout: 2_000 }); await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible(); }); test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ page, guest, signIn, }) => { const g = await guest('Lp2'); await seedUpload(g.jwt, 'Quick tap'); await signIn(page, g); await page.goto('/feed'); const card = page.locator('article').filter({ hasText: g.displayName }).first(); await expect(card).toBeVisible({ timeout: 10_000 }); // Simulate a short press (200 ms — well under the 500 ms threshold). await longPress(page, card, 200); // Within 1 s, the ContextSheet must not be open (aria-modal is set only when // open). A quick tap opens the lightbox instead, which is a different element. await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000, }); }); test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn, }) => { const g = await guest('Lp3'); await seedUpload(g.jwt, 'Suppress click'); await signIn(page, g); await page.goto('/feed'); const card = page.locator('article').filter({ hasText: g.displayName }).first(); await expect(card).toBeVisible({ timeout: 10_000 }); await longPress(page, card, 700); // The longpress action sets `suppressNextClick = true` — so the lightbox // (separate role=dialog) should NOT appear in addition to the context sheet. // Exactly one aria-modal=true dialog should be open: the context sheet. await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { timeout: 2_000, }); }); });