import { test, expect, type Locator, type Page } from '@playwright/test' import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' import { dotted } from '../dates' import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, BOOKING_EML, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures' import { openTrip, modal, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared' import { requireExtractor, allowEmlUploads } from './external' import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings' import type { HelpGuide } from '../../src/help/types' /** * Actions for the Bookings tab of a trip, keyed by the ids in * `src/help/contexts/tripBookings.ts`. They run on the seeded "Autumn in Japan" * with `ensureBookingsFixtures` in front of them, because the seed's only * reservation is a flight and a flight is a transport: without the fixture this * tab is its empty state. What a guide creates or changes it puts back in * `cleanup`, mostly by deleting the booking and letting the fixture build it * again, so the next one starts from the same seven cards. */ const guide = (id: string): HelpGuide => { const g = tripBookingsGuides.find(x => x.id === id) if (!g) throw new Error(`no registered guide "${id}"`) return g } /** The tab id in the address is the legacy German one; its label is Bookings. */ const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' }) /** A booking's card in the Cards view: an article named after the booking. */ const bookingCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true }) /** The pencil and the bin in a card's head band, named by their tooltips. */ const cardAction = (page: Page, title: string, name: 'Edit' | 'Delete') => bookingCard(page, title).getByRole('button', { name, exact: true }) /** Open a booking's editor from its card, the way the texts tell the reader to. */ async function editFromCard(page: Page, title: string): Promise { await cardAction(page, title, 'Edit').click() await expect(modal(page).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible() await settle(page) } // ── The bar ─────────────────────────────────────────────────────────────────── /** The tab's one bar: its heading, then search, filter, the views and the add buttons. */ const toolbar = (page: Page) => page.getByRole('heading', { name: 'Bookings', exact: true, level: 2 }).locator('xpath=..') const searchBox = (page: Page) => toolbar(page).getByRole('textbox', { name: 'Search', exact: true }) /** The chip that says how many are left ("3 of 7") once anything filters; a click resets. */ const resultsChip = (page: Page) => toolbar(page).getByRole('button', { name: /^\d+ of \d+$/ }) const filterButton = (page: Page) => toolbar(page).getByRole('button', { name: 'Filter', exact: true }) /** The filter panel under the funnel: Status, Type and Travelers. */ const filterMenu = (page: Page) => page.getByRole('menu') const viewSwitch = (page: Page) => page.getByRole('group', { name: 'View', exact: true }) const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwitch(page).getByRole('button', { name, exact: true }) // ── Timeline and detail ─────────────────────────────────────────────────────── /** Trip | Day above the chart. */ const zoom = (page: Page) => page.getByRole('group', { name: 'Zoom', exact: true }) /** A day's heading on the Trip zoom; a click opens that day by the hour. */ const dayHeading = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`Day ${n}$`) }) /** A bar of the timeline, named after its booking. */ const timelineBar = (page: Page, title: string) => page.getByRole('button', { name: title, exact: true }) /** The booking's detail popup, labelled by its title. */ const detail = (page: Page, title: string) => page.getByRole('dialog', { name: title }) /** The booking the views guide opens: a tour on day 5, with a place and travellers. */ const VIEWED = { title: 'Fushimi Inari night walk', day: 5 } // ── The editor ──────────────────────────────────────────────────────────────── /** A field of the booking form, by its own label; the block around it is the label's parent. */ const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first() const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..') /** The two-column or three-column row a field shares with its neighbours. */ const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..') /** A labelled block of a dialog's body whose label is not a `