mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||
import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||
import { seededTrip, ensureDayDetailFixtures, KYOTO_HOTEL, TOKYO_HOTEL, EVENING_WALK } from './fixtures'
|
||
import { openTrip } from './trip-shared'
|
||
import { tripDayDetailContext, tripDayDetailGuides } from '../../src/help/contexts/tripDayDetail'
|
||
import type { HelpGuide } from '../../src/help/types'
|
||
|
||
/**
|
||
* Actions for the day details panel of a trip, keyed by the ids in
|
||
* `src/help/contexts/tripDayDetail.ts`. They run on the seeded "Autumn in
|
||
* Japan" with the day fixtures in place, so day 1 carries the flight and the
|
||
* Tokyo hotel and day 5 the train and its evening walk; what a guide writes it
|
||
* takes back in `cleanup`, so the next one starts from the same seed.
|
||
*/
|
||
|
||
const guide = (id: string): HelpGuide => {
|
||
const g = tripDayDetailGuides.find(x => x.id === id)
|
||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||
return g
|
||
}
|
||
|
||
/** The name the rename guide gives day 5, and takes away again. */
|
||
const DAY_NAME = 'Arrival in Kyoto'
|
||
|
||
/** The panel itself: the only `fixed z-50` in the client. */
|
||
const panel = (page: Page) => page.locator('div.fixed.z-50')
|
||
/** A day's header in the days column; its name is number, weather, title, date. */
|
||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||
/**
|
||
* The hotel picker is a portal of its own with neither `trek-modal-backdrop`
|
||
* 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.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 <section> 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 <article> 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 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) => 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. */
|
||
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||
|
||
/**
|
||
* Select a day by its title rather than by the middle of its header: a day with
|
||
* a night booked over it carries a hotel button there, and that button opens the
|
||
* place instead of selecting the day.
|
||
*/
|
||
const selectDay = async (page: Page, n: number): Promise<void> => {
|
||
await dayHeader(page, n).getByText(`Day ${n}`, { exact: true }).click()
|
||
await expect(panel(page)).toBeVisible()
|
||
await settle(page)
|
||
}
|
||
|
||
/** Open the trip with day `n`'s panel open; `openTrip`'s own day closes it again. */
|
||
const openDay = async (page: Page, n: number): Promise<void> => {
|
||
await openTrip(page, { day: null })
|
||
await selectDay(page, n)
|
||
}
|
||
|
||
interface Stay {
|
||
id: number
|
||
place_id: number | null
|
||
place_name?: string | null
|
||
start_day_id: number
|
||
end_day_id: number
|
||
}
|
||
|
||
async function stayOf(page: Page, name: string): Promise<Stay | undefined> {
|
||
const { tripId } = seededTrip()
|
||
const res = await page.request.get(`/api/trips/${tripId}/accommodations`)
|
||
const { accommodations } = (await res.json()) as { accommodations: Stay[] }
|
||
return accommodations.find(a => a.place_name === name)
|
||
}
|
||
|
||
/** Cancel a night again. The stop it put on the day and its Hotel booking go with it. */
|
||
async function removeStay(page: Page, name: string): Promise<void> {
|
||
const { tripId } = seededTrip()
|
||
const stay = await stayOf(page, name)
|
||
if (stay) await page.request.delete(`/api/trips/${tripId}/accommodations/${stay.id}`)
|
||
}
|
||
|
||
/** The update route reads every field off the body, so the whole stay goes back. */
|
||
async function restoreTokyoStay(page: Page): Promise<void> {
|
||
const { tripId } = seededTrip()
|
||
const stay = await stayOf(page, TOKYO_HOTEL)
|
||
if (!stay) return
|
||
await page.request.put(`/api/trips/${tripId}/accommodations/${stay.id}`, {
|
||
data: {
|
||
place_id: stay.place_id,
|
||
start_day_id: stay.start_day_id,
|
||
end_day_id: stay.end_day_id,
|
||
check_in: '15:00',
|
||
check_in_end: null,
|
||
check_out: '11:00',
|
||
confirmation: 'GRC-7731',
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Give day 5 its number back: `selectDay` finds a day by "5 … Day 5 ", which a
|
||
* renamed day no longer answers to, and every later guide uses it.
|
||
*/
|
||
async function resetDayTitle(page: Page, index: number): Promise<void> {
|
||
const { tripId, dayIds } = seededTrip()
|
||
await page.request.put(`/api/trips/${tripId}/days/${dayIds[index]}`, { data: { title: null } })
|
||
}
|
||
|
||
const only = (target: (p: Page) => Locator) => ({ target })
|
||
|
||
const SCRIPTS: Record<string, GuideScript> = {
|
||
'day-panel': {
|
||
guide: guide('day-panel'),
|
||
start: p => openTrip(p, { day: null }),
|
||
steps: [
|
||
{
|
||
target: p => dayHeader(p, 1),
|
||
act: async p => {
|
||
await selectDay(p, 1)
|
||
await expect(stays(p)).toBeVisible()
|
||
},
|
||
},
|
||
only(p => panel(p).getByText('Day 1', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||
{
|
||
// The forecast is asked for when the panel opens, and it is a round trip
|
||
// to Open-Meteo; without the wait the picture catches the spinner.
|
||
prepare: async p => { await expect(forecast(p)).toBeVisible({ timeout: 20_000 }) },
|
||
target: p => forecast(p).locator('xpath=..'),
|
||
},
|
||
only(bookings),
|
||
only(stays),
|
||
{
|
||
target: p => panel(p).getByRole('button', { name: 'Collapse' }),
|
||
act: async p => {
|
||
// Every day card carries a chevron with the same label, so the panel's
|
||
// own is reached through the panel.
|
||
await panel(p).getByRole('button', { name: 'Collapse' }).click()
|
||
await expect(panel(p).getByRole('button', { name: 'Expand' })).toBeVisible()
|
||
await expect(stays(p)).toBeHidden()
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
'day-weather': {
|
||
guide: guide('day-weather'),
|
||
start: async p => {
|
||
await openDay(p, 5)
|
||
// Day 5 has one located stop, so the forecast is that stop's.
|
||
await expect(panel(p).getByText('Forecast for Fushimi Inari Taisha')).toBeVisible({ timeout: 20_000 })
|
||
},
|
||
steps: [
|
||
only(forecast),
|
||
only(summary),
|
||
only(chips),
|
||
only(hourly),
|
||
],
|
||
},
|
||
'rename-day': {
|
||
guide: guide('rename-day'),
|
||
start: p => openDay(p, 5),
|
||
steps: [
|
||
only(p => panel(p).getByText('Day 5', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||
{
|
||
// Every booking row of the open day carries an Edit of its own in the
|
||
// days column (DayPlanSidebar), so the header's pencil is reached
|
||
// through the panel; inside it, it is the only one.
|
||
target: p => panel(p).getByRole('button', { name: 'Edit', exact: true }),
|
||
act: async p => {
|
||
await panel(p).getByRole('button', { name: 'Edit', exact: true }).click()
|
||
await expect(panel(p).getByPlaceholder('Day 5')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => { await typeInto(p, panel(p).getByPlaceholder('Day 5'), DAY_NAME) },
|
||
target: p => panel(p).getByPlaceholder('Day 5'),
|
||
act: async p => {
|
||
await p.keyboard.press('Enter')
|
||
await expect(panel(p).getByText(DAY_NAME, { exact: true })).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
only(p => p.getByRole('button', { name: new RegExp(`^5 .*${DAY_NAME} `) })),
|
||
],
|
||
cleanup: p => resetDayTitle(p, 4),
|
||
},
|
||
'add-accommodation': {
|
||
guide: guide('add-accommodation'),
|
||
start: p => openTrip(p, { day: null }),
|
||
steps: [
|
||
// The fixture put the hotel in the trip as a place; the picker offers
|
||
// nothing else, and the rows of the column are options.
|
||
only(p => p.getByRole('option', { name: new RegExp(`^${KYOTO_HOTEL.name}`) })),
|
||
{
|
||
prepare: async p => { await selectDay(p, 5) },
|
||
target: p => p.getByRole('button', { name: 'Add accommodation' }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Add accommodation' }).click()
|
||
await expect(picker(p)).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The two day selects are named Start and End; their options are
|
||
// portalled to the body, outside the picker.
|
||
prepare: async p => {
|
||
await picker(p).getByRole('button', { name: 'End', exact: true }).click()
|
||
await p.getByRole('button', { name: /^Day 7/ }).last().click()
|
||
await expect(picker(p).getByRole('button', { name: 'End', exact: true })).toContainText('Day 7')
|
||
await settle(p)
|
||
},
|
||
// 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
|
||
// `for`, so every field is found by its placeholder.
|
||
prepare: async p => {
|
||
await picker(p).getByPlaceholder('14:00').fill('15:00')
|
||
await picker(p).getByPlaceholder('22:00').fill('20:00')
|
||
await picker(p).getByPlaceholder('11:00').fill('11:00')
|
||
await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842')
|
||
await settle(p)
|
||
},
|
||
target: times,
|
||
},
|
||
{
|
||
// 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: /^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}`) }),
|
||
act: async p => {
|
||
await picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }).click()
|
||
await expect(picker(p).getByRole('button', { name: 'Save' })).toBeEnabled()
|
||
},
|
||
},
|
||
{
|
||
target: p => picker(p).getByRole('button', { name: 'Save' }),
|
||
act: async p => {
|
||
await picker(p).getByRole('button', { name: 'Save' }).click()
|
||
await expect(picker(p)).toHaveCount(0)
|
||
await expect(stayCard(p, KYOTO_HOTEL.name)).toBeVisible({ timeout: 15_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
cleanup: p => removeStay(p, KYOTO_HOTEL.name),
|
||
},
|
||
'edit-accommodation': {
|
||
guide: guide('edit-accommodation'),
|
||
start: async p => {
|
||
// The day fixtures book the Tokyo hotel over the first nights, so day 1
|
||
// is its check-in day.
|
||
await openDay(p, 1)
|
||
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
|
||
},
|
||
steps: [
|
||
only(p => stayCard(p, TOKYO_HOTEL)),
|
||
{
|
||
// Neither the pencil nor the × carries a label; the icon is the handle.
|
||
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)'),
|
||
act: async p => {
|
||
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
|
||
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await picker(p).getByPlaceholder('11:00').fill('10:00')
|
||
await picker(p).getByPlaceholder('ABC-12345').click()
|
||
await settle(p)
|
||
},
|
||
target: times,
|
||
},
|
||
{
|
||
target: p => picker(p).getByRole('button', { name: 'Save' }),
|
||
act: async p => {
|
||
await picker(p).getByRole('button', { name: 'Save' }).click()
|
||
await expect(picker(p)).toHaveCount(0)
|
||
// Day 1 is the arrival: the card shows the check-in window, not the new check-out.
|
||
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Deliberately not performed: the × removes the stay without a question,
|
||
// and with it the seed's hotel booking and the stop it put on the day.
|
||
prepare: async p => { await stayCard(p, TOKYO_HOTEL).hover() },
|
||
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-x)'),
|
||
},
|
||
],
|
||
cleanup: restoreTokyoStay,
|
||
},
|
||
'day-bookings': {
|
||
guide: guide('day-bookings'),
|
||
start: p => openDay(p, 5),
|
||
steps: [
|
||
only(bookings),
|
||
only(p => bookingRow(p, EVENING_WALK)),
|
||
only(p => bookingRow(p, 'Nozomi 21')),
|
||
{
|
||
// A row opens its booking over the plan; Edit at its foot is where it changes.
|
||
prepare: async p => {
|
||
await bookingRow(p, 'Nozomi 21').click()
|
||
await expect(bookingDetail(p, 'Nozomi 21')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
target: p => bookingDetail(p, 'Nozomi 21').getByRole('button', { name: 'Edit', exact: true }),
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||
|
||
test.describe.configure({ mode: 'serial' })
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
await ensureDayDetailFixtures(request)
|
||
})
|
||
|
||
test('every registered day-detail guide has a script, and only those', async () => {
|
||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDayDetailContext.guides].sort())
|
||
})
|
||
|
||
test('hero: trip-day-detail', async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
// Day 1 is the one day that has all four blocks at once: the forecast for its
|
||
// first stop, the flight, and the hotel it checks into.
|
||
await captureHero(page, tripDayDetailContext.id, p => openDay(p, 1))
|
||
})
|
||
|
||
for (const id of tripDayDetailContext.guides) {
|
||
test(`pictures: ${id}`, async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureGuide(page, SCRIPTS[id])
|
||
})
|
||
}
|