diff --git a/client/e2e/help/trip-day-detail.guide.ts b/client/e2e/help/trip-day-detail.guide.ts index a918ad56c..b03352640 100644 --- a/client/e2e/help/trip-day-detail.guide.ts +++ b/client/e2e/help/trip-day-detail.guide.ts @@ -31,17 +31,29 @@ const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: ne * nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it; * the card is the second `role="presentation"` and holds the day range. */ -const picker = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Apply to days' }).last() -/** The accommodation block: the heading's parent holds the stays and the add button. */ -const stays = (page: Page) => panel(page).getByText('Accommodation', { exact: true }).locator('xpath=..') -/** One booked stay: a card of the list inside that block. */ -const stayCard = (page: Page, name: string) => stays(page).locator('> div > div').filter({ hasText: name }).first() +const picker = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Apply to days' }) +/** The row of the stay's three times, with Confirmation right under it. */ +const times = (page: Page) => picker(page).getByPlaceholder('14:00').locator('xpath=ancestor::div[contains(@class,"grid-cols-3")][1]') +/** + * A block of the panel by its heading. Every block is a
whose first + * row carries the heading, so the section is the block with everything in it. + */ +const block = (page: Page, heading: string) => + panel(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]') +/** The accommodation block: the stays and the add button. */ +const stays = (page: Page) => block(page, 'Accommodation') +/** One booked stay: an
card inside that block. */ +const stayCard = (page: Page, name: string) => stays(page).locator('article').filter({ hasText: name }).first() /** The caption that opens the weather block. */ const forecast = (page: Page) => panel(page).getByText(/^Forecast for /) -/** The chips row; the summary above it and the hourly strip below are its siblings. */ -const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[2]') +/** The weather's pills (rain, wind, sunrise, sunset), at the right end of the summary row. */ +const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[1]') +/** The summary row's numbers: the temperature, the low and high, the condition. */ +const summary = (page: Page) => forecast(page).locator('xpath=following-sibling::div[1]/div[1]') +/** The hourly strip under its own heading. */ +const hourly = (page: Page) => panel(page).getByText('Hourly Forecast', { exact: true }).locator('xpath=..') /** The day's reservations block. */ -const bookings = (page: Page) => panel(page).getByText('Reservations', { exact: true }).locator('xpath=..') +const bookings = (page: Page) => block(page, 'Reservations') /** One booking row: on the desktop a button that opens the booking, named with its title. */ const bookingRow = (page: Page, title: string) => panel(page).getByRole('button', { name: title }) /** The booking a row opens, named by its title. */ @@ -158,9 +170,9 @@ const SCRIPTS: Record = { }, steps: [ only(forecast), - only(p => chips(p).locator('xpath=preceding-sibling::div[1]')), + only(summary), only(chips), - only(p => chips(p).locator('xpath=following-sibling::div[1]')), + only(hourly), ], }, 'rename-day': { @@ -216,7 +228,8 @@ const SCRIPTS: Record = { await expect(picker(p).getByRole('button', { name: /^Day 7/ })).toBeVisible() await settle(p) }, - target: p => picker(p).getByText('Apply to days').locator('xpath=..'), + // The row of Start, End and All: the All button's parent. + target: p => picker(p).getByRole('button', { name: 'All', exact: true }).locator('xpath=..'), }, { // The three time fields normalise what is typed; the labels carry no @@ -228,13 +241,14 @@ const SCRIPTS: Record = { await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842') await settle(p) }, - target: p => picker(p).getByPlaceholder('ABC-12345').locator('xpath=ancestor::div[2]'), + target: times, }, { - // "All" is both the whole-trip shortcut and the no-filter chip, so the - // category is clicked by its own name. + // The category is a pill over the list; its choices open in a list of their own. prepare: async p => { - await picker(p).getByRole('button', { name: 'Hotel', exact: true }).click() + await picker(p).getByRole('button', { name: /^Category:/ }).click() + await p.getByRole('group', { name: 'Category' }).getByRole('button', { name: 'Hotel', exact: true }).click() + await expect(p.getByRole('group', { name: 'Category' })).toHaveCount(0) await settle(p) }, target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }), @@ -280,7 +294,7 @@ const SCRIPTS: Record = { await picker(p).getByPlaceholder('ABC-12345').click() await settle(p) }, - target: p => picker(p).getByPlaceholder('ABC-12345').locator('xpath=ancestor::div[2]'), + target: times, }, { target: p => picker(p).getByRole('button', { name: 'Save' }), diff --git a/client/e2e/help/trip-days.guide.ts b/client/e2e/help/trip-days.guide.ts index 70f59090d..4a6c20613 100644 --- a/client/e2e/help/trip-days.guide.ts +++ b/client/e2e/help/trip-days.guide.ts @@ -50,6 +50,10 @@ const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name 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 @@ -70,9 +74,9 @@ const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).loc * 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: its own portal with no backdrop class, known by its body field. */ +/** The note dialog, known by its body field. */ const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…') -const noteDialog = (page: Page) => portalDialog(page, noteBody(page)) +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. */ @@ -149,7 +153,7 @@ const SCRIPTS: Record = { // 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 => placeRow(p, SPARE.name).getByRole('button'), + 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' })), @@ -250,7 +254,7 @@ const SCRIPTS: Record = { // The "+" puts the place on the OPEN day, so day 1 stays selected. await openTripOnDay(p, 1) await placeRow(p, SPARE.name).hover() - await placeRow(p, SPARE.name).getByRole('button').click() + await toOpenDay(p, SPARE.name).click() await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 }) await settle(p) }, @@ -298,22 +302,28 @@ const SCRIPTS: Record = { start: p => openTrip(p), steps: [ { - target: p => dayHeader(p, 2).getByRole('button', { name: 'Add Note' }), + // 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 dayHeader(p, 2).getByRole('button', { name: 'Add Note' }).click() + await menu(p).getByRole('button', { name: 'Add Note' }).click() await expect(noteBody(p)).toBeVisible() await settle(p) }, }, { prepare: async p => { - // Note is the name the day card shows and the only required field; - // Daily Note below it is the markdown the toolbar formats. + // 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: p => noteBody(p).locator('xpath=ancestor::div[2]'), + target: noteDialog, }, { target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'), @@ -416,8 +426,11 @@ const SCRIPTS: Record = { 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. - target: p => renameDay(p).locator('xpath=..'), + // 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/ })), @@ -431,7 +444,7 @@ const SCRIPTS: Record = { 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, /^Departure LH716/)), + only(p => bookingRow(p, /^LH716 FRA → HND Departure/)), { prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) }, target: p => stop(p, 'Nishiki Market'), @@ -442,7 +455,7 @@ const SCRIPTS: Record = { await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible() await settle(p) }, - target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=..'), + 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/ })), @@ -464,9 +477,9 @@ const SCRIPTS: Record = { await settle(p) }, }, - only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=..')), - only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=..')), - only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=..')), + 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, }, diff --git a/client/e2e/help/trip-place.guide.ts b/client/e2e/help/trip-place.guide.ts index 781ae32bb..f9eddb0f7 100644 --- a/client/e2e/help/trip-place.guide.ts +++ b/client/e2e/help/trip-place.guide.ts @@ -38,19 +38,22 @@ const UPLOADED = 'cover-fixture.jpg' /** The card itself: the scroll body's parent holds head, body and footer. */ const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..') -const head = (page: Page) => card(page).locator('> div').first() -const footer = (page: Page) => card(page).locator('div.border-t') +const head = (page: Page) => card(page).locator('> header') +const footer = (page: Page) => card(page).locator('> footer') +/** A block of the card by its heading: every block is a
that opens with it. */ +const section = (page: Page, heading: string) => + card(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]') /** A row of the places column (the rows are options of a list). */ const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() /** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */ const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first() /** PlaceRating's root: the stars, the average and the voter faces on one line. */ const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..') -/** ParticipantsBox: found by its eyebrow, which is the box's first child. */ -const participants = (page: Page) => card(page).getByText('Participants', { exact: true }).locator('xpath=..') +/** ParticipantsBox: the block under its heading. */ +const participants = (page: Page) => section(page, 'Participants') /** A member's chip, or, once the chip is gone, that member's line in the add list. */ const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) }) -const addMember = (page: Page) => participants(page).getByRole('button', { name: '+', exact: true }) +const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true }) /** "Files" while the place has none, " files" once it has. */ const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ }) /** The booking strip opens the booking; it is a button named after it. */ @@ -71,8 +74,8 @@ const weekday = (offset: number) => long(offset).split(',')[0] * ("Saturday: 06:00-17:00") and the day moves with the run. */ const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)') -/** The hours box: the row and, once unfolded, the week under it. */ -const hoursBox = (page: Page) => hoursRow(page).locator('xpath=..') +/** The hours block: the row and, once unfolded, the week in its place. */ +const hoursBox = (page: Page) => section(page, 'Opening Hours') /** The card's own X, the one in its head; the day panel over the map has another. */ async function closeCard(page: Page): Promise { @@ -237,7 +240,7 @@ const SCRIPTS: Record = { // around the collapsed row would show the reader one line of it. prepare: async p => { await hoursRow(p).click() - await expect(hoursRow(p)).toHaveText('Opening Hours') + await expect(hoursBox(p).getByRole('button', { name: 'Collapse' })).toBeVisible() await expect(hoursBox(p).getByText(/^Sunday: /)).toBeVisible() await settle(p) }, @@ -551,7 +554,7 @@ const SCRIPTS: Record = { await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible() }, }, - only(p => card(p).getByText('Track Stats', { exact: true }).locator('xpath=ancestor::div[2]')), + only(p => section(p, 'Track Stats')), { target: p => card(p).getByRole('button', { name: 'Track color' }), act: async p => { diff --git a/client/e2e/help/trip-places.guide.ts b/client/e2e/help/trip-places.guide.ts index 561653ede..ebddfdb6e 100644 --- a/client/e2e/help/trip-places.guide.ts +++ b/client/e2e/help/trip-places.guide.ts @@ -1,7 +1,7 @@ import { test, expect, type Page, type Locator } from '@playwright/test' import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' import { seededTrip, gpxFixture, ensureTrack } from './fixtures' -import { openTrip, modal, dialog, confirmDialog, portalDialog } from './trip-shared' +import { openTrip, modal, dialog, confirmDialog } from './trip-shared' import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces' import type { HelpGuide } from '../../src/help/types' @@ -22,22 +22,41 @@ const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, addre /** What create-place looks up. See the note in its second step for why this one. */ const SEARCHED = { query: 'Osaka Castle', match: /^Osaka Castle/ } +/** The search result create-place picks. */ +const suggestion = (page: Page) => + modal(page).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first() /** A row of the places column by the place's name (the rows are options of a list). */ const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first() /** The context menu is a fixed popover of plain buttons; the item texts tell it apart. */ const menu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Delete' }) }).last() /** The add button: "Add Place/Activity" with no day open, "New place" with one. */ const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\/Activity|New place)$/ }) -/** The filter row's controls. */ -const filterSelect = (page: Page) => page.getByTestId('places-filter').getByRole('button').first() -const searchBox = (page: Page) => page.getByPlaceholder('Search places...').last() -/** FileImportModal: its own portal, recognised by the hidden file input and its heading. */ +/** + * The head band of the column: the add row, the search with the select switch, + * and the row of filters. Found from its import button, the one control only + * this band has, so the plain "Search" of the field is read inside it. + */ +const head = (page: Page) => + page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]') +/** The import button's menu: Import file and the list import. */ +const importMenu = (page: Page) => + page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Import file' }) }).last() +/** The Show dropdown (All, Unplanned, Planned, Tracks) and the list it opens. */ +const showTrigger = (page: Page) => head(page).getByRole('button', { name: 'Show', exact: true }) +const showList = (page: Page) => page.getByTestId('places-filter').locator('xpath=..') +/** The category filter: a tag button whose list sits right after it. */ +const categoryTrigger = (page: Page) => head(page).getByRole('button', { name: 'Categories', exact: true }) +const categoryList = (page: Page) => categoryTrigger(page).locator('xpath=following-sibling::div[1]') +const searchBox = (page: Page) => head(page).getByPlaceholder('Search', { exact: true }) +/** The selection bar at the foot of the column, found by its delete action. */ +const selectionBar = (page: Page) => page.getByRole('button', { name: 'Delete selected' }).locator('xpath=..') +/** FileImportModal, recognised by its hidden file input. */ const importFileDialog = (page: Page) => - portalDialog(page, page.locator('input[type="file"][accept*=".gpx"]')) -/** ListImportModal: its own portal, recognised by the link box. */ -const listImportDialog = (page: Page) => portalDialog(page, page.getByPlaceholder(/goo\.gl|naver/)) + page.getByRole('dialog').filter({ has: page.locator('input[type="file"][accept*=".gpx"]') }) +/** ListImportModal, recognised by the link box. */ +const listImportDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder(/goo\.gl|naver/) }) /** The drop box of the file dialog: the dashed button that opens the file picker. */ -const dropBox = (page: Page) => importFileDialog(page).getByRole('button').first() +const dropBox = (page: Page) => importFileDialog(page).locator('button.border-dashed').first() /** The column itself: the header block around the search box. */ const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[contains(@class,"scroll") or @data-places-sidebar][1]') @@ -81,6 +100,9 @@ const SCRIPTS: Record = { }, }, { + // The results are pictured before the pick: once one is taken the + // search box empties again, and a ring round an empty box shows the + // reader nothing of what the step says. prepare: async p => { const box = modal(p).getByPlaceholder('Search places...') // Osaka Castle on purpose: its entry in the index carries a picture, @@ -88,16 +110,19 @@ const SCRIPTS: Record = { // text says it shows. Most results have none of that and the column // would be an empty state in the picture. await typeInto(p, box, SEARCHED.query) - const suggestion = modal(p).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first() - await expect(suggestion).toBeVisible({ timeout: 20_000 }) - await suggestion.click() + await expect(suggestion(p)).toBeVisible({ timeout: 20_000 }) + await settle(p) + }, + target: p => modal(p).locator('.shadow-dropdown').first(), + hover: p => suggestion(p).hover(), + act: async p => { + await suggestion(p).click() await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(SEARCHED.match, { timeout: 20_000 }) // The details column answers after the form; wait for it or the // picture catches the spinner. await expect(modal(p).getByText('Pick a picture', { exact: false }).first()).toBeVisible({ timeout: 30_000 }) await settle(p) }, - target: p => modal(p).getByPlaceholder('Search places...').locator('xpath=../..'), }, // The details column is an