mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
551 lines
23 KiB
TypeScript
551 lines
23 KiB
TypeScript
import { test, expect, type Page, type Locator } from '@playwright/test'
|
|
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
|
import { seededTrip, ensureDaysFixtures } from './fixtures'
|
|
import { openTrip, openTripOnDay, modal, portalDialog, selectDay, closeDayDetails, closeMenu, dayDetails } from './trip-shared'
|
|
import { tripDaysContext, tripDaysGuides } from '../../src/help/contexts/tripDays'
|
|
import type { HelpGuide } from '../../src/help/types'
|
|
|
|
/**
|
|
* Actions for the days column of a trip, keyed by the ids in
|
|
* `src/help/contexts/tripDays.ts`. The seeded trip gets a timed stop, a
|
|
* reservation at a stop, a train and a hotel first, so the cards show every
|
|
* kind of row; a guide that changes the plan puts it back in `cleanup`.
|
|
*/
|
|
|
|
const guide = (id: string): HelpGuide => {
|
|
const g = tripDaysGuides.find(x => x.id === id)
|
|
if (!g) throw new Error(`no registered guide "${id}"`)
|
|
return g
|
|
}
|
|
|
|
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
|
|
/** The note the day-note guide writes: a name for the card, markdown underneath. */
|
|
const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of the shrine, tickets at the gate.' }
|
|
|
|
/** A day card's header button. */
|
|
/**
|
|
* The day the route guides read. Day 6 is the only one whose two stops sit next
|
|
* to each other: day 1's are separated by the flight, so it has no leg of its
|
|
* own and its only connector is the one to the booked night, which depends on a
|
|
* fixture. Day 6 also carries a reservation at a stop, so one card shows every
|
|
* row the guide talks about.
|
|
*/
|
|
const ROUTED_DAY = 6
|
|
/** The pencil that renames the open day, in its details panel. */
|
|
const renameDay = (page: Page) => dayDetails(page).getByRole('button', { name: 'Edit', exact: true })
|
|
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
|
/**
|
|
* A stop row. Every row of a day card is a div with `role="button"`; a stop is
|
|
* the one whose accessible name opens with the label of the lock on its
|
|
* picture, which is what tells it from the note and booking rows beside it.
|
|
*/
|
|
const stop = (page: Page, name: string) =>
|
|
page
|
|
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
|
|
.filter({ hasText: name })
|
|
.first()
|
|
/** A booking row of a day: its phase (Departure / Arrival) and title make its name. */
|
|
const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name })
|
|
/** The note the day-note guide left on day 2, as a row of the card. */
|
|
const noteRow = (page: Page) => page.getByRole('button', { name: new RegExp(`^${NOTE.title}`) })
|
|
/** A row of the places column. */
|
|
const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
|
/** The "+ Day" at the end of a places row: it puts the place on the open day. */
|
|
const toOpenDay = (page: Page, name: string) => placeRow(page, name).getByRole('button', { name: '+ Day' })
|
|
/** The "+" in a day's head band; the note, the transports and the stay are in the menu it opens. */
|
|
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
|
|
/**
|
|
* The context menu of a stop, a note or a leg. Tooltip renders into the same
|
|
* `.trek-popover-enter` class and a click on a connector leaves its tooltip
|
|
* standing, so the menu is the popover that is not one.
|
|
*/
|
|
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
|
|
/** The route bar of the open day: Route, the two hand-offs, Optimize, the modes. */
|
|
const routeBar = (page: Page) => page.getByRole('button', { name: 'Route', exact: true }).locator('xpath=..')
|
|
const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).locator('xpath=..')
|
|
/**
|
|
* A connector between two stops, once the day's route is computed. The hotel
|
|
* bookend legs carry the same label and are not between two stops, so they are
|
|
* filtered out by the hotel icon only they have.
|
|
*/
|
|
/**
|
|
* A leg between two rows of a day, ringed by its travel time. Day 1's is the one
|
|
* to the booked night: the flight sits between its two stops, so they have no
|
|
* leg of their own, and the day fixtures put a hotel on the first four nights.
|
|
*/
|
|
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
|
|
/** The note dialog, known by its body field. */
|
|
const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…')
|
|
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBody(page) })
|
|
/** The question a timed stop asks before it moves; also a portal of its own. */
|
|
const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?'))
|
|
/** The reorder popup's panel, the list and its footer, or the question a bin asks in its place. */
|
|
const reorderPopup = (page: Page) => page.locator('.trek-modal-enter').filter({ hasText: 'Reorder days' })
|
|
|
|
async function createSpare(page: Page): Promise<void> {
|
|
const { tripId } = seededTrip()
|
|
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
|
|
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
|
|
}
|
|
|
|
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
|
const { tripId } = seededTrip()
|
|
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
|
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
|
const places = Array.isArray(body) ? body : (body.places ?? [])
|
|
for (const place of places.filter(x => names.includes(x.name))) {
|
|
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the trip on day 1 and draw its route, so the connectors between the
|
|
* stops exist. The day stays selected with its details panel up: the panel is
|
|
* the selection, and the route tools render for the selected day only.
|
|
*/
|
|
async function openWithRoute(page: Page): Promise<void> {
|
|
await openTripOnDay(page, ROUTED_DAY)
|
|
await page.getByRole('button', { name: 'Route', exact: true }).click()
|
|
await expect(connector(page)).toBeVisible({ timeout: 30_000 })
|
|
await settle(page)
|
|
}
|
|
|
|
const closeModal = async (page: Page): Promise<void> => {
|
|
if (await modal(page).isVisible().catch(() => false)) {
|
|
await page.keyboard.press('Escape')
|
|
await expect(modal(page)).toHaveCount(0)
|
|
}
|
|
}
|
|
|
|
const only = (target: (p: Page) => Locator) => ({ target })
|
|
|
|
/** The menu a right-click on a day's head opens. */
|
|
/** The question before a day is cleared (a ConfirmDialog, which carries no dialog role). */
|
|
const clearDialog = (page: Page) => page.locator('.trek-modal-enter').filter({ has: page.getByRole('heading', { name: /^Clear .+\?$/ }) }).last()
|
|
const dayMenu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Clear day' }) }).last()
|
|
|
|
const SCRIPTS: Record<string, GuideScript> = {
|
|
'clear-day': {
|
|
guide: guide('clear-day'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await dayHeader(p, 2).click({ button: 'right' })
|
|
await expect(dayMenu(p)).toBeVisible()
|
|
await beat(p, 300)
|
|
},
|
|
target: dayMenu,
|
|
act: async p => {
|
|
await dayMenu(p).getByRole('button', { name: 'Clear day' }).click()
|
|
await expect(p.getByRole('heading', { name: /^Clear .+\?$/ })).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: clearDialog,
|
|
act: async p => {
|
|
await clearDialog(p).getByRole('button', { name: 'Clear day' }).click()
|
|
await expect(clearDialog(p)).toHaveCount(0, { timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: async p => {
|
|
// Undo puts the seed's stops back on the day for the guides after this one.
|
|
const undo = p.getByRole('button', { name: 'Undo' }).last()
|
|
if (await undo.isVisible()) await undo.click()
|
|
await settle(p)
|
|
},
|
|
},
|
|
'read-day-plan': {
|
|
guide: guide('read-day-plan'),
|
|
start: openWithRoute,
|
|
steps: [
|
|
only(p => dayHeader(p, ROUTED_DAY)),
|
|
only(p => stop(p, 'Arashiyama Bamboo Grove')),
|
|
only(p => stop(p, 'Nishiki Market')),
|
|
only(connector),
|
|
only(routeBar),
|
|
],
|
|
},
|
|
'place-onto-day': {
|
|
guide: guide('place-onto-day'),
|
|
start: async p => {
|
|
await createSpare(p)
|
|
// Day 1 stays open: the "+" of a place row and "To day" exist only while
|
|
// a day is selected, and steps 2 and 4 are about those two.
|
|
await openTripOnDay(p, 1)
|
|
},
|
|
steps: [
|
|
{
|
|
target: p => placeRow(p, SPARE.name),
|
|
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
|
|
act: async p => {
|
|
await placeRow(p, SPARE.name).dragTo(dayHeader(p, 2))
|
|
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
// The "+" offers the OPEN day, so it is still there with the place on
|
|
// day 2 — and the place stays on a day, which is what the result says.
|
|
prepare: async p => { await placeRow(p, SPARE.name).hover() },
|
|
target: p => toOpenDay(p, SPARE.name),
|
|
},
|
|
only(p => p.getByRole('button', { name: 'Add place to this day' }).first()),
|
|
only(p => p.getByRole('button', { name: 'Add to the open day' })),
|
|
],
|
|
cleanup: p => deleteByName(p, SPARE.name),
|
|
},
|
|
'reorder-stops': {
|
|
guide: guide('reorder-stops'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
|
|
dropTo: p => stop(p, 'Senso-ji Temple'),
|
|
},
|
|
{
|
|
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
|
|
target: p => stop(p, 'teamLab Planets').locator('button:has(svg.lucide-chevron-up)'),
|
|
},
|
|
{
|
|
target: p => stop(p, 'teamLab Planets'),
|
|
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
// Only a move that breaks the day's chronology asks, and the arrows
|
|
// never do here: the timed stop is the first row of the day. Dropping
|
|
// it below the 13:05 departure does, and Cancel leaves the day alone.
|
|
const booking = bookingRow(p, /Departure.*LH716/)
|
|
const box = await booking.boundingBox()
|
|
if (!box) throw new Error('the departure row of day 1 has no box')
|
|
await stop(p, 'Senso-ji Temple').dragTo(booking, { targetPosition: { x: box.width / 2, y: box.height - 4 } })
|
|
await expect(timeConfirm(p)).toBeVisible({ timeout: 10_000 })
|
|
await settle(p)
|
|
},
|
|
target: timeConfirm,
|
|
act: async p => {
|
|
await timeConfirm(p).getByRole('button', { name: 'Cancel' }).click()
|
|
await expect(p.getByText('Remove time?')).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
},
|
|
'set-stop-times': {
|
|
guide: guide('set-stop-times'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await stop(p, 'teamLab Planets').click({ button: 'right' })
|
|
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
|
await beat(p, 300)
|
|
},
|
|
target: menu,
|
|
act: async p => {
|
|
await menu(p).getByRole('button', { name: 'Edit' }).click()
|
|
await expect(modal(p).getByText('Start', { exact: true })).toBeVisible({ timeout: 10_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
// The time pickers are text fields that parse on blur.
|
|
const times = modal(p).getByPlaceholder(/^(00:00|2:30 PM)$/)
|
|
await times.first().fill('13:00')
|
|
await times.first().blur()
|
|
await times.nth(1).fill('15:00')
|
|
await times.nth(1).blur()
|
|
await beat(p, 300)
|
|
},
|
|
target: p => modal(p).getByText('Start', { exact: true }).locator('xpath=../..'),
|
|
},
|
|
{
|
|
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
|
|
act: async p => {
|
|
await modal(p).getByRole('button', { name: 'Update', exact: true }).click()
|
|
await expect(modal(p)).toHaveCount(0)
|
|
await expect(stop(p, 'teamLab Planets').getByText('13:00')).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: async p => {
|
|
const { tripId, dayIds } = seededTrip()
|
|
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`)
|
|
const body = (await res.json()) as { assignments?: { id: number; place?: { name: string }; place_name?: string }[] }
|
|
for (const a of body.assignments ?? []) {
|
|
if ((a.place?.name ?? a.place_name) === 'teamLab Planets') {
|
|
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/time`, { data: { place_time: null, end_time: null } })
|
|
}
|
|
}
|
|
},
|
|
},
|
|
'remove-from-day': {
|
|
guide: guide('remove-from-day'),
|
|
start: async p => {
|
|
await createSpare(p)
|
|
// The "+" puts the place on the OPEN day, so day 1 stays selected.
|
|
await openTripOnDay(p, 1)
|
|
await placeRow(p, SPARE.name).hover()
|
|
await toOpenDay(p, SPARE.name).click()
|
|
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await stop(p, SPARE.name).click({ button: 'right' })
|
|
await expect(menu(p).getByRole('button', { name: 'Remove from day' })).toBeVisible()
|
|
await beat(p, 300)
|
|
},
|
|
target: p => menu(p).getByRole('button', { name: 'Remove from day' }),
|
|
act: async p => {
|
|
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
|
|
await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
only(p => placeRow(p, SPARE.name)),
|
|
],
|
|
cleanup: p => deleteByName(p, SPARE.name),
|
|
},
|
|
'lock-stop': {
|
|
guide: guide('lock-stop'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
|
|
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }),
|
|
act: async p => {
|
|
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }).click()
|
|
await expect(stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' })).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }),
|
|
act: async p => {
|
|
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }).click()
|
|
},
|
|
},
|
|
],
|
|
},
|
|
'day-note': {
|
|
guide: guide('day-note'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
// The "+" of the day's head band opens the menu; Add Note is its last entry.
|
|
prepare: async p => {
|
|
await addToDay(p, 2).click()
|
|
await expect(menu(p).getByRole('button', { name: 'Add Note' })).toBeVisible()
|
|
await beat(p, 300)
|
|
},
|
|
target: p => menu(p).getByRole('button', { name: 'Add Note' }),
|
|
act: async p => {
|
|
await menu(p).getByRole('button', { name: 'Add Note' }).click()
|
|
await expect(noteBody(p)).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
// The name is typed into the head band and is what the day card shows,
|
|
// the only required field; Daily Note is the markdown the toolbar formats.
|
|
await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title)
|
|
await typeInto(p, noteBody(p), NOTE.body)
|
|
await beat(p, 300)
|
|
},
|
|
target: noteDialog,
|
|
},
|
|
{
|
|
target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'),
|
|
act: async p => {
|
|
await noteDialog(p).getByRole('button', { name: 'Add', exact: true }).click()
|
|
await expect(noteDialog(p)).toHaveCount(0)
|
|
await expect(noteRow(p)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await noteRow(p).click({ button: 'right' })
|
|
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
|
await beat(p, 300)
|
|
},
|
|
target: noteRow,
|
|
act: closeMenu,
|
|
},
|
|
],
|
|
cleanup: async p => {
|
|
const { tripId, dayIds } = seededTrip()
|
|
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[1]}/notes`)
|
|
const body = (await res.json()) as { notes?: { id: number; text: string }[] } | { id: number; text: string }[]
|
|
const notes = Array.isArray(body) ? body : (body.notes ?? [])
|
|
for (const note of notes.filter(n => n.text.startsWith(NOTE.title))) {
|
|
await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`)
|
|
}
|
|
},
|
|
},
|
|
'day-route': {
|
|
guide: guide('day-route'),
|
|
start: p => openTripOnDay(p, ROUTED_DAY),
|
|
steps: [
|
|
{
|
|
target: p => p.getByRole('button', { name: 'Route', exact: true }),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Route', exact: true }).click()
|
|
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
only(p => routeBar(p).getByRole('button', { name: 'Driving' }).locator('xpath=..')),
|
|
{
|
|
prepare: async p => {
|
|
await connector(p).click()
|
|
await expect(menu(p).getByRole('button', { name: 'Use day default' })).toBeVisible()
|
|
await beat(p, 300)
|
|
},
|
|
target: menu,
|
|
act: closeMenu,
|
|
},
|
|
only(p => p.getByRole('button', { name: 'Optimize' })),
|
|
// The two hand-offs are siblings with no wrapper of their own: their
|
|
// parent is the whole route bar, which is already read-day-plan's step 5.
|
|
only(p => p.getByRole('button', { name: 'Open in Google Maps' })),
|
|
],
|
|
},
|
|
'manage-days': {
|
|
guide: guide('manage-days'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
only(p => dayHeader(p, 1)),
|
|
{
|
|
prepare: async p => {
|
|
await p.getByRole('button', { name: 'Reorder days' }).click()
|
|
await expect(p.getByText('Reorder days').first()).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
// The whole popup: the rows with their arrows and bins, and the two ways
|
|
// of adding a day in its footer.
|
|
target: reorderPopup,
|
|
},
|
|
{
|
|
// The question before a day goes, answered with Cancel: the picture shows
|
|
// what would go with the second day, its places and its landing, and the
|
|
// seed keeps all of its days.
|
|
prepare: async p => {
|
|
await reorderPopup(p).getByRole('button', { name: 'Delete day' }).nth(1).click()
|
|
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: reorderPopup,
|
|
act: async p => {
|
|
await reorderPopup(p).getByRole('button', { name: 'Cancel' }).click()
|
|
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toHaveCount(0)
|
|
await reorderPopup(p).getByRole('button', { name: 'Close', exact: true }).last().click()
|
|
await expect(reorderPopup(p)).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
// The steps before leave the first day selected, and a click on a
|
|
// selected day folds its panel away: a second one brings it back.
|
|
await dayHeader(p, 1).click({ position: { x: 22, y: 20 } })
|
|
await settle(p)
|
|
if (!(await dayDetails(p).isVisible())) await selectDay(p, 1)
|
|
await expect(renameDay(p)).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
// Scoped to the panel: a stop row carries an Edit of its own, so the
|
|
// bare name matches two buttons as soon as any day is unfolded. The
|
|
// ring goes round the panel's head band, title and pencil together: a
|
|
// ring round the pencil alone sits on the day's name beside it.
|
|
target: p => renameDay(p).locator('xpath=ancestor::div[@role="presentation"][1]'),
|
|
hover: p => renameDay(p).hover(),
|
|
act: closeDayDetails,
|
|
},
|
|
only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })),
|
|
],
|
|
},
|
|
'bookings-in-plan': {
|
|
guide: guide('bookings-in-plan'),
|
|
start: async p => {
|
|
await openTrip(p, { day: 5 })
|
|
},
|
|
steps: [
|
|
// The flight, not the train: Departure and Arrival are what a booking
|
|
// gets when it crosses days, and the train starts and ends on one.
|
|
only(p => bookingRow(p, /^LH716 FRA → HND Departure/)),
|
|
{
|
|
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
|
|
target: p => stop(p, 'Nishiki Market'),
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await selectDay(p, 1)
|
|
await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'),
|
|
act: closeDayDetails,
|
|
},
|
|
only(p => toolbar(p).getByRole('button', { name: /Show all booking routes|Hide all booking routes/ })),
|
|
{
|
|
prepare: async p => { await stop(p, 'Senso-ji Temple').hover() },
|
|
target: p => stop(p, 'Senso-ji Temple').getByRole('button', { name: 'Add booking' }),
|
|
},
|
|
],
|
|
},
|
|
'export-plan': {
|
|
guide: guide('export-plan'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
target: p => p.getByRole('button', { name: 'Export' }),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Export' }).click()
|
|
await expect(modal(p).getByText('Document', { exact: true })).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=ancestor::section[1]')),
|
|
only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=ancestor::section[1]')),
|
|
only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=ancestor::section[1]')),
|
|
],
|
|
cleanup: closeModal,
|
|
},
|
|
}
|
|
|
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
await ensureDaysFixtures(request)
|
|
})
|
|
|
|
test('every registered days guide has a script, and only those', async () => {
|
|
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDaysContext.guides].sort())
|
|
})
|
|
|
|
test('hero: trip-days', async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
// The plan as it opens: the details panel is trip-day-detail's subject and the
|
|
// route is day-route's, so the hero shows neither.
|
|
await captureHero(page, tripDaysContext.id, p => openTrip(p))
|
|
})
|
|
|
|
for (const id of tripDaysContext.guides) {
|
|
test(`pictures: ${id}`, async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureGuide(page, SCRIPTS[id])
|
|
})
|
|
}
|