Compare commits

...

208 Commits

Author SHA1 Message Date
Maurice 31859651d7 feat(help): rewrite the help center for v5 with a Tours section, new pictures in every language, and fix the tour planner bugs it surfaced
Every help screen checked against the v5 redesign. Tours is documented for
the first time, as a screen of its own with twelve screens under it, and
notifications get help of their own. All pictures are taken again on a
realistic demo trip, with short loops where a still cannot show the motion.
The English is plain and every language is written naturally rather than
word for word.

The switcher shows two levels at most, a screen's overview lists its
sub-screens as cards above its guides, each wiki page is linked once, and
the help sidebar is wider.

Fixes found along the way: Save to Collection keeps a tour's route, a track
dropped on the places column opens the Tours import, tour Share on desktop,
the unsaved tour draft lives on the account, a route click no longer adds
an end point, Trip places leaves the trip's tours out, Escape closes a
stage's day menu, confirms before destructive deletes, DWD warnings sharing
one id kept apart, a booking's date moves with it, and smaller fixes across
notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and
the admin screens.
2026-10-22 22:29:35 +02:00
Maurice 98f7549ac5 fix(journey): decide the phone's all-layers view inside the layers hook, follow the tests to the facet chip and the attach plus 2026-10-22 21:55:12 +02:00
Maurice 8e420cc1f1 fix(mobile): split the new phone sheets and cards below the lint limits, follow the tests to the new rows 2026-10-22 21:43:15 +02:00
Maurice 49afee161d feat(mobile): phone styling for tours list, trip settings and admin cards
The tours list in the places browser now uses the same rows as places: a
grade or type tile, the day plan's figure badges and a day badge or plus on
the right. A shared "Planned on desktop" badge replaces the grey hint box
there and sits under the road trip's empty stage.

Trip settings and trip edit open phone sheets instead of the desktop
dialogs; the edit sheet gains the day count of a dateless trip and the
reminder. Questions are asked in phone sheets, not the browser.

Admin on the phone: the paddle regions and school holiday cards are phone
cards on their shared hooks, long addon descriptions fold to two lines,
the addon header keeps the wordmark inside its sentence, the group bands
drop the dash and the permissions actions get a row of their own. The
Tours addon gets its catalog name and description in every locale.

The journey map on the phone shows every layer without the switch bar.
2026-10-22 21:26:41 +02:00
Maurice bb3c0151d5 fix(journey): sort the stay dates by a compare, make the hint pills real buttons and keep the walk's no-boats default a constant 2026-10-21 21:34:53 +02:00
Maurice 3101b36f3d fix(journey,tours): read a day's stays as a read, give the services the request context in the e2e runs and hold the tests to the foot-only walks and the translated notices 2026-10-21 21:16:36 +02:00
Maurice ef4eac34c4 docs(dawarich): word the next-day note of a stay's time range without a dash 2026-10-21 20:55:02 +02:00
Maurice a518b8dfc2 fix(journey): give preparing the recorded days its own section in the journey settings, apart from the trip tracks 2026-10-21 20:54:22 +02:00
Maurice 8523bbb724 fix(journey): say when a stay runs into the next day, leave the doubtful stays of a day unticked and pin a day report to a stay of the day, not the night 2026-10-21 20:46:33 +02:00
Maurice 464d4e006c feat(tours): mark the places along the tour that are a stop of it already 2026-10-21 20:33:12 +02:00
Maurice 1797e1c70d fix(journey): frame the first map view around the entries and the plan, and never hand a removed map the basemap it waited for 2026-10-21 20:33:12 +02:00
Maurice 5dc945145f fix(journey): keep the journey page and maps within their lint gates, format the journey locales 2026-10-21 20:00:07 +02:00
Maurice a1180b2b19 fix(journey): open the map on the plan when nothing else is located, say when the recordings could not be read and open photo hits on the shared map 2026-10-21 19:08:25 +02:00
Maurice d62fc284e1 fix(journey): share the stays of a day report only with the recorded days, and hold the share read tests to the two new flags 2026-10-21 19:08:25 +02:00
Maurice 8f53d98d45 feat(journey): show the plan, what was recorded, the entries and the photos as layers of the journey map
Every journey map (the desktop aside, the phone screens and the shared page)
now carries four switches: Plan, Recorded, Entries and Photos, each with its
count, a spinner while it loads, the warning tone when it could not be read
and the reason when it has nothing to show. A switch only hides, in place:
the map is not rebuilt and keeps its view, and the choice is kept in this
browser per journey (per link on a shared page, by a hash of its token).

The plan draws the places the linked trips put on their days as hollow dashed
pins and the trips' GPX routes dashed; a place an entry was written about is
not drawn twice, the entry's pin carries a check instead. The stays a day
report was taken over from show as dots in the colour of their day. Marks on
one spot join into a disc with their number and open as a list; a picked
place or stay opens a card with its trip, days and times and leads to its
entry. A key beside the switches explains every pin and line and says that
a plan is no proof of a visit. The phone timelines now show the photos too.

The GPX routes come with the map's layers through the linked trips, read
only while the journey's track switch is on; the frozen /tracks answer stays
Studio's. The share settings on desktop and phone get Trip plan and Recorded
routes under Map, off by default and only switchable while the map is shared.
2026-10-21 18:55:54 +02:00
Maurice 9b71948497 feat(journey): read what a journey map shows beside its entries, and share its plan and recorded days on purpose
GET /api/journeys/:id/map-layers answers the planned places of the linked
trips (one per place, with every day the plan puts it on and the entry
written about it), the trips' GPX routes while the track switch is on, and
the located stays entries were taken over from. It goes through
journey_trips, so a trip nobody wrote about yet shows its plan too. Every
source is read on its own: one that fails says unavailable and the others
still draw. A position that is not one is left out, never moved.

GET /api/public/journey/:token/map-layers cuts the same on the server by the
link: nothing while the map is not shared, the plan and its routes only with
share_plan, the recorded days only with share_recordings and only beside a
published entry, stays only of published entries. Migration 046190 adds both
flags to journey_share_tokens, off by default, so a journey that is public
already shows what it showed before. The share link REST route and the MCP
share tool take them alike; get_journey_map_layers reads the layers over MCP
on the same access, a route by its size only.
2026-10-21 18:15:24 +02:00
Maurice bb10b94046 fix(journey): mark only the days a run reaches as prepared, name trips out of reach, show editors their own status in the day review and say when a fetch is already running 2026-10-21 17:53:39 +02:00
Maurice 94c369b913 feat(journey): prepare the recorded days of a journey's trips in the background, switched on per journey, with each day's state
Journey Settings get "Prepare recorded days automatically" under GPX Tracks,
off by default and set by the owner. With it on, the Dawarich background
check also reads the linked trips of the journey for the owner and every
editor with a connection of their own, within the same 400 day reach and
windows as the trip sync, and only for new and changed stays: a trip the
person travels on is not asked twice, and the stays of a trip they are not
on are filed under no trip. Nothing is written into the journey; the stays
wait at their day, and a stay taken over or left out is never offered again.

Under the switch the caller sees when their stays were last fetched, or why
not (no connection, no dated trip, background check off, the reason of the
last failure beside the state still shown), with a "Fetch now" that the
switch also runs when it is turned on. A day of the trips that is not final
yet carries a dot on its take-over button, on the phone in the stay sheet,
and the tooltip says whether it is still running, was fetched before it
ended, was not fetched yet or shows an older state after a failed refresh.
The day review says so for today too.

Server: migration 046180 adds journeys.auto_prepare_recordings and
journey_recording_prep (the last attempt, last success and last error per
journey and person). JourneyRecordingPrepService in the journey domain lists
the prepared trips and keeps the status; DawarichSyncService walks those
trips in the same loop as the person's own and records each journey's
outcome. GET /api/journeys/:id/recordings/prep answers the caller's status
under a @trek/shared contract, the MCP tool get_journey_recording_prep reads
the same, and update_journey takes auto_prepare_recordings like the PUT
contract. The column stays out of the plugin output. All 27 locales carry
the strings; the Journey, Dawarich and MCP tool wiki pages describe it.
2026-10-21 17:39:33 +02:00
Maurice 3f1d1a57cc fix(journey): take a day over from the phone stay sheet, keep a picked position inside its group and send stays without a place unpinned
A journey on a phone with stays on days that hold no entry offers each waiting day in the Dawarich stay sheet, so a day can be built from its stays alone; desktop and phone read the same days through openStayDatesWithin. A position picked for a group falls back to the longest stay with a place once that stay is unchosen or split off, a merged entry of stays without a position goes out unpinned instead of being refused, and a stay taken over meanwhile is never joined across. review_journey_day_stays is no longer marked read-only, since it refreshes the caller's stored stays. The already-taken notice no longer claims the stays were in this journal. An e2e case checks the drafts stay off the public share.
2026-10-21 16:49:47 +02:00
Maurice 7c1de6ed82 feat(journey): take a recorded day over with its stays, chosen, renamed and joined into drafts or one day report
A journey day now has a list button in its header beside the route, with the
number of stays still waiting, and the row of the day's Dawarich stays gets a
"Take over day" button; on a phone the journey's More options offers it for
the day of the card shown. The dialog reads the day again from the caller's
own Dawarich and shows the route and the stays on one map, numbered in the
order they were lived and pinned in the colour of the day, beside a list with
times, durations, the stays Dawarich only guessed or is unsure about, the ones
without a position, the entries the journal has on the day and how many people
will see the drafts. When Dawarich does not answer, it says why and shows the
stays of the last sync.

Open stays start chosen, the same place twice in a row starts joined as one
entry under a title and a chosen map position, any two neighbours can be
joined or kept apart, a name can be corrected and a stay left out. "Day
report" turns every chosen stay into one entry that lists them with their
times, at a chosen stay or off the map. The day's route can go along when the
journey keeps none yet. Nothing is written before "Take over N drafts", and a
blank name or title holds it back with the reason. Stays left out are dismissed
and can be restored in the stay list.

The take-over writes drafts only, never touches what the journal holds, and is
one transaction: every stay is checked to be the caller's and of that day, the
entries, the stays they hold and the marked suggestions commit together, and
the entries are announced after the commit. A stay the journey holds already
is reported instead of written twice, so a retry after a lost answer is
harmless; a failed attempt reads the day again and keeps the choices, and a
route that fails after the drafts can be kept again alone.

Server: migration 046170 adds journey_entry_stays (one row per stay of an
entry with its own name, place and times, unique per journey, source, person
and visit, gone with its entry), owned by the journey domain through
JourneyEntryStaysService. The single stay accept records its stay there too,
over REST and MCP alike. DawarichSyncService.refreshDay reads one day's visits
and upserts them without flagging anything missing; DawarichDayStaysService
lists and settles a day's stays. JourneyDayImportService in journey-recordings
serves POST /api/journeys/:id/recordings/:date/stays/review and /import with
@trek/shared contracts, and the MCP tools review_journey_day_stays and
take_over_journey_day_stays run on the same service and checks. createEntry
can write without announcing for a caller that announces after its commit.

The desktop dialog and the phone sheet of the route (JRN-01) and of the
take-over share one frame each. All 27 locales carry the strings; the Journey
and Dawarich wiki pages and the MCP tool list describe it.
2026-10-21 16:31:40 +02:00
Maurice fbf6cd2840 fix(journey): read a day kept meanwhile again to offer replace, date the phone row in the app language 2026-10-21 15:18:01 +02:00
Maurice 5116f7be2c fix(journey): refuse the second of two saves of a recorded day at once unless it asked to replace 2026-10-21 15:18:01 +02:00
Maurice 4f60cc5c98 fix(integrations): cut a journey day in the zone of its own first track, never in the UTC Dawarich writes times in 2026-10-21 15:18:00 +02:00
Maurice 65bb2d95eb feat(journey): keep the route recorded on a journey day from Dawarich, previewed first, with its gaps
A day header in the journey timeline now carries the Dawarich mark for an
editor whose Dawarich answers (dimmed with the reason while offline). It opens
a dialog that names the journey and the day and reads that one local day from
the caller's own connection: a sketch of the route, distance, first and last
time, pieces, gaps without recording, modes and whether tracks or thinned
points answered. The day is cut in the zone Dawarich records in where its
answer shows one, else in this device's, and a picker cuts it in any other.
Nothing is kept before "Keep route". On a phone the same sheet opens from the
journey's More options for the day of the card shown.

What is kept is the preview that was seen: the save reads the day again and
refuses with recording_changed when its hash moved, and the dialog shows the
new version. The same day taken over twice keeps one version and announces
nothing; a different version is replaced only on "Replace", never touching an
entry or text. A failed read throws before any write, so the kept version
stays. Gaps stay gaps: points are split where silence ends elsewhere, and no
day is joined to the one before. The dialog says how many other people will
see the route; editors may keep their own recording.

Kept days show in the day header (distance, whose recording, since when,
still running) and on the desktop and phone journey maps in the colour of
their day's pins, winning over the live trail of their date, and stay drawn
without Dawarich. A trip's GPX track is now drawn dashed as the plan, recorded
routes solid. Shared links do not show recorded days. Remove route asks first
and works without a connection.

Server: migration 046160 adds journey_recorded_days (one row per journey,
local day and source, segments as JSON, zone, window, fetch time, hash,
importer kept past their account). The journey domain owns the rows through
JourneyRecordedDaysService; the new journey-recordings domain takes a day
over through DawarichTracksService.fetchDay, which reads past the cache, asks
a window wide enough for every zone and refuses with DomainErrors. REST under
/api/journeys/:id/recordings (list, preview, PUT and DELETE /:date) with
@trek/shared contracts, and MCP list_journey_recorded_days,
preview_journey_recorded_day, save_journey_recorded_day and
delete_journey_recorded_day on the same service and access. A new user event
journey:recording:updated keeps every open map current. JourneyTrack gains an
optional kind. All 27 locales carry the strings; the Journey and Dawarich wiki
pages and the MCP tool list say what it does.
2026-10-21 15:05:21 +02:00
Maurice cbca3b2851 test(maps): the overpass place names cases get a suite of their own, the service suite back under its size 2026-10-21 13:53:24 +02:00
Maurice 8a75712041 fix(tours): tell a router that did not answer from a walk no way joins, count each boat of a leg on its own into the day and its arrivals 2026-10-21 13:53:21 +02:00
Maurice 6e13f7e655 fix(tours): keep a city walk on foot across the water, mark a crossing by boat apart from the walking time and save it only once taken 2026-10-21 13:16:43 +02:00
Maurice 41f3267503 fix(tours): name every derived field of a walk's remaining time, read a second run on the same day, say when only the way back is left 2026-10-21 12:35:02 +02:00
Maurice 2a4a5617dc feat(tours): show the walking time left beside a city walk's progress, with what it counts from and the stays apart 2026-10-21 12:26:36 +02:00
Maurice 43353b9394 feat(tours): read the walking time left of a city walk on a day over MCP, apart from its stays and the whole walk 2026-10-21 12:16:57 +02:00
Maurice 4eaf5e6a11 fix(tours): read e-bike chargers as mapped, keep unnamed ones on the map search 2026-10-21 12:05:38 +02:00
Maurice 1a722e63fb feat(tours): find e-bike charging points along a bike tour, only those tagged for bicycles, with what they state 2026-10-21 11:58:39 +02:00
Maurice 924e38472e fix(maps): stretches beside the overpass probe wait for it, empty note only after a full search 2026-10-21 11:43:04 +02:00
Maurice bf2efca8f0 fix(tours): let a failed search along the tour really ask again, with a probing breaker, hedged mirrors and a countdown 2026-10-21 11:35:03 +02:00
Maurice 2377a63a05 fix(tours): offer the place's undo only while its insert is the last history step, and explain the add button with a tooltip 2026-10-21 11:14:39 +02:00
Maurice 377619abd1 fix(tours): keep the place card open while its position is picked, with slots, a map preview and undo 2026-10-21 11:08:46 +02:00
Maurice 988890866a fix(ski-routing): sort the network cache key by code unit, not by default 2026-10-20 23:18:54 +02:00
Maurice 0dda5bc3e9 fix(tours): name the piste router switches and the stage message keys where the key scan sees them 2026-10-20 23:06:29 +02:00
Maurice 7fa8b3fe35 fix(tours): keep the day menu items with the stage logic their views import
The pair rule wants TourDayAddButton to import stageSpread; the items it
builds belong there anyway. The addon manager test answers the Tours
switches, so a failed list is the only thing that toasts.
2026-10-20 22:54:57 +02:00
Maurice 112017a9e8 fix(tours): clear the stage spread's lint, read the vias after render and test against the reloaded vias
The vias the spread keeps on their legs are taken over in a layout effect
instead of during render, the route redraw takes the planner's async
updateRouteForDay, and the menu items for whole or stages move out of the
button component into their own module. Two leftovers of the old day pills
in tourParts go. The hook test's reanchor reloads the vias like the real
one does, and the detail test finds the button by its label, Add to Day.
2026-10-20 22:44:42 +02:00
Maurice cb5c5ab4a1 chore(client): lower the planner page size baseline to its count 2026-10-20 22:38:29 +02:00
Maurice bed9f53615 fix(deps): raise the MCP SDK to 1.32 and hono to 4.13.11
GHSA-22jm-h49p-29qw and GHSA-6prh-2h8m-c8cw (MCP SDK, high) and
GHSA-5r4p-p66f-jhc7 (hono, moderate) are fixed in these versions. npm audit
for what the server installs reports none left.
2026-10-20 22:29:33 +02:00
Maurice a8e2486cd3 test(tours): read the last undo without Array.at, which the client lib does not offer 2026-10-20 22:23:45 +02:00
Maurice 5d5015066f chore(planner): keep the planner page at its size after the stage choice 2026-10-20 22:17:53 +02:00
Maurice 3e8d192305 fix(tours): keep the road trip's vias on their legs when stages spread, and ask on the phone too
Spreading a multi-day tour wrote through the store directly and skipped
the via re-anchoring every other write on the plan does. A tour with a
start counts as a road-trip stop, so taking the whole tour, a stale stage
or a moved stage off the middle of a day shifted every later stop, and the
vias anchored by position shaped the wrong leg. The undo had the same gap
when it put the whole tour back mid-day. Each stop a spread or its undo
takes off a day now carries the day's vias along (reanchorAfterRemove),
and the whole tour put back carries them the other way
(reanchorAfterInsert). The stops are read from the store as each write
goes out and the vias are kept as the last correction left them, since
one run can touch a day twice before the plan renders.

The phone's place sheet greyed out a day that holds a multi-day tour, so
its stages could not be spread from there. It now checks the day the way
the desktop menus do and leaves the choice to the plan, which opens the
card with only the stages offered.

The second step of the day menu names the day picked, and a greyed-out
"Whole tour on this day" says why right under it. The toast for a tour
that sat whole on several days now says "days" instead of "day", in all
27 languages.
2026-10-20 22:17:38 +02:00
Maurice bafb74f872 feat(tours): put a multi-day tour on a day whole or a stage a day from it on
Putting a multi-day tour on a day through any of the usual ways put the
whole tour on that one day. Now the way it goes on is a choice: the whole
tour on this day, or stage 1 on this day and each next stage on the next
day of the trip.

The day menu of the tour lists and the detail's "Add to day" ask in the
same menu, as a second step after the day; a tour dragged onto a day, or
added from a place where no menu asked, gets a small dialog on the desktop
and a card on the phone. Only the trip's days are used: when fewer days
are left than stages, the menu says up to which stage fits and the rest
stay without a day. A stage already on a day moves to its new day, the
whole tour comes off the days it was on, and a stage the route no longer
has comes off too. The writes run in order through the store, the repo
and the mutation queue, so they work offline, and one undo takes the
spread back, the whole tour onto its day again.

The plan lives in stageSpread.ts, the writes and the question in
useTourDayChoice, which every way onto a day now passes through on both
the desktop and the phone.
2026-10-20 22:17:20 +02:00
Maurice b71e32cf12 fix(tours): clear the last lint, knip and wiring findings of the follow-up
The unused day pills go, a client-only type stops being exported, the place
form context moves out of the page hook so its view model stays the first
object it returns, the place form test helpers split the stub component
from the plain setup, and four router files are formatted.
2026-10-20 22:13:25 +02:00
Maurice 786df612fd refactor(planner): split the tour day time growth out of four size-capped files
The place form's time section moves into PlaceFormTimeSection, its tour test
into PlaceFormModal.tour.test.tsx over a shared tests/helpers/placeForm setup,
the page hook hands the place form its close, day plan and tour, and the day
plan's surface classes and tones move into dayPlanSurfaces. The size baseline
drops to the new counts.
2026-10-20 22:02:48 +02:00
Maurice e6002444a1 test(server): narrow a partial ways answer the way the server build can, format three tests 2026-10-20 21:56:06 +02:00
Maurice e453816ded test(shared): count the five tours.ski.summary keys in the tours domain 2026-10-20 21:43:48 +02:00
Maurice b9ca822d51 chore(shared): format the ski schemas and the new CJK tours strings 2026-10-20 21:43:47 +02:00
Maurice 20537193b6 test(tours): expect ski_options null on non-ski rows, let the ski choice settle before asserting 2026-10-20 21:43:47 +02:00
Maurice 60611aee65 test(server): run sequential awaits through oneByOne instead of a loop 2026-10-20 21:43:47 +02:00
Maurice 6c66cff365 fix(ski-routing): no promise in a condition and no await in a loop, ski route type from its schema
ski-areas.service checks the pending load and download against null and
undefined, the area refresh lane recurses instead of awaiting in a loop,
TourSkiRoute is inferred from its schema, the ways answer is read by
complete === false, and /v1/routes no longer needs a truncated field.
2026-10-20 21:43:46 +02:00
Maurice d3f574f225 fix(tours): match a saved ski route by its ski choice, and keep the planner under the lint limits
The saved ski route compared against an undefined skiKey, so opening a saved
ski tour threw. The held seed now lives in useSavedSkiSeed and is matched by
skiRouteKey(skiChoice). The ski route facts move to skiRouteFacts.ts so the
summary and leg rows only export components, the rail renders
SkiPlannerSummary, and openTour, useTourRoute and useAddonManager shed the
branches and lines the new ski code had added.
2026-10-20 21:43:46 +02:00
Maurice e5c5ffbc00 fix(tours): one get_tour_plan description for routed ski legs, entities as generated
A ski leg is routed where the piste router planned it and free elsewhere;
the description said both. The collection tours entity takes its generated
form again after the merge.
2026-10-20 21:17:08 +02:00
Maurice 562176bd83 chore(db): number the ski options migration after the Tours default 2026-10-20 21:16:09 +02:00
Maurice ae699187f7 fix(tours): open a saved ski tour as saved and keep what its router planned
A saved ski tour now opens with the line, heights, time, build and
planned legs it was saved with, read from the place this device keeps,
and nothing is asked of the piste router until a point, an option or the
discipline changes. Opened offline, while TREK Places is down or with
piste routing switched off, it no longer turns every leg straight, and a
rename saves the planned tour as it was. A kept line whose length is not
the tour's is another save's, and the tour is planned afresh.

- the save names the legs the router planned (ski_planned_legs, checked
  against the tour's own legs); get_tour_plan reports each leg routed or
  drawn from them instead of from the build alone, a tour that names none
  counting as planned only with the router's time
- copies keep ski_options in every path: in the trip, taken over, kept
  in a collection and made from one (collection_tours gets the column in
  the same unshipped migration)
- the route summary shows the data build, the km of runs, the height
  skied down on them and the lift rides, and the credit the server sends
  in place of a fixed one; a tour opened as saved says so
- answers refused while piste routing is off, or planned on an older kept
  copy, are not remembered, so they are asked again later
- terrain heights between known ones are filled in one walk along the line
- i18n: five summary keys in all 27 locales
2026-10-20 21:15:45 +02:00
Maurice 1d72fbd25f fix(ski-routing): keep one job's timeout to itself and bound the area refresh
The router thread now takes one job at a time from a queue kept on the
main thread, so a job's clock starts when the thread takes it up. A job
that times out ends only its own thread, and only while that thread is
still the current one; the jobs in line go to a fresh thread instead of
waiting out their own timers and killing the new thread in turn. A
thread that dies fails the job it held, and a thread that cannot start
fails each job on its own.

- the daily refresh fetches changed areas two at a time and stops once
  the TREK Places breaker opens; the rest keep their older copies
- the winter index cap is on the unpacked JSON fetch hands over, so it
  follows from the host's 8 MB gzip cap (64 MB) instead of matching it
- a build name is read to the rule a ski tour stores it by, so a build
  TREK could not save is refused when the winter layer is read
- an unused import in the piste router contract is gone
2026-10-20 21:15:21 +02:00
Maurice 2d0c0597b3 feat(tours): plan ski tours on the piste router in the planner
A ski tour with a discipline now sends its legs to the piste router and
takes back runs, lifts and trails: each stretch is coloured by its grade
in the area's scheme, links and lift rides down are dotted, and a leg the
router cannot plan stays straight and unrouted with its reason and the one
change that would plan it. Snowshoe and winter hike legs off the piste
data follow summer paths instead, said so. The router's time and heights
are used once every leg is planned, and the options, the build and the
time are saved with the tour.

- skiRouteRepo and api: one request per tour, batched, remembered unless
  the answer may change, offline legs failed as offline
- planner: router options per discipline, leg steps and failure rows with
  fixes, a piste route summary, draft and reopen keep the options
- admin: switch for the piste router under Tours
- i18n: new ski keys in all 27 locales, ski info and time texts rewritten
2026-10-20 21:15:07 +02:00
Maurice a85a50e186 feat(tours): keep a ski tour's piste router options, build and time
A ski tour now saves the options its legs were planned with and the piste
data build they ran on, as one JSON column (a new migration, since the
ski migrations have shipped on this branch); the server keeps only the
options its discipline reads. Both stay off every other type. The refusal
of a ski time is lifted: the piste router works one out, and the planner
sends it only when every leg followed the piste data. get_tour_plan names
the legs of a tour the router planned as routed, and list_tours says what
the new fields hold.
2026-10-20 21:14:44 +02:00
Maurice c8eb510bd4 feat(ski-routing): plan ski tours on the piste graphs of TREK Places
A new Nest domain plans the legs of a ski and winter tour on the runs,
lifts, cross-country trails, ski touring and winter paths the Places host
builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the
index once, downloads an area by its id when a planner first plans in it,
checks it against the index's SHA-256 and keeps it in data/ski; no
clicked point ever leaves the server. A leg whose area is slower than two
seconds answers loading, and an older copy stands in while the host is
down.

The reader decodes the writer's golden file byte for byte, including the
two edge flags it added (area_has_run, type_unknown). The search is A* in
a worker thread over a profile per discipline (alpine, snowboard,
cross-country by technique, touring, snowshoe, winter hike) with the
times and penalties of the cost model, up to five snap candidates per
point, lift stations as entry points, legs that go on from each other,
pieces of a split network stitched by their boundary nodes, other ways by
the penalty method, and a failed leg explained by relaxing one rule at a
time (difficulty, lift class, technique, riding down, uphill) without ever
handing out the relaxed line.

POST /api/trips/:tripId/ski/route, GET .../ski/area-near and
GET .../ski/network/:areaId sit behind Tours and the trip, with
plan_ski_route as their MCP twin. A daily job looks for a new build and
sweeps areas unused for 90 days. The admin switch ski_routing_enabled is
on wherever TREK Places is.
2026-10-20 21:13:48 +02:00
Maurice beea4c762b refactor(server): move the routing heap and the metre helpers out of the paddle domain
The binary heap and the flat-frame distance helpers are not about water;
the piste router needs the same two. They move to src/lib/geo, where any
domain may import them without reaching into paddle-routing. Nothing
changes in what they do.
2026-10-20 21:12:15 +02:00
Maurice d924dcf99d fix(tours): keep the Places layers off the search breaker and keep partial way answers
The ways and routes layers now count failures on a breaker of their own, so
busy or slow layer answers no longer send place search to the fallback for a
minute, and a layer's 404 no longer clears the count of search failures.

When a later batch of /v1/ways fails, the batches that answered are kept and
only the ids still open go to the Overpass mirrors.

The wiki, the compose file and the chart now say that the way ids of a planned
tour and the horse routes box go to the TREK Places API.
2026-10-20 21:12:09 +02:00
Maurice 371083ed47 feat(tours): read way tags and horse routes from the TREK Places layers
The trail grades under a tour (BIK-05, MAP-03, MAP-04, RUN-02) and the
horse routes around a map view (HOR-01) asked the public Overpass mirrors.
The places host now serves both from its own planet build: GET /v1/ways
for the tags of ways by id and GET /v1/routes for route relations per box,
each in Overpass element form.

trek-places.client gains trekPlacesWays (batches of 500, one after
another) and trekPlacesRoutes, both through getJson with its off switch,
breaker, timeout, size cap and instance header, each element validated on
its own. TrekPlacesLayersService in the maps domain hands their answers to
the tours services, or why there is none (switched off, layer not built,
failed), and logs that once per layer until it answers again. Both tours
services ask the layer first and fall back to the existing Overpass path;
the responses TREK sends its clients are unchanged.
2026-10-20 21:12:09 +02:00
Maurice eaeaf68352 chore(server): format the tours MCP test 2026-10-20 21:11:44 +02:00
Maurice 5156fa2c0a test(addons): check the Tours migration against the addon table as it was before
The table holds the seeded addons too, so compare against a snapshot taken before the migration with only tours switched on.
2026-10-20 21:11:44 +02:00
Maurice 6c6e285c3d test(tours): expect the time estimate on imported tours and hikes without a time
Since the estimate rule, an import without its own time shows the worked-out estimate on the day plan tile, and the sidebar metrics lead with it. Add the estimate field to the RS-01 fixture.
2026-10-20 21:11:43 +02:00
Maurice 93e1156aa4 refactor(tours): bring the tour time parts, collection rows and flyover back under the lint baseline
Move workedOutEndHint and totalText into tourDayTimeHint.ts so TourDayTimeParts only exports components, render the collection row's type icon without a component made during render, and pass the chase camera's lens and scene as one view object.
2026-10-20 21:11:43 +02:00
Maurice dc19807126 fix(tours): zoom the flyover around the rider and keep no stale GPX file
The flyover asked wheel and pinch to zoom around the centre with enable()
on handlers that were already on. MapLibre returns from enable() at once
in that case, so the option never took and a wheel zoom still went to the
cursor before the camera pulled back to the line. Each handler is now
switched off and on again with the centre anchor, and the same way back to
the cursor on pause, stop and unmount. The map stub refuses an enable() on
a handler that is on, like MapLibre does, so the tests see the real anchor.

The GPX file kept for the share sheet was only dropped when the tour's id
or name changed. A route saved elsewhere arrives live with a new revision
and the dialog kept handing out the old file. The kept file now carries the
revision it was read at; a new revision drops it and fetches again, and a
file a tap kept is not shared for another revision.
2026-10-20 20:56:19 +02:00
Maurice 5580ff71a9 feat(addons): Tours is on for every install
A fresh install gets Tours enabled from the addon seeder. An install that
already has the row is switched on by a migration that runs once, so an
admin who turns Tours off afterwards keeps it off.
2026-10-20 20:56:19 +02:00
Maurice e42622d71d feat(tours): let the user zoom during the 3D flyover
The chase camera set a fixed zoom and pitch on every frame, so wheel,
pinch and the +/- keys were undone at once. It now keeps the zoom and
tilt of the user's latest gesture and only follows position and
bearing. While a zoom is still easing the flyover holds for those
frames, since any camera move would cancel the gesture. Wheel and pinch
zoom around the rider while flying, and drag panning is off until the
flyover stops. Starting over returns to the flyover's own zoom.
2026-10-20 20:56:19 +02:00
Maurice 94674e7572 fix(tours): open the GPX share sheet inside the tap
Sharing a tour fetched the GPX file first and only then called
navigator.share, so the tap that asked had often run out by then. The
retry path kept that file for one more tap only, and a tap while a sheet
was still open made the browser throw InvalidStateError, which read as a
failed share.

The share button now fetches the file ahead where sharing is offered
(not while the planner has unsaved changes), and every tap hands a fresh
File built from the kept bytes to the sheet before anything is awaited.
A file a tap had to fetch is kept for the next tap. A tap while a sheet
is open is let be, and InvalidStateError and a closed sheet stay quiet;
only a real failure still says the download is left.
2026-10-20 20:56:19 +02:00
Maurice 7f14dde362 fix(tours): tidy the day times of a tour after review
The get_tour_plan description named the estimate of a tour "such a field"
right after the values of a recording, so it could be read as one of them.
It now follows the sentence on derived fields and says outright that it is
one of them, worked out from distance and heights or the pace of a run.

The arrow between a tour's start and end points from the start to the end
in a right-to-left language as well. A tour just added to a day, which
carries no tour_place_id until the next refresh, now says its overrun like
the row above it. A phone row of an import with a total the travellers
entered shows that one time, the estimate joining its words as on the
desktop. The German overrun line reads naturally without an item name.
2026-10-20 20:52:31 +02:00
Maurice 9765cf3fee feat(tours): give a tour its start and end in the day plan
A tour on a day had no place in the day's timeline: its time was not tied
to the slot it was planned in, so several hikes on one day could not be
lined up. A tour now sits there the way a place does, on the times of its
day entry, one model for every tour type. With a start and a known time
(the planned total, else the estimate of an import) its end follows as the
start plus that time; it is shown fainter, marked with an approximate sign
where it comes from an estimate, and says in its tooltip how it was worked
out. An end typed by hand wins. A day that holds one stage of a multi-day
tour gets no end from the whole route.

When the end runs past the start of the day's next timed item, the row
says so in one quiet line, as a late connection between two city walks
does; nothing is moved. The existing time fields of the day entry, on the
desktop form and the phone sheet, say what the end works out to while none
is typed. City walks keep their arrivals per station. The logic lives in
one module both day plans run on.
2026-10-20 20:52:30 +02:00
Maurice c38eb7ebf0 feat(tours): estimate the walking time of imported tours
A hike imported from GPX, KML/KMZ or wanderer had no time at all, so
several hikes on one day could not be planned against each other. It now
gets the walking time the planner times a hike by (DIN 33466), worked out
from its own distance, ascent and descent; a run gets its distance at the
pace entered. Without heights in the track the time is unknown rather
than the time on the flat, and types timed by a router, by a speed of
their own or by nothing get no estimate.

The estimate is derived on every read and never stored, so tours imported
before get it too and no stored or entered time is touched. The rule moved
to @trek/shared, where the planner, the client and the server read it.
The tour list, the day rows on desktop and phone and the tour detail say
it as an estimate, apart from a planned total; list_tours and
get_tour_plan carry it as time_estimate and name it as derived.
2026-10-20 20:52:30 +02:00
Maurice e653733390 fix(notifications): a little more room for the unread count, with a test 2026-10-20 20:52:23 +02:00
Maurice 2655e510fc fix(notifications): give the unread count room in its badge 2026-10-20 20:48:33 +02:00
Maurice 904eb31e62 fix(tours): even the tour detail's rows and footer
The times fill their row when there are two, the trip days are boxes in
the look of the measures with the date under the day, the footer's
collection button carries its label like the others and the copy button
says Copy, and the name field keeps its distance to a save button as large
as the close button.
2026-10-20 20:48:33 +02:00
Maurice 76ec302526 feat(tours): kept tours are rows of a collection's list, on the phone too
A tour kept in a collection was a card in a box of its own above the list,
and its button lost its look to the collections page's button reset. It is
now a row of the list like a saved place: its grade or type tile, its name,
type and the trip it came from, its measures, and Add to a trip, with
removing dimmed beside it. The list's search narrows the tours too. The
phone shows the same rows and takes a tour into a trip from a sheet, on the
same hooks as the desktop.
2026-10-20 20:48:33 +02:00
Maurice 810feba413 fix(tours): open the tour dialogs in the middle of the screen
The walk from a Google Maps link, the take-over from another trip and the
other new tour dialogs sat at the top of the screen; they now open centred
like the planner's own dialogs.
2026-10-20 20:15:05 +02:00
Maurice 4009588078 fix(tours): give the latest stage date a starting value
Sonar flagged the reduce without an initial value; every stage has a date at
that point, so the empty start never wins.
2026-10-20 19:27:58 +02:00
Maurice ef583ede18 fix(notifications): name each wanderer hint text in a key of its own
The three texts of a wanderer hint were picked in a nested conditional, which
the client's key check cannot read, so it counted them as unused.
2026-10-20 19:16:20 +02:00
Maurice 9bc543a387 chore(tours): keep the client ratchets where they were
Snowshoe is no longer exported, useRouteCalculation takes the stage stops
type through dayRoutePlan, and tourPlannerFacts and useTourRoute hand the
saved duration, the ski check and the planned key to small helpers.
2026-10-20 19:07:13 +02:00
Maurice 7291555e95 test(tours): follow the ski tour changes in the tests
The collection tour fixture no longer writes the dropped winter_activity
column, the drafts are saved as version 11, and a hike no longer offers
the pistes layer, which belongs to a ski track only.
2026-10-20 19:07:12 +02:00
Maurice 57f0e2f263 fix(tours): give the tour entities back their generated form
The format run rewrote two files that check:entities holds byte for byte
to the generator's output; .prettierignore excludes them for that reason.
2026-10-20 19:07:12 +02:00
Maurice de043cfb58 fix(tours): keep the get_tour_plan description one string
An apostrophe in the ski sentence ended the quoted description early, so the
tours MCP file no longer parsed and the server could not boot.
2026-10-20 18:53:40 +02:00
Maurice 69df85bfa2 chore(tours): format the ski and winter files 2026-10-20 18:41:55 +02:00
Maurice be6702f3ab fix(tours): keep the shipped ski migrations and follow the ski tour through
The winter label and four-discipline migrations are already on
feat/tours-board-rest, so a database that ran them would refuse to boot
or fail the old CHECK. 045500, 045520 and 046100 are back as they
shipped, and a new migration drops both winter_activity columns and
adds ski_discipline again with the six disciplines. The entity and
table types follow the column to its new place.

German, Hungarian, Indonesian and English no longer name the whole type
with the ski touring word, and the info dot speaks of ski tours and
tracks. search_tour_pois and its wiki row list rides and ski and winter
tours. Snowshoeing gets a snowshoe glyph instead of the walk's
footprints, a ski import without a discipline can be planned over (the
planner asks for it), and an overlay shown by default tries its tiles
afresh each time it comes on.
2026-10-20 18:40:48 +02:00
Maurice 164a3633ac feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them
Ski becomes "Ski & winter", a type the planner plans with six disciplines
(alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking),
nothing preselected; saving needs a discipline, and a technique for
cross-country. Every leg is drawn straight between the points through
planner/skiRouting.ts, the one place the piste router will plug in, with its
heights from the elevation source and no ski time worked out.

The hike's winter label goes entirely: its migration, column, field, marks,
GPX rule, MCP field and strings. A hike is summer only; a ski tour with the
snowshoe discipline exports as snowshoeing. The unreleased ski migration takes
the six disciplines and the collection migration loses the winter column.

The pistes are offered for a ski tour, shown until switched off, and with no
tour open, never for a hike. Export, copy, collections, stages (untimed),
actual tracks, the atlas, list and day marks (one glyph per discipline), 3D,
planning over a ski import and list_tours/get_tour_plan know the planned ski
tour.
2026-10-20 18:40:48 +02:00
Maurice 204ee2ef86 chore(ci): give the server test typecheck room, format new client files
The server's src and tests together are past 570,000 lines and the test
typecheck needs about 4.3 GB, over Node's default heap; it now gets 8 GB like
the test jobs. Forty new client files are formatted, and the react-hooks/refs
baseline drops by the one warning a split hook removed.
2026-10-20 18:39:58 +02:00
Maurice baba2de344 refactor(planner): hand the day plan its tours as one bundle from the page hook
Which places are tours, their planned times and where their stages run
travel together to the desktop and the phone day plan. useTripPlannerPage
now puts them together as dayPlanTours, and the page passes that bundle
instead of naming the three props at both places.
2026-10-20 18:26:56 +02:00
Maurice 49dd2db040 refactor(planner): project a day's tours by assignment in one place
The day route plan and the day plan sidebar both built the map of a day's
stops projected onto their tours, each stage on its own stretch, by hand.
projectedById in dayTourProjection builds it for both, and the sidebar's
drive runs reuse the route plan's point type instead of spelling it out
twice.
2026-10-20 18:26:00 +02:00
Maurice 49e306ea48 refactor(map): report the GL viewport from a module of its own
reportGlViewport says what a GL map shows, its bounds and its zoom on the
raster tile scale, and has its own test for that scale. MapViewGL only
wires it to moveend and idle, and its test checks that wiring. The GL map
tests put the zoom back to its default before each test, as they do for
every other value a test overrides, instead of each test restoring it.
2026-10-20 18:23:50 +02:00
Maurice 1d7c8a63e2 refactor(tours): hold the planner's routing choices in a hook of their own
The hike's limit and preferences, the bike profile and the run profile were
four states with four refs kept in step by hand in every handler that set
them. useRoutingChoices holds them as one value with one ref, so a history
step, an undo, a new draft and an opened tour each lay them in with one
call, and useTourPlanner is back under the size limit.
2026-10-20 18:21:46 +02:00
Maurice fb208a88e2 test(tours): read the MCP fields and stage edits the way the other tests do
The confirmed field is checked through z.object(...).pick() as the dawarich
and doc-sync tests read their tool shapes, and the stage edits pass typed
waypoints through the request contract as the walk tests do. The previous
forms sent the test typecheck out of memory.
2026-10-20 18:19:00 +02:00
Maurice 5ae8ac8e70 test(tours): type the tours tests against the current contracts
Raw request bodies are objects, the collection snapshot names the run, ski,
winter and track time fields, the walk write says it has no hiking limit,
the MCP field shapes are read as zod shapes, and the stage edits go through
the tour request contract the API parses them with.
2026-10-20 18:05:41 +02:00
Maurice 20bd384feb fix(tours): send a ski import's technique only when one is chosen
The classification request may clear the technique with null; the GPX
import fields take it or leave it out, so the null never reaches them.
2026-10-20 18:05:40 +02:00
Maurice 58302dc9e4 fix(tours): keep the planner page hook's view model its only object return
The map focus helpers split out of useTripPlannerPage returned object
literals ahead of the hook's own return, which the B4A wiring test reads as
the view model. They move to tourMapFocus.ts beside the hook.
2026-10-20 18:05:40 +02:00
Maurice b460cfd9a7 refactor(tours): move non-component exports out of component files and split long hooks
Clears the client lint ratchet: helpers move to sibling .ts files for react-refresh, complex render paths get small helpers, floating promises are voided, the stage rail lets a stale request go during render, and the collections page reads the active list's tours and rights in ActiveCollectionTours. StageNumber is no longer exported.
2026-10-20 17:51:28 +02:00
Maurice 2b8d2e790f chore(shared): format the tours and wanderer locale and spec files 2026-10-20 17:51:27 +02:00
Maurice 2fc9b1991a test(tours): fix client test expectations and mocks
The type order puts run before kayak, the import button reads Import GPX or KML, and the mocks gain what the components now read: routeRun, packing items and links, the map's zoom, the translation provider and the compare store's map flag.
2026-10-20 17:51:27 +02:00
Maurice 4d81180fc1 test(tours): bring the server tests in line with the tours board
Expectations follow createAssignment's options, the stage_ends column, the editor's trip access and the broadcasts of the take-over. Refusals in the run and ski e2e tests go one by one so supertest does not reset a connection under another request. A parity test keeps the source hint enums in step with the contract.
2026-10-20 17:51:27 +02:00
Maurice 0f600d04f5 fix(tours): import the walk icon, keep one stop menu open and count only stage ends a change lost
The activity section used Footprints without importing it. Opening one stop's menu now closes any other. lostStageEnds no longer counts a stop the change kept without its mark as lost twice over. The wanderer watch switch keeps the same tree once the connection is known, so it is not remounted and keeps its focus.
2026-10-20 17:51:26 +02:00
Maurice b53257deaf fix(tours): answer a new tour with its activities and type the change save's outcome
createTour now answers activities: [] as the detail read does, and the change save's transaction declares the shape it returns so the strict typecheck can narrow it.
2026-10-20 17:51:26 +02:00
Maurice b284095ee0 fix(tours): count a multi-day tour in the activity sums only once all its stages are on days
The activity sums never read which stage a day holds, so a multi-day tour
counted its whole route on the last dated stage day while other stages
were on no day yet, and a recording on one stage took the place of the
whole plan beside it. With stages on days of their own, the route now
counts once every stage is on a dated day of the user's, on the last of
them, and is uncounted until then; a recording counts as what was done
and the plan is not added beside it.
2026-10-20 17:00:27 +02:00
Maurice e40e576e39 fix(tours): preset a ski tour's actual track as ski, and cover the run and ride presets
Ski has been an importable type since SKI-02, yet a ski tour's recording
still opened in the GPX preview as a hike. It now opens as ski with the
tour's discipline, and its technique for cross-country. The run profile
fallback and the ride preset get their tests.
2026-10-20 16:57:27 +02:00
Maurice 4d9b84f681 test(tours): give the packing link copy test an id of its own
TOURREPO-012 was used twice in the tours repository test.
2026-10-20 16:56:56 +02:00
Maurice 223b056de2 fix(tours): name a round hike's distance bounds {from} and {to}
The three round hike strings passed formatted distances as {min} and
{max}, and {max} is a quantity param, so i18n parity read them as count
strings outside a plural group and failed. The bounds are now {from} and
{to} in every locale and in the card.
2026-10-20 16:56:45 +02:00
Maurice 981c372640 fix(tours): let only the latest read set a collection's kept tours
Two reads of the same collection, after two changes by other members or
the first read overlapping one, could answer in either order and an older
list replace a newer one. A refused removal also restored the list from
before it, dropping changes read meanwhile; it now only puts its own tour
back.
2026-10-20 16:56:15 +02:00
Maurice 839d736905 fix(atlas): keep tour exclusions right when their answers come back out of order
Each exclusion replaced the whole list with its answer, so two quick
exclusions answered in the other order, or a read answered after an
exclusion landed, put back a tour as counted although the server held it
excluded. Only the answer to the latest request is now taken whole, with
the exclusions still on their way laid over it; an older answer only
confirms its own tour.
2026-10-20 16:55:34 +02:00
Maurice b9391a9921 fix(tours): hold an actual track change back while the tour is offline or queued
Linking or unlinking a recorded track went out even in offline mode and
behind a queued write of the same tour, and its answer replaced the whole
cached tour with the server's older state, losing the local change. Both
now refuse as online only in those cases and for a tour the server has
not got, the view says so, and the answer only updates the cached
tour's recordings.
2026-10-20 16:54:35 +02:00
Maurice 5e0f216b0c fix(tours): name an actual track's metrics and a tour's stages as derived in get_tour_plan
What a recorded track measures is read off its line on every read, and a
multi-day tour's stage count and stops are counted from its stage ends,
yet derived did not list them, so an assistant would take them for saved
facts.
2026-10-20 16:52:08 +02:00
Maurice a249c861e6 fix(tours): read and write a run's pace in the user's distance unit
The pace was always min/km and the run's time sum forced kilometres, so
with distances in miles the run card disagreed with every other figure in
the planner. The pace is still kept per km, but is typed, shown and
checked per mile when distances are in miles, and the time sum reads in
that unit too.
2026-10-20 16:51:34 +02:00
Maurice 2a40b884a1 fix(tours): keep a kept tour's remove button in sight, dimmed
It was invisible until hover or focus, so a phone or tablet had no way to
find it. It now shows dimmed and comes up fully on hover or focus.
2026-10-20 16:48:09 +02:00
Maurice f5be556c27 fix(tours): list a kept tour with its type's marks, pace and stages
The collection copy keeps the winter, run, ski, bike and stage columns,
but its list item only carried the older fields, so a kept ski track lost
its discipline mark, a snowshoe hike its winter mark, a run its pace and
a multi-day tour its stage count. The item now carries them, and a kept
tour's card shows the pace and the stages beside its measures.
2026-10-20 16:48:00 +02:00
Maurice 6b1488994f fix(tours): give an actual track its own mark and a winter tile its label's icon
Footprints stood for a snowshoe hike, an actual track and a walk's moving
time at once, with the snowshoe pill and the track pill side by side in a
tour's head. An actual track now goes by the done mark on the list row,
in the head and on a recording's tile, and a winter tile's corner takes
the icon its head pill shows.
2026-10-20 16:46:08 +02:00
Maurice 2ee4ea1e65 fix(tours): keep a multi-day tour's days in sight in the list row
The stage count sat in the row's fixed-height line of badges, so a tour
with stages and a recorded track wrapped and lost its days badge. The
count now sits on the fold button, a moon with the number at its corner,
or beside the row where it does not unfold, and the days come before the
measures so the ascent is what gets cut.
2026-10-20 16:44:55 +02:00
Maurice a1d683d7bc fix(tours): show a run's ways in the hike's trail details card
A run described its ways in a card of its own, with the bike's sections,
the bike's paint for the SAC grades (T5 and T6 merged) and its own words,
so the same path read differently on a hike and on a run. The run now
uses the hike's card, matched as a run, and keeps only the unpaved share
and the hardest grade in its head. The run's grade labels and the now
unused SAC paint go.
2026-10-20 16:42:47 +02:00
Maurice ae726ed370 fix(tours): time a run's stages at its pace and leave a ride's stages untimed
A multi-day run's stages took a share of the foot router's time while the
run itself is timed at the runner's pace, so the stages did not add up to
the run. A ride has no time at all, so its stages have none either.
2026-10-20 16:38:42 +02:00
Maurice f4750352e6 fix(tours): fit the merged tracks together after integration
Types, test expectations and helpers the seven tracks each changed on their
own, brought in line with the integrated code. tours.service.ts and the tours
repository grew past the size limit with every track and hand their row and
write helpers to modules of their own.
2026-10-20 16:30:55 +02:00
Maurice 65ee472d66 chore(db): regenerate entities and kysely types for the tours board 2026-10-20 15:40:31 +02:00
Maurice 73fa7aeb19 test(tours): give the tests the merged tracks added side by side ids of their own
Runs, winter hikes and ski tracks numbered their ToursRepository tests 016
to 018, as multi-day tours did; the multi-day list_tours tests took
TOURS-MCP-015 and 016 next to get_tour_plan's. They now read TOURREPO-022
to 024 and TOURS-MCP-024 and 025, so every id names one test.
2026-10-20 15:38:07 +02:00
Maurice fbba4fd73b fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline
The watch row only re-read its state when the connection came or went, so after
a passing connection test or a newly saved token it kept saying "Token refused"
until a reload. The connection card now hands it a key built from the saved
connection and its last check, and the row re-reads the watch whenever that
changes.

Without a connection the switch was only dimmed: the button stayed in the tab
order and was announced as active. ToggleSwitch gains a disabled prop that
disables the button and marks it aria-disabled, and the reason stays reachable
through a focusable wrapper carrying the tooltip.
2026-10-20 15:35:32 +02:00
Maurice 2e1dca9fe9 feat(tours): let a background check hint at changes of your own wanderer trails
Settings, Integrations, wanderer gains an optional background check of the
trails you imported, off until you switch it on. While on, TREK reads the
short record of each such trail at most once a day (never its GPX file) and
leaves a hint when the trail may have moved on: "may have changed" when only
wanderer's change time moved, "changed" when its name or description proves
it, and "not checkable" when the trail is gone, no longer yours or carries no
change time. Nothing is ever said to be unchanged. A new hint arrives as a
notification that opens the tour (a new ?place= link in the planner); the
tour's provenance shows it with the source check right beside it, and the
card lists all open hints in a dialog, each leading to its tour. The check
reads the source in full as before and spends the hint; taking a change in
stays the confirmed reimport. No tour is ever changed or deleted.

The check only asks through your own connection, only about trails your
account imported into trips you can still open, and stops when you switch it
off, remove the connection, wanderer refuses the token (until a test passes
or a new token is saved) or the admin switches wanderer off; hints and
snapshots stay. A run asks at most 25 connections for at most 10 records
each, one after another, and an instance that fails (an outage, a rate
limit) is left alone for an hour, doubling up to two days.

Server: migration 046120 adds the switch and the run bookkeeping to
wanderer_connections and the tour_source_hints table (one row per snapshot,
for its importer, gone with it). WandererWatchService and an hourly
wanderer-watch job on the CronRegistrar live in the tours domain and read
through WandererService; GET and PUT /api/integrations/wanderer/watch and GET
/api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the
check) answer with @trek/shared contracts. A new user notification event,
wanderer_source_hint, can be muted per channel. Saving a new token now clears
what the last check said about the old one. There is no MCP tool, as for the
rest of the personal wanderer connection. All 27 locales carry the strings,
and the admin wiki page says what the check does.
2026-10-20 15:35:32 +02:00
Maurice aacc7f2c8d fix(tours): open a tour's packing list on Packing, and ask before letting go of it while Packing is off
"Open packing list" switched to the Lists tab but left it on whichever
sub-tab was open last, so someone who had been in the to-dos landed there.
It now picks the packing side first, and a Lists tab already on screen
follows the choice; storage that is blocked no longer throws either.

With the Packing addon off, "Remove link" let go of the link on one click,
and the link cannot be made again until Packing is back on. It now opens
the tour's packing dialog with only the dimmed link and the Remove link and
Cancel buttons, so nothing changes without a second, deliberate click.
2026-10-20 15:35:09 +02:00
Maurice f833a05461 feat(tours): point a tour at its trip's packing list
A tour can now point at the packing list of its trip, as a whole or at one
of its categories (say "Hiking gear" for a hike), and shows it in its detail.
The new Packing list section reads the list live as the user may see it: the
backpack tile with the item count on its corner and the category's own
colour, how many are packed with a thin progress bar, the first items ticked
off or not with their quantity, how many more there are, and a button that
opens the trip's Lists tab. Who may change the tour links one, changes it or
lets go through a dialog that lists the whole list and every category with
its colour and item count, and writes only on its confirm.

It is a reference, never a copy: the items stay in Packing, are ticked off
and edited there, and each traveller sees in the tour only the items Packing
shows them, so an item kept to oneself never shows through a tour. A
category that is renamed, cleared or only holds items of others reads as
missing, an empty one as empty, a link that could not be read says so with a
retry, and while Lists are switched off the link stays, dimmed, and can
still be let go. Removing the link, or the tour, leaves the list and every
item as they are. Nothing suggests or changes an item by itself.

On the server the link is a row of its own in the tours domain
(tour_packing_links, migration 046110), one per tour and gone with it.
GET, PUT and DELETE /api/trips/:tripId/tours/:placeId/packing-link read it
with the trip and change it with place_edit; linking needs the Lists addon
and, for a category, an item of it the user can see. The writes answer the
tour with its link and tell the trip through tours:changed, so the offline
client queues them as writes of the tour and replays them like one. A copied
trip keeps its tours' links, a tour saved as new or taken over does not.
The MCP tools get_tour_packing, link_tour_packing and unlink_tour_packing run
over the same service and checks; the read also hands the tour's facts as
context for suggesting what to pack, which only becomes an item through the
packing tools. The empty category placeholder now lives in @trek/shared so
both sides leave it out alike. All 27 locales carry the new strings, and
the wiki describes the link and the tools.
2026-10-20 15:35:08 +02:00
Maurice 65fe8cd6e5 fix(tours): explain a kept tour without a line, and say the take-over once
Taking a tour kept in a collection into a trip left the user guessing in
two places when the tour had no line. The map box stayed an empty grey
square that read as a broken map; it now says that there is no line yet,
only the waypoints. Why "Line only" is dimmed was only in the tooltip of a
disabled button, which never shows on hover, focus or touch; the reason now
stands under the two choices, and a disabled tile sits in a span so its
tooltip can show as well.

A successful take-over said the same sentence twice, as a toast and as the
dialog's last word with "Open trip". The toast is gone.

The collection strings move out of tours.ts into a tourCollection.ts per
locale, keeping their keys, so no locale's tours file grows past the
1000-line limit. The tours locale spec now holds both files to their own
counts.
2026-10-20 15:34:09 +02:00
Maurice 3fe34061fd feat(tours): keep whole tours in collections and take them into trips
A saved tour can now be kept in a collection, apart from any trip, and
later made into a tour of another trip. The bookmark button in a tour's
footer and in the planner's head (waiting for the save while there are
unsaved changes) opens a dialog with every list of the user; a list they
may only read stays in it, dimmed with a lock and a tooltip. The list
keeps an independent copy: the line, the type, every measure and setting,
the waypoints in order with their roles, the origin, and the trip and tour
it was saved from as they were called then. Its days, its links to places
of the trip and its obstacle decisions stay in the trip, and later changes
to the tour do not reach the copy.

A collection shows its kept tours as cards above its places: the grade
tile with the source badge, measures and the waypoint count as badges,
the trip it came from, and who kept it. The owner or an admin of the list
can remove one after a question; tours made from it stay. "Add to a trip"
shows the kept tour on a map with its facts and numbered waypoints, lists
the user's trips (the ones they may not add tours to dimmed), and asks
outright whether the waypoints come along. "Line only" keeps the line as
saved and adds no waypoints; a tour kept without a line can only bring
them. The new tour has an id of its own, no days and no new places, and
names the trip and tour it was saved from; the dialog then offers to open
that trip. Neither the collection nor the original tour changes.

On the server the kept tours are a table of their own in the tours domain
(collection_tours, migration 046100), each row written whole once. Every
copy, the in-trip "save as new tour", the take-over from another trip and
both directions here, now goes through one copy model (tour-copy.helpers)
instead of a second one. Reading and removing follow the collection's
roles, keeping one also needs access to the source trip, and taking one
over needs place_edit on the target trip; the kept tour is read again
inside the write, so one removed meanwhile leaves no half-made tour. The
MCP tools list_collection_tours, get_collection_tour,
save_tour_to_collection, take_over_collection_tour and
delete_collection_tour run over the same service and checks. All 27
locales carry the new strings, and the wiki describes the flow.
2026-10-20 15:32:47 +02:00
Maurice 3659297af1 feat(tours): import KML and KMZ routes as tours, with a preview first
A route planned in Google My Maps or Google Earth can now become a tour
without converting it to GPX first. The import button and a file dropped on
the tours workspace take .kml and .kmz files beside .gpx, through the same
preview, the same import routes and the same transaction as a GPX file.

The preview shows every line of the file on its own, numbered, with its
distance and heights; two lines of one placemark are numbered after its name
and never joined. Heights count only where they are heights: kept for an
absolute line, dropped for any other altitude mode and for the all-zero
placeholder My Maps writes, so a missing height stays unknown. What the file
holds besides its lines (pins, areas, overlays, links to other files) is
counted in a dashed card, and nothing is fetched from elsewhere.

The KML reading reuses the place import's codec. A KMZ only ever has its KML
entry unpacked, counted byte by byte against the decompressed cap whatever
its header claims. A file that cannot be read is refused with its reason
(damaged, an archive without KML, too large unpacked), and the toast names
the file and the reason. A KML or KMZ import keeps the gpx origin of a file
import; its provenance names the real format from the stored file name.
2026-10-20 15:30:18 +02:00
Maurice f57a0dbd31 fix(tours): let an unwanted stage go and read a tour's stages once
Two review findings of MDT-02 in the planner's list and the day plan:

- A stage asked for while the draft has unsaved changes stayed pending when
  the changes were kept or the tour failed to open, so opening that tour
  later from its row jumped to a stage nobody asked for then. The rail now
  has cancel(), which the discard question calls when it is dismissed and
  the open calls when it fails, and it lets a stage go once the opened
  tour's points are in and none of its stages starts at that stop.
- Every day holding a stage of a tour read the whole tour on its own, so a
  five stage tour sent five identical requests on top of the list's own.
  tourPointsRepo shares one read per trip, tour and revision among the
  views showing it at the same time, through the tour repo as before, and
  drops it once the last view lets go or the read fails.
2026-10-20 15:28:23 +02:00
Maurice 3748bdb815 fix(tours): keep the day assignments repository under the size limit
The stage queries of MDT-02 took DayAssignments.repository.ts past the
1000 lines lint:size allows. Two concerns move out of it instead:

- The stages of a multi-day tour that days hold are a tour question, so
  findTourStage and deleteTourStagesNotIn move to ToursRepository, next to
  isOnDay and listStagePoints, which AssignmentsService already asks. No
  constructor changes, and the tests move with them (TOURREPO-019 to 021).
- The rows the repository answers with and the Kysely table shapes its
  statements are typed against move to day-assignment-rows.ts, so the file
  holds the statements alone and has room again.

tours.service.ts stays at its 1000 lines.
2026-10-20 15:28:23 +02:00
Maurice 8fa75b3c4e feat(tours): show a multi-day tour's stages in the list and put each on a day
A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
2026-10-20 15:28:22 +02:00
Maurice 6ec06c90f4 fix(tours): keep a lost stage end visible on paddle tours
A stage end set on a lock or portage point the water router placed left
the point automatic, so the router could drop it with its leg later,
silently and without an undo step. Ending a stage there now makes the
point the paddler's own, like any other edit of it.

The notice for stage ends a change could not keep also vanished as soon
as the router laid its points in again after the change. It now stays
until the next change the user makes, an undo or redo, a save, or
another draft.
2026-10-20 15:26:43 +02:00
Maurice 2449616ee9 feat(tours): plan a multi-day tour as one route split into stages
A hut-to-hut hike or a paddle trip over several days is now planned in the
Tours planner as one tour: the whole route is planned as before, and stops
along it are marked as stage ends (MDT-01). The stages are read off that one
route in its order, never kept as copies, so a change inside a stage changes
the whole tour and every figure on it.

- A Day tour / Multi-day tour switch beside the tour type; a stop's menu
  ends a stage there or lets it go on. Back to a day tour removes every stage
  end after a confirmation, as one step undo brings back.
- The list of points is headed per stage with its distance and moving time;
  a stop that ends a stage carries a moon at its number, on the map too.
- The overview lists the stages from stop to stop with distance, climb,
  descent and time (hiking rule per stage, paddle estimate per stage, the
  router's time shared by distance otherwise), marks where each ends across
  the elevation profile, and a stage of more than a day on the move warns.
- A stage end a change cannot keep (its stop removed, moved to an end or
  made a shaping point) is dropped and said so, with undo, never moved
  to another point.
- A multi-day tour may take a day a stage, so its time limit grows with its
  stages; the tour keeps its one type, portages included.
- The list and the MCP tools show the stage count and where stages end.

Stage ends are a flag on the waypoint (migration 046000); a tour without one
is a day tour, as every tour saved before.
2026-10-20 15:26:18 +02:00
Maurice acc294dd8e fix(atlas): count shared days for every member, keep routes and multi-day tours honest (STA-02)
A day of a tour that names nobody now counts for every member of the trip,
not only for its owner. In the planner an empty participant list means
everyone, and a full member list is stored back as empty, so members got
nothing for most shared tours. Only a day that names participants without
the user stays theirs. The tours of a shared trip on no day or only on
undated days are now named as not counting for members as well.

A whole route a multi-day tour's stages were planned from is no longer
dropped on the server as soon as any stage has a day. Each entry carries
derived_from_place_id and the shared summary leaves the route out only
while one of its stages is done, counted and in the period and type. A
route walked last year now still counts when a stage of it is planned for
next month or was left out because it fell through, in the Atlas and over
MCP alike.

A tour on several days is dated on the last of them, as is a recording on
no day, so it no longer counts while it is still under way.

The info text, the get_tour_activity_stats description and the wiki say so.
2026-10-20 15:23:55 +02:00
Maurice bde2ad2ca8 feat(atlas): sum up the tours you have done, by period and type (STA-02)
With the Tours addon on, the Atlas panel gets a third tab, Tours, beside
Stats and Bucket List: what you have done on your tours across all your
trips. A period (all time, this year, the last 12 months or an earlier year)
and a tour type narrow it; the tab shows the distance large with the number
of tours at its corner, the ascent and descent, and the types with their
distance. "Show tours" opens every tour of the period in a dialog with the
same filters, the sums, the split by type and one row per tour.

What counts is said, never guessed. A tour counts once the day it is on is
over, with the values of the planning, badged "Planned" and explained as
not measured. A linked actual track (ACT-01) replaces those values for its
execution, badged "Recorded" and named, and is never added on top nor
counted as a tour of its own. A tour on several days counts once, a whole
route does not count beside the stages planned from it, a paddle tour sums
no heights, and a value nobody knows stays out of the sum, which then reads
"at least". Only the user's own part counts: a day names them among its
participants, or names nobody on a trip of their own; being able to open
somebody else's trip makes none of its tours theirs. Tours still ahead, on
no day or only on undated days are named as not counting. A tour that fell
through can be left out of the sums ("Leave out"): it stays in the plan
and in the list, dimmed, and "Count again" brings it back. Only the user's
own numbers change.

Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
2026-10-20 15:23:55 +02:00
Maurice ae79ab8dad fix(tours): offer the map comparison only where the plan map can draw it (ACT-02)
The compare switch in a tour's detail showed whenever the detail sat beside
the plan map, but the map draws no comparison in road trip mode or with the
trip overview open. Pressing it there turned the switch on while nothing
appeared on the map.

The plan map now tells the compare store whether it can draw a comparison.
The detail offers the switch only then, and turning to the road trip or the
overview, or leaving the map, ends a running comparison.
2026-10-20 15:23:18 +02:00
Maurice 6a0c54cb2f feat(tours): compare a tour's plan with the track it was actually done with (ACT-02)
Each actual track in a tour's detail now stands beside the plan instead of
on its own: a table names the plan and the actual track as columns, each with
the line it is drawn as on the map, and sets distance, ascent, descent and the
time with breaks side by side. The difference is shown, signed and with an
arrow, only where both values are known and mean the same thing; a planned
total that leaves a stay out reads "at least" and is not subtracted from. A
value one side does not carry is named as missing ("Not in the plan", "Not in
the track", "No time stamps"), never estimated. Below the table, how far the
track left the planned line: its farthest point and the recorded stretches
more than 50 m away, or that there is no line to compare.

On the desktop plan map, "Compare on map" draws the actual track dotted in
the colour of done next to the plan, which stays picked in its own track
colour (and is put back should it be hidden on the map). The recording leaves
the map as a tour of its own meanwhile, so it is never drawn twice or in a
plan's colour. A frosted legend over the map names both lines and ends the
comparison; so do closing the detail, comparing another track, or the track
being unlinked. Comparing only reads: neither the plan nor the track changes.

The track is laid into the day's route lines the map already draws, so both
renderers show it without a layer of their own. distancesToLine in the shared
route corridor measures many points against one line laid flat once; a long
recording is measured in at most 1500 samples.
2026-10-20 15:23:18 +02:00
Maurice a8be1c5b45 fix(tours): keep unknown actual tracks unknown and name every track a link replaces (ACT-01)
A tour detail that cannot be read (offline without a cached copy, or a server
error) no longer shows "No actual track yet" while the trip list names tracks
of the tour; the section says they could not be loaded and offers no link over
them.

The day a track was recorded on is the viewer's calendar day of its first time
stamp, not the UTC one, in the tour detail, the link dialog and the GPX
preview alike, through one helper.

Taking a tour off a day keeps its actual track without a day, so "no day" can
hold several. A change now starts on the track it was asked for, and the link
dialog names every track it replaces, as the server clears all of them. The
migration, repository, service and MCP texts say so.

The refusal for linking a tour to itself or a track with tracks of its own now
reads "This track cannot be linked to this tour", which is true of both, and
the offline helper of the tour cache sits above the doc of replaceTripTours
again.
2026-10-20 15:23:10 +02:00
Maurice d0cc6fc2f4 feat(tours): link the track a tour was actually done with, and show its own values (ACT-01)
A planned tour can now be given the track it was actually done with. In the
tour's detail, "Add actual track" opens a dialog that asks for the day the
tour was done on (or none) and a recording of the trip: an import nobody
planned over, or a new one through the existing GPX import, its preview
preset to the tour's type. What the choice measures is shown before anything
is written, and only "Link" writes. The linked track keeps its own name and
source; the detail shows its distance, heights, profile and, only where the
file carried time stamps, the time it took, marked as actual values next to
the untouched plan. A track can be changed or unlinked again.

Server: a tour_activities table points a tour's execution (a day assignment,
or none) at a recording of the same trip, and the tours GPX import keeps the
first and last time of a track whose every point carries one. The values are
read from the recording's own line on every read, so a changed recording
updates them while the plan's line, measures and revision stay as they were.
Every check runs inside the write: the recording must be an import of this
trip not planned over, documenting no other tour; the day must be one of this
tour; a refusal names its reason and changes nothing. PUT and DELETE
/api/trips/:tripId/tours/:placeId/activities answer the detail, which now
carries the actual tracks; the list names them per tour and per recording.
MCP: assign_tour_track and unassign_tour_track over the same service and
checks, get_tour_plan reads the values without the line.

A recording linked as a tour's actual track is no longer counted as a tour of
its own in the trip's tour summary, and the GPX preview imports a city walk
as one instead of as a hike.
2026-10-20 15:22:55 +02:00
Maurice f016972a4e fix(tours): explain and offer to re-ask hike ways that could not be asked
A way OpenStreetMap could not be asked about this time now carries a tooltip
saying so, and the unknown list offers to ask again whenever such a row is
there, not only when nothing at all was answered.
2026-10-20 15:19:11 +02:00
Maurice c80152f41d fix(tours): keep a non-canonical sac_scale as written instead of lowercasing it
A value such as Alpine_Hiking is not one of the six SAC values word for
word, so it now reads as an unclear grade with the raw value OpenStreetMap
holds, rather than as a known T4 the planner normalised itself.
2026-10-20 15:19:03 +02:00
Maurice f745ca13c9 feat(tours): suggest round hikes of about a wished length from a stop (RTE-03)
A hike can now be planned as a round out of one of its stops without
building the loop point by point. "Round hike from here" in a stop's menu,
or the round button in the head of the waypoints for the start, opens a
card over the planner map: the start (any stop of the tour, picked from a
list), the wished length in the traveller's unit, the window of a fifth
either way it allows, and the tour's hiking limit, which every question
to the router keeps to together with the hike's preferences.

TREK looks for up to three different rounds itself on the hiking router it
already uses. It steers loops round circles in several directions from the
start, asks a loop that missed the length once more at a corrected size,
and learns from each answer how much longer the paths are than straight
lines. A loop that walks most of its ground twice (a way there and back), a
spot too far from any path, and a round that repeats one found are left
out, and nothing outside the window is offered. The rounds show up in
their colours on the map and in 3D as they are found, the map frames the
area they can lie in, and each lists its length, how far it lies off the
wish, climb, descent and walking time once the heights are in. Fewer
rounds than three, none, or a router that does not answer are said so.

Showing, choosing and closing change nothing. "Take this round" puts the
start, the steering spots as shaping points and the way back in place of
the plan as one step of the history, with a "Round taken" note and its
undo; a tour that held more than its start asks first. The round is then
an ordinary tour to edit and save. A start moved or a limit changed while
rounds are shown lets them go and the card says why.

The card shares its frame, buttons and rows with the comparison of a
section's ways, and the map corner still holds one card at a time.
2026-10-20 15:19:03 +02:00
Maurice 4f68029b2c fix(tours): keep one card in the map corner and offer only ways that change a section
While the other ways of a hike's section were compared, a click on a via
ferrata or a place on the map opened its card in the same corner, stacked
over the comparison. The corner now holds one card: whichever opened last
stays and the other closes, before paint, so the two never show together.

The router's first answer between the two ends is its own way under the
hike's rules, which the tour already routes the section by. Offered as a
choice, taking it only planned the leg again, usually to the same line, and
the card closed without "Way taken" or an undo step. It is no longer
offered, so every way taken is held by shaping points, changes the route and
can be taken back in one step.
2026-10-20 15:18:48 +02:00
Maurice 64445a5204 feat(tours): compare other ways for one section of a hike (RTE-02)
A routed section of a hike between two neighbouring points can now be
compared with the other ways the router knows between the same two points,
under the same limit and preferences. "Compare ways for the next leg" in a
waypoint's menu opens a card over the planner map: the way taken now and up
to three others, each drawn on the map in its own colour, with length,
climb, descent and walking time by the signposts' rule. Figures without
heights stay unknown, and the differences to the current way are shown
only where both are known. When the router knows no other way, or does not
answer, the card says so.

Showing, hovering and choosing change nothing. Only "Take this way" does:
the router is first asked whether a few shaping points hold the chosen way
(the check the road trip's picker already uses), and only then are they set
into that one leg as a single step of the history, with a "Way taken" note
and its undo at the foot of the map. Other legs, fixed lines, days and
points stay as they were, the totals follow from the replanned route, and
the shaping points can be dragged like any other. A section that changed
while the router was asked, or a way it does not keep to, is not written,
and the card says why. The ways are drawn in 3D too.
2026-10-20 15:18:38 +02:00
Maurice bff9bcde92 fix(tours): read ways past the grade cap as not asked instead of untagged
A long hike or mountain bike tour can run over more distinct ways than one
grade request may ask about. The ways past that cap were never asked, yet the
facets read them as carrying no surface, way type or grade in OpenStreetMap.
They now land under 'Could not be asked this time' with the unanswered ones.
2026-10-20 15:18:01 +02:00
Maurice 3572f4b8a3 feat(tours): show the known surfaces and way types along a hike (MAP-04)
The hike's "SAC grades" card becomes "Trail details": one switch, then a
segmented choice between SAC, Surface and Way type, three things kept
apart and drawn along the route on the planner map one at a time. Every
facet reads from the same answer: the ways under the line are matched on
foot once, their OpenStreetMap values fetched once through the
trail-grades read MAP-03 already uses, and each stretch is placed against
the tour's own line once. Switching facets asks nothing new.

The server now hands on a way's surface and highway tags as OpenStreetMap
wrote them, only trimmed and bounded. The planner groups them (asphalt or
concrete, paving stones, compacted, gravel, natural ground, grass, rock,
wood or metal, unpaved without a kind, other; path, footway, steps, farm
or forest track, cycle path, minor road, road, via ferrata, other), and
each row's tooltip names the values recorded, such as surface=gravel,
surface=pebblestone. The router's own surface reading is never used: it
is normalised and a default where nothing is recorded.

What is not known stays unknown and says why, in every facet: no value
recorded, a value no group takes or two at once (unclear, never the
nearest group), no way clearly under the tour's line, or a way that could
not be asked about. Imported tracks and hand-drawn stretches keep their
geometry; a matched way only counts where it keeps within 25 m of it.
A summary badge gives the known share and the most common class; the SAC
facet keeps its highest step and routing limit badges. Nothing about the
tour, its places or its days changes, and no routing preference is set.
2026-10-20 15:17:52 +02:00
Maurice ea9e997338 feat(tours): show the known SAC grades along a hike (MAP-03)
A hike in the planner, being edited or viewed as an import, now has an
"SAC grades" card. Switched on, it asks the router which OpenStreetMap
ways lie under the line (matched on foot, every grade of path open) and
the server reads their sac_scale through the Overpass mirrors, the same
trail-grades read the mountain bike grades use. The grades are drawn
along the route on the planner map in one ramp of a single hue, T1 to
T6, and listed by share and length; a row draws its class alone. Each
row names its source in a tooltip: the SAC hiking scale and the
sac_scale value OpenStreetMap wrote.

What is not known stays unknown and says why, never T1: a way without
a grade, a grade that names no single step (handed on as written), a
stretch with no way clearly under the tour's line, or a way that could
not be asked about. For imported tracks and stretches drawn by hand a
matched way only counts where it keeps within 25 m of the tour's own
line, so the original line is kept and nothing is guessed. The router's
own reading of the scale is not asked for, since it may be normalised.

The routing limit of a planned hike sits apart as a setting with its
own badge, and a known step above it is marked. Nothing about the tour,
its places or its days changes; switched off, the map is as it was.
No regional colour categories and no grade derived from slope, height
or trail markings.
2026-10-20 15:17:23 +02:00
Maurice ad2d653e9a fix(tours): save_tour_change says nothing was saved and answers a proposal saved once already
A save refused by the rules of the tour now says "Nothing was saved and
nothing changed." instead of the preparation's wording: lay() takes what
the changes are laid for, and refusalText() leads with that action.

A save sent again after it committed (a retry after a lost answer) used
to meet the conflict of a tour that moved on, which tells the assistant
to prepare the change anew, so an add could be saved twice. The save now
looks up the step its proposal was saved as from the revision it was
prepared on, before the stale check and once more inside the write, and
answers already_saved with that step instead, writing nothing.
2026-10-20 15:14:30 +02:00
Maurice b177daea3c fix(tours): point undo_tour_change at the revision prepare_tour_undo returns
The expected_revision field told assistants to read now.revision of the
preview, but prepare_tour_undo never returns that field: the revision sits
at undo.expected_revision. An assistant following the description either
guessed or left the value out and ran into a validation error or a revision
conflict. The description now names the real field, and the schema spec
pins it.
2026-10-20 15:14:30 +02:00
Maurice 63a7a4d397 feat(tours): undo_tour_change takes back a saved change to a tour once the traveller confirmed it
An assistant can now take back a change it saved with save_tour_change,
the way the change was saved: listed, shown, then taken back only after
the traveller said yes. Until now a saved step could be followed but not
undone; the traveller had to rebuild the walk by hand in the planner.

- list_tour_change_steps lists the steps of a tour, the latest first, with
  the changes as confirmed and where each stands: latest, undone, later
  change on top or no readable earlier state. Planner saves are no steps.
- prepare_tour_undo shows what taking a step back does without writing
  anything: the tour now, with the stations visited or skipped, set
  against the tour exactly as the step kept it, the stations kept, coming
  back and removed, and the distance and walking time stored then.
- undo_tour_change restores that tour whole or not at all, through the
  write the planner's save uses: line, stations with their keys and the
  measures stored then, in one transaction with the mark on the step, and
  the trip is told after the commit. It needs place editing on the trip
  and checks everything again inside the write.
- Only the tour's latest change goes back. A step something was saved on
  top of since, in the planner or by another step, is a conflict on the
  tour's one revision (no second one), so no later edit is lost unseen; a
  preview read at another revision is a conflict too. Taking back a step
  that would remove a station visited or skipped since, or bring back a
  station whose place of the trip is gone, is refused with every reason,
  and so is a step without a readable earlier state. Every refusal and
  failure leaves the tour as it is.
- A step is taken back once: tour_change_steps records the revision that
  raised the tour to, who confirmed it and when, and nothing else is kept.
- None of it touches a planner draft: the planner's own undo stays in the
  browser, and discarding a draft never changes a saved tour. The tool
  descriptions and the wiki say so.

The write of a tour's line and points and the tour as the answer gives it
now come from one place for the save and the undo.
2026-10-20 15:14:29 +02:00
Maurice f4533fc69b feat(tours): save_tour_change saves a confirmed change to a city walk whole or not at all
An assistant can now save the change prepare_tour_change prepared, once the
traveller said yes to it exactly as shown. Until now a proposal could only
be looked at; the traveller had to redo it by hand in the planner.

- The save names the proposal: the same changes, the revision it was
  prepared on, the proposal_id the preparation answered and confirmed true.
  Anything else, a change altered on the way or no yes, is refused and
  nothing is saved.
- Place editing on the trip and the tour are checked again before anything
  is written. A tour changed since the proposal was prepared is a conflict
  on the tour's one revision (no second one), telling the assistant to
  prepare the change anew instead of writing over newer edits. The changes
  are laid on the saved tour once more inside the write, so a station
  visited or skipped meanwhile, or a place of the trip gone, refuses the
  save with every reason.
- The change goes through the write the planner's save uses, in one
  transaction: line, stations, keys, measures and visits together or not at
  all, and the trip is told only after the commit. Stations kept keep their
  keys and visits. Where the router gives no way on foot the walk keeps its
  stations only and waits for the planner, as a created one does.
- Every save is kept as a step of the tour (tour_change_steps): the
  proposal, the revision it was prepared on and the one it raised the tour
  to, the changes and the tour as it was before them, so the step can be
  followed and taken back later. Nothing of the conversation is kept.
- A new walk has no revision to name and stays with create_city_walk; both
  tool descriptions and the wiki say which is which.
2026-10-20 15:14:29 +02:00
Maurice 1993ed4d28 fix(tours): prepare_tour_change drops the shaping of legs it takes apart and guards a loop's closing leg
A prepared change to a city walk took a station out or put one in without
touching the shaping points around it. The way after the change was then
routed through points steering legs that no longer exist, so the walking
effects showed a detour that is not real, and the points handed on for a
save could start or end on a shaping point.

- A shaping point belongs to the leg it steers. Removing, replacing or
  moving a station drops the shaping points of its legs in and out, and a
  stop put after a station drops those of the leg it splits. Shaping points
  of other legs stay. The answer names every dropped one in data_gaps
  (shaping_dropped), and the walk always starts and ends on a station.
- The closing leg of a loop counts as the leg into its first station. A
  closing leg drawn by hand now refuses a change to the first station and a
  stop after the last one, instead of quietly moving the end of a leg drawn
  in the planner. The hand-drawn check also covers every sub-leg between two
  stations, not only the one next to the station.
- for_later leaves out a new station already linked to a place of the trip,
  so following it no longer creates a second copy of that place.
2026-10-20 15:14:01 +02:00
Maurice 1969ca57d9 feat(tours): prepare_tour_change prepares a change to a saved city walk for review
An assistant can now prepare a change to a saved city walk and show the
traveller what it would do before anything is saved. Preparing writes
nothing: no tour, station, visit or day plan changes, and turning the
proposal down keeps the plan as it was.

- The changes apply in order and name the saved stations by their keys:
  add a station after another (a spontaneous stop is an add after the
  station the traveller is at), remove, replace and move. Stops put after
  the same station keep the order they were given in.
- What the travellers did stays as it is: a station visited or skipped on
  any day of the walk is never removed, replaced or moved, nothing goes
  before the last station already done, a protected station keeps its
  place and a leg drawn by hand stays as drawn. A proposal breaking a rule
  is refused whole, naming every change and why. Other tour types, and a
  walk that is a recorded track, are refused too: so far only a city walk
  takes prepared changes, since the others plan their way with routing of
  their own the server cannot run.
- The answer sets the walk after the change against the saved one: each
  station with what changed it and where it stood, what was removed, the
  way on foot before and after planned by the same router (the stored
  distance and walking time given apart, never mixed in), the stays as far
  as known, every leg with the new ones marked, the gaps that could not be
  worked out, a warning for a must station removed or replaced, and each
  new station as create_place takes it, to keep it for later as a place of
  the trip instead.
- The proposal names the revision it was prepared on, so a save of it is
  refused once the tour has moved on, and says whether the traveller may
  change the trip's places at all.

The way on foot of a new walk, its creation and a prepared change now come
from one CityWalkFootWayService instead of a copy in the briefing service.
2026-10-20 15:14:00 +02:00
Maurice b826ecd425 fix(tours): get_tour_plan calls the leg of a recorded track a track, not routed
An import without control points of its own answers the two ends of its
recorded track as its points, and the one leg between them was labelled
routed, as if the router had planned it. The assistant read a false fact.
Such a leg now says track, and the tool description and the wiki explain
the kind.
2026-10-20 15:14:00 +02:00
Maurice 19946039ad feat(tours): get_tour_plan reads the whole saved plan of a tour
An assistant reading a tour over MCP now gets everything the saved plan
holds, from the same reads the app uses, so its suggestions start from the
actual plan:

- legs lists every step from one control point to the next with how its
  line came to be (routed, via ferrata, drawn by hand, portage), keyed by
  the points' stable keys, and the length of a fixed leg's own line; a
  routed leg has no length stored apart and says so with null.
- line gives the saved line in brief: its point count, whether it carries
  heights, and a preview thinned to 200 points; null while the way waits
  to be planned.
- A city walk can be read for one of its days (dayId): each station then
  carries its visit status that day and run what is still open. Without a
  day no visit is told, so one day's ticks are never passed off as the
  walk's; dayId on another type or a day the walk is not on is refused.
- derived names every field worked out on reading rather than stored,
  including the control points an import without its own answers from the
  ends of its track.

Reading still writes nothing: no revision moves and no visit changes, and
drafts in a browser stay invisible. The wiki row of the tool says the same.
2026-10-20 15:13:41 +02:00
Maurice 6577595058 feat(tours): take a recorded ride from a GPX file as it was ridden
A ride recorded elsewhere comes in through the one Tours GPX import
(HOR-02): pick the file, choose Horse ride in the preview and confirm.
Once a ride is chosen, the preview says in three badges what the import
will and will not do for it: the line stays as the file recorded it, no
riding time is worked out, and nothing checks whether its ways may be
ridden. Each badge gives its reason in a tooltip, reachable by keyboard.
Nothing is stored before the confirmation.

Opened again, the ride's rail carries a Recorded ride card with the same
badges above its measures, and names heights the file did not carry
instead of showing them as zero. Its origin and file name stay in the
tour's provenance, it goes on a day like any tour, and no editable
version is planned from it on foot.

The import itself was already canonical and needed no change; end to end
tests now pin what this card promises: the stored line equals the
recorded points, the ride keeps its origin, distance and heights and has
no riding time, a file without heights keeps them unknown, a failed or
refused import leaves an existing ride and its place untouched, and the
same file twice is skipped.
2026-10-20 15:12:01 +02:00
Maurice 78366a9024 fix(tours): long rides save, the horse route store stays bounded
A ride sends its full line with heights and every horse route section's
own line besides, about fifty bytes a point, so a ride along a few
thousand points of bridleway hit the API's 100kb body ceiling and the
save failed with a 413. Creating and editing a tour are now measured
against TOUR_WRITE_MAX_BYTES (2 MB), like the book and list file
routes; every other route, the tour copy among them, keeps 100kb.

The horse routes kept in memory are now bounded by section points as
well as by count (64 answers, 500k points), so panning a dense map can
no longer grow the server by hundreds of megabytes.

A search that found no connection can be asked again on the view whose
routes are shown, and a ride with a recorded time shows the horseshoe
beside its riding time instead of footprints.
2026-10-20 15:11:25 +02:00
Maurice 0331d9950f feat(tours): plan rides on horseback from signposted horse routes
A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a
tour type with its own horseshoe, and a Horse routes card beside the
waypoints looks, when asked, for the route=horse relations OpenStreetMap
knows in the map view. A route opens in a dialog that shows it on a map in
the sections it is made of, split at forks, gaps and crossings, so nothing
is ever joined across a gap. The rider picks a section and its direction,
sees what the horse=* tags along it say (open, limited, closed, or unknown
where untagged) and whether it is signposted the other way, and takes it as
one undoable step. A section that does not start where the ride ends is
only joined by a straight manual stretch once the rider switched that on.

Nothing of a ride is routed: every leg follows a section or is drawn by
hand, and the waypoint list and the map say which (a section as a solid
line with its route, a manual stretch dashed and marked unchecked). A point
at a section stays where the route has it, a ride works out no riding time,
and distance, heights, day assignment, GPX import and export, the 3D view
and the reversed ride keep working with each leg's origin saved and opened
again. A failed lookup leaves the ride and the routes already found as they
were.

The server reads the routes through the shared Overpass client
(GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access),
keeps an answer twelve hours per grid-aligned view, and the tour contract
refuses a routed leg, a via ferrata or a riding time on a ride and a horse
route section on any other type. A migration enables the horse tour type.
2026-10-20 15:11:08 +02:00
Maurice dfe11e9c4c fix(tours): give the snowboard a corner glyph of its own
Snowboarding used the snowflake, the same glyph the winter hike label
shows in the same corner badge, so a snowboard track looked like a winter
hike in lists and the day plan. It now draws a board with its bindings.
2026-10-20 15:06:06 +02:00
Maurice fcdb7e48cd fix(tours): start the winter label from winter hiking on every other tour
The winter field remembered the label the switch brings back in its own
state, seeded once on mount. The details section stays mounted while the
planner saves, closes or opens another tour, so a snowshoe hike left its
choice behind and the next unrelated hike switched on as snowshoeing.

The planner now keeps that choice beside the label itself: a label set is
remembered, starting a new tour or discarding resets it to winter hiking,
and opening a tour takes the tour's own label.
2026-10-20 15:06:06 +02:00
Maurice 008aaeb408 fix(tours): say to zoom in when the pistes are on further out than they draw
The pistes overlay has no tiles below zoom 9, so switching it on at a region
or country view left the map empty while the switch looked on, which read as
broken. Both maps now report their zoom with the viewport, on the 256 px tile
scale raster sources count in (a GL map's own zoom is one step lower there),
and the planner hands it to its overlays. While the pistes are on and the map
is further out than their first tiles, the switch stays pressed in the soft
accent, its tooltip and the key's first line say to zoom in, and the screen
reader hears it once. An unknown zoom claims nothing.
2026-10-20 15:06:05 +02:00
Maurice 5ff7e54e09 feat(tours): lay pistes, cross-country trails and lifts over the planner map
SKI-01. The Tours planner's layer switch gains a winter layer: OpenSnowMap's
transparent piste overlay over whichever base map is shown, so a ski area
can be looked at while preparing a winter trip. It is offered with no tour
open (a resort without any route), beside a hike for winter hiking and on an
imported ski track; it needs neither a GPX import nor the ski type, and a
bike tour, a run and a paddle tour do not offer it. It is off until switched
on, and the choice belongs to the map view: switching it changes no point,
no step and no route, and it stays on from no tour to a hike and into 3D,
where the terrain draws it too.

The switch goes by its icon alone, named by its tooltip, so a hike's row
stays short. While the layer shows, a small key button rides beside it in
the same pill, its badge the four grade colours. The key opens above the
map as a popover: the downhill grades in OpenSnowMap's own colours (novice,
easy, intermediate, advanced, expert, freeride), cross-country trails,
winter and snowshoe trails, toboggan runs and lifts, a note that this is
mapped data that says nothing about what is open or groomed, the snow or
avalanches, and a link to the full key at OpenSnowMap. If the tiles stop
coming, the switch turns to the warning tone with its badge and says that
the map and the route still work, and the key stays away.

Only the overlay is used, never OpenSnowMap's relief or base maps. Like
every overlay it loads only the tiles in view: nothing is prefetched or
cached by the Service Worker, as their terms forbid bulk downloads. The
tiles go out with the page's origin as the referer, the attribution credits
OpenSnowMap (CC BY-SA) and the OpenStreetMap contributors, and the layer
stops at zoom 18, beyond which the server answers a picture instead of a
transparent tile. The CSP admits tiles.opensnowmap.org for the GL map,
which reads tiles through fetch.
2026-10-20 15:06:03 +02:00
Maurice 0169be3d79 feat(tours): import a ski track from GPX and keep it as recorded
SKI-02. Ski joins the types a GPX file can be imported as, offered in the
import preview's type field only: TREK routes no ski track, so the planner
never offers it. The preview asks for the discipline on purpose (alpine,
snowboard, cross-country or ski touring, none chosen at first) and, for
cross-country, the technique (classic, skating or unknown); Import waits
until both are named, and the technique stays in sight but dimmed for the
other disciplines. The track is stored exactly as recorded, with its file
name as provenance, its distance and heights from the file and no time:
no ski time is worked out and no hiking estimate stands in for one.

Opened in the planner, a ski track gets a rail card with its discipline on
a tinted tile, a cross-country technique as a badge beside it, the ski time
said as not worked out, and an info dot on what TREK does not say (snow,
avalanches, open runs, and that the 3D gradient colours are no difficulty
or avalanche rating). Change opens a dialog to classify the track again;
only the classification changes, through the offline core, with the
revision it was made at. Planning over the import is not offered for ski.
The existing 3D terrain view, the elevation profile and its focus work on
the track as for any import, with the standard map, topo and satellite and
no via ferratas. Lists, the day plan and the detail show the discipline as
a frosted corner badge on the tile and as a pill with the technique inside;
day assignment, copies and take-overs work as for any tour. The corner
badge is now one component that the winter label shares.

The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists,
NULL on every other type), switches the ski type on without a routing
profile, refuses a ski import without its discipline, a technique outside
cross-country and ski fields on any other type, and takes a planned save's
classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a
ski track again (place_edit, 409 on a stale revision, 400 for a tour that
is no ski track) and broadcasts tours:changed. GPX export names the
discipline (alpine_skiing, snowboarding, cross_country_skiing,
backcountry_skiing), and list_tours describes both fields with what a ski
track does not say. Ski is labelled plainly as Ski in every language, so
it no longer reads as ski touring.
2026-10-20 15:06:01 +02:00
Maurice 68907ec811 fix(tours): name the path grades on the run card without dashes
The run card's grade rows and its hardest-grade badge borrowed the hiking
limit's labels, which read 'T1 — Hiking'. The card now has its own labels in
the bike card's style ('T1, hiking') in every language, and the hiking limit
keeps its own wording.
2026-10-20 15:06:00 +02:00
Maurice 51bfb5abd1 feat(tours): plan a trail run on forest tracks and natural paths
RUN-02. Trail running joins Running as a run profile, chosen on purpose in
the planner and in the GPX import. It asks Valhalla's pedestrian costing for
tracks wherever they serve (use_tracks 1) and admits mountain paths up to T2
(max_hiking_difficulty 2), never T3 and above, with ferries only where no
way on land exists. Checked with trace_attributes against the public
instance: above Freiburg Guenterstal up to the Schauinsland the route went
from 82 % roads and 83 % paved (running) to 78 % forest tracks and 81 %
compacted, gravel and dirt; Rathen to the Bastei took 28 % of T2 path that
running leaves out; Heidelberg's old town stayed as it was. Leaning on
footways (walkway_factor) was tried and left out, it moved runs onto paved
footways.

While a run is planned, a card says what it runs on: its surfaces, the
hiking grades of its paths (sac_scale, T1 to T6, as mappers gave them) and
its kinds of way, each as shares with lengths that point at their stretches
on the map. A way without a grade or a surface is its own row, unknown and
never easy, and an info dot says the grades say nothing about whether a way
can be run. The head carries the unpaved share and the hardest graded path
as badges. The line is matched on foot for this, so steps and mountain
paths stay on it; a bike tour's is matched as before. Reading fails, goes
offline or is retried as for a bike tour, and nothing of it changes the run.

Pace and time are RUN-01's: the running time stays the distance at the
runner's pace, with climbs, descents and difficulty left out, whatever the
profile. A profile change plans the route again and keeps the pace; a
failed trail route keeps the last route and holds Save back. The card's
building blocks moved out of the bike card into RouteWaySections, so both
cards share one set of rows, bars and status lines.

The run_profile column already allowed trail (RUN-01), so no migration.
GPX export names a trail run trail_running, list_tours describes the
profile as a routing preference, never a statement that a way can be run.
2026-10-20 15:05:51 +02:00
Maurice 1d9b9bf838 fix(tours): read a run pace the way runners write it, and keep it
A pace typed as "5.30" or "5,45" is now read as 5:30 and 5:45, the way
it is written in much of Europe, instead of quietly becoming 5:18 and
5:27. One digit after the point stays a tenth of a minute ("5,5" is
5:30); anything else is marked as no pace.

The pace field no longer flashes the out-of-bounds error while a pace
is still being typed: the first digit of "12:30" reads as 1:00, so a
pace outside the bounds now reaches the planner only on leaving the
field or pressing Enter, like text that is no pace already did.

The pace unit is translated ("мин/км", "分/km" and so on) in the field,
the sum the running time is and the pace pills, so what is shown
matches what assistive tech reads out.

A run planned over in place again keeps the pace it holds, so the
runner need not type it again before Save.
2026-10-20 15:05:45 +02:00
Maurice c83eb42730 feat(tours): plan a run at your own pace
RUN-01. Run joins the tour types in the planner, with Running as its first
profile, chosen on purpose like a bike tour's. A run is routed on foot for
runnable, mostly paved ways: tracks and ferries only where there is no other
way (use_tracks 0, use_ferry 0, measured against the public Valhalla), no
hiking limit and no via ferrata. The runner types a pace in min/km; the
running time is the distance at that pace and nothing else, shown under the
field as badges (8 km x 6:00 /km = 48 min). TREK suggests no pace: without
one the time stays unknown and Save waits, text that is no pace or a pace
outside 2:00 to 20:00 is marked. The info dot, the overview and the head say
climbs, descents and difficulty are not in the time, and the time reads as
planned everywhere, so it never passes for a recorded one; the heights are
shown as for any tour on land.

Type, profile, pace and route come back on opening, survive a draft reload,
an undo of the type, a copy and a take-over; switching to another type drops
profile and pace. Day assignment, the 3D terrain view (with topo and
satellite, no ferratas) and a routing failure keeping the last route work as
for every tour. A GPX file can be imported as a run with its profile and no
pace, and planned over as a run. Runs export to GPX as running and get their
own places along the way (water, toilets, parks, viewpoints).

The server keeps tours.run_profile (CHECK list, already holding trail for
RUN-02, since SQLite cannot widen a CHECK in place) and
tours.run_pace_seconds_per_km (120 to 1200), switches the run type on,
refuses a run without a profile and run fields on any other type, and
list_tours names both with what the planned time does not include.
2026-10-20 15:05:38 +02:00
Maurice cd407cf80c feat(tours): a hike can be marked as a winter hike or a snowshoe hike
HIK-01. The details of the hiking planner gain an optional Winter activity
switch, off by default, so the normal hiking flow asks for nothing more.
Switched on, the hike is a winter hike or a snowshoe hike; the choice stays
dimmed and out of reach while the switch is off. The label is saved with the
tour, comes back when it is opened, survives a draft reload, a copy and a
take-over, and switching it off takes only the label away: the route, its
grade and the days stay as they were.

The label is a label only. Routing, the walking time and the 3D terrain view
work as for any hike, and the info dot and the head pill's tooltip say that
nothing was checked for snow, avalanches or whether a path can be walked in
winter. The saved label shows as a frosted snowflake on the corner of the
tour's tile in the tour list and the day plan, and as a pill beside the type
in the tour's detail.

The server keeps it in tours.winter_activity (CHECK list, NULL for every tour
saved before and for every other type), refuses it on a bike tour, a city walk
and a paddle tour, and keeps the stored label when an older client saves a
hike without the field. A snowshoe hike exports to GPX as snowshoeing.
list_tours names the field, with the same words on what it does not say.
2026-10-20 15:05:15 +02:00
Maurice 71fe69c1f1 feat(tours): mark the best known places along a city walk
A search for sights along a tour now also asks the places index for the best
known places near the line, ranked by how often their Wikipedia articles are
read (QRank). A row the index already found is marked as a highlight; a
famous sight no kind names, like a square, is added as a sight of its own.
Stations, hotels and restaurants rank a row they match but are never added.

Highlights are listed first with a star in the list and a badge in the map
card. A place counts as a highlight from 40000 views a year, or the best
three from 5000 where fewer reach that. An index without ranks, or one older
than the endpoint, leaves the search as it was.
2026-10-20 12:16:11 +02:00
Maurice 54baf85a2a fix(tours): a city walk keeps to the bridges and avoids ferries
The walk was routed with the pedestrian router's default, which takes
ferries, so a walk through Venice went along the vaporetto lines across
the canals instead of over the bridges. A walk now asks for no ferries
(use_ferry 0), as a hike that avoids them does; where no way on land
exists the router still takes the ferry.
2026-10-20 11:32:03 +02:00
Maurice 3882feec5c fix(tours): the last review findings of the city walks
A place closing at midnight is no longer read as open past it: the hours
run on only where the next date opens at midnight (any window of the day
counts, as written order is kept), and an impossible date such as
2026-02-31 reads as unknown instead of rolling into March. The hours
source names the trip's places rather than "your own" entries, in all
27 locales, as the pill beside it does.

The day a walk's arrivals are checked for shows its date on the pill,
not only "Day N". The 12 hour clock is tested for a window to midnight.

The along-the-tour term table is built once again, now that the one test
that stubbed the places client without its real exports spreads them.
New test seeding goes through the ORM factories.
2026-10-20 10:37:29 +02:00
Maurice 6477bf5e39 test(admin): fail the addon load with a server error, not a dropped connection
A network error starts the client's connectivity probe, and whether the
app then counted itself offline, where it stays quiet on purpose, raced
the error toast on a loaded runner (FE-MOB-AADD-004 failed in CI twice).
A 500 fails the load the same way and toasts every time.
2026-10-20 10:12:36 +02:00
Maurice 971133a1eb test(tours): read what taking a place in did from the hook test, not from a planner whose points stay put
The card test's planner is a stub whose waypoints never take the new
point, so the list's outcome line, which waits for the point to be in
the route, cannot show there. FE-TOUR-POI-006 covers it with a planner
that does.
2026-10-20 10:01:08 +02:00
Maurice 0344db6d0c test(shared): count the tour keys without the four hint lines taken out
The stay-unknown pill, the along-the-tour intro, its undo hint and the own
theme line left the tours domain in the previous commit.
2026-10-20 09:49:27 +02:00
Maurice 5d265c98de feat(tours): places along the tour picked on the map, stops that keep their kind, and calmer station rows
Along the tour (PLN-02, PLN-03): the list shows one line per place (its
icon, name and distance from the route) and scrolls past ten. A place
picked in the list or on the map opens its card over the map, where the
other cards of the planner sit, with what the source says and the way to
take it in as a stop. What taking it in did is one line in the list. The
intro, the theme line and the out-of-date note are gone: the themes say
what they look for on hover, and the search button says when to search
again.

A stop taken in from there keeps the kind of place it was found as
(migration 045400, poi_category, read back only where the contract knows
the kind) and its number carries that kind's icon at the corner, as a
via ferrata's end does.

City walks (CW-02, CW-03, CW-10): a stay nobody entered says nothing.
While no stay is entered at all the walk is planned as its walking time,
with no "unknown" and no "at least". A must-see rings its number, an
optional stop has a dashed ring, a protected one a shield at the corner;
only the stay stays under the name, below the coordinates.
2026-10-20 09:47:24 +02:00
Maurice 554d6f1b9f feat(tours): a city walk prepared from a briefing over MCP, and the station fields an assistant reads spelled out (CW-10, CW-11)
An assistant connected over MCP can now prepare a city walk from what the
traveller tells it. propose_city_walk takes a start (and an end), a time
frame on a day of the trip, the themes of CW-05, the stations the
traveller wishes for, the places of the trip it may use and fixed
appointments with their times, and saves nothing. Places for the themes
come from the search along a tour, at most eight, each with the theme,
category and source that brought it; nothing is made up. The stations are
ordered on estimates over the straight distance, and a place of a theme or
of the trip goes in only where it is open on arrival, keeps the frame and
the appointments, and leaves every station already on the walk open when
the walk gets there. The walk in that order is then planned on foot by the
router the road trip uses (the traveller's own routing server where they
set one), and its legs give the walking distance and time from one station
to the next. Where the router cannot be reached, or a station lies more
than 300 m off every way (the planner's limit, now shared with the
server), the times are estimates over the straight distance with an
allowance for streets at 4.5 km/h, and the answer says so and why. The
answer gives the arrival and leave times, the stay where the traveller
gave it and unknown otherwise, which makes every later arrival the
earliest one, and the opening hours checked against the arrival on that
day with the helpers the planner uses. Walking and stays are counted
apart, the total is complete only when every stay is known, and the
minutes the frame leaves for the unknown stays are named. Free time before
an appointment or at the end, wishes and appointments that cannot be kept
as asked, and places left out with their reason are listed; a tour of the
trip offered as a station is named as a tour. A wish or an appointment is
never dropped; an appointment is a must and protected. A place's default
duration is not read as a stay: every place carries one, so it says
nothing.

Once the traveller confirms, create_city_walk saves the walk through the
same creation the planner's save uses: one write, a key for every station,
the links to the trip's places checked, the trip told, with place editing
permission on the trip. Its way on foot is planned by the same router and
saved with the walk, together with its distance and walking time. Where
the router gives no way, the walk is saved with its stations only and the
answer says why: no line, no distance and no walking time. The tours list
says so (route_pending), the tour detail says in one line that the way is
planned in the planner, and opening the walk in the planner plans its way
at once and offers to save it, with a note once it is planned that it was
just planned. Such a walk can be saved as a new tour and taken over into
another trip, starting at its first stop, and is never offered as a place
to stay. A tour without a line needs two points of its own.

get_tour_plan now spells out every field of a city walk's stations: key,
name, stay (null when not known), importance (null for normal), protection
(false when not set) and opening hours (null when none are known);
list_tours names the station count and whether a walk waits for its way,
get_city_walk_progress gives each station's key and opening hours, and
take_over_tour says a walk keeps its stations with their protection. No
tool changes a walk's stations; deleting a walk's place takes its stations
along, protected ones included, and both delete tools now ask the
assistant to name those and ask the traveller first. REST answers the
stations as before.

A tour create refused for a type that is switched off, a description that
is too long or planned times out of range is a domain refusal now, so an
assistant gets the same text REST sends; REST still answers it as an error
at 400.
2026-10-20 07:09:57 +00:00
Maurice f79b2020d6 test(admin): wait for the addon list's empty state after a failed load
The empty state shows once loading has ended, which can come a render after
the toast; the test read it in the same tick and failed now and then.
2026-10-20 07:09:57 +00:00
Maurice 9f0caf8539 feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08)
"Public transit" on the connector between two city walks in the day plan,
on the desktop and on the phone, now opens the existing search from where
the first walk really ends to where the next one starts, at the
coordinates the day's route draws, named after the first walk's last stop
and the next walk's first stop. A loop ends at its first stop again. A stop
without a name takes the name of the trip place it stands for, and a walk
whose stops cannot be read on this device names that end "End of" or
"Start of" the walk. The search opens on the walks' day and departs when
the first walk ends: its start that day, the time on the day's entry
before the place's own, plus its planned total, a total entered by hand
before walking and stays. The assignment's own end time is not used.

Above the search a note says when the first walk ends, "at the earliest"
where a stay or the walking time is not known yet, and when the next walk
starts. Without a start or a total no time is made up: the time field
stays empty and Search waits until one is chosen. A walk that only ends
after midnight says so, and the time field stays empty as well. On any other day, or for
any other leg, the search opens as before, with 09:00 as its default. A
connection that arrives after the next walk starts says so in its card,
before its legs are opened and before it is taken. Nothing is saved until
"Add to day", which keeps the connection as its own journey in the plan as
before; both walks stay two walks on foot and nothing is moved.

A journey saved before a walk that now arrives after the walk starts says
so on its row in the plan, on the desktop and the phone, while the plan is
looked at. Nothing is rewritten. The desktop no longer draws a connector of
nothing between a stop and a booking at the same spot, as the phone already
did, so a journey saved at the walks' ends adds no walk on either side. A
leg leaving a loop tour no longer departs at the tour's start time.

The leg is told by the walk it leaves, so two walks of a day that end at
one spot, such as two loops from the hotel, each search from their own end
and time, and a stop between two walks keeps the leg an ordinary one. Only
the leg asked for last opens the search, and none opens once the plan is
left while the walks' stops are still being read.

Both views run on one shared hook, the walk's stops are read through one
helper the stop list uses as well, and the walk's start on a day is worked
out in one place for the times shown in the detail and here.
2026-10-20 07:09:57 +00:00
Maurice 7470a2c6d4 fix(tours): a short link whose name does not resolve just now, or that Google does not know, is said as such in the walk import (CW-06, CW-07)
When the name of maps.app.goo.gl or goo.gl could not be looked up for a
moment, the walk import said the short link leads somewhere other than
Google Maps. It now says the link could not be followed just now and answers
502, as for any other failure on the way; a name that leads into this
network is still refused.

A short link Google answers with 404 or 410 is one it does not know, and
asking again changes nothing. The dialog now says so in the reader's
language and asks to share the route from Google Maps again, with a 400 and
the reason short_link_unknown, instead of suggesting to try again later. A
busy or blocking answer from Google stays a failure that may pass.

The MCP tool preview_google_maps_walk is marked open world, since it follows
a short link to Google on every call, and the English text REST and MCP send
for each reason is held to the English text the planner shows.
2026-10-20 07:09:57 +00:00
Maurice 4ca5728072 feat(tours): a city walk from a Google Maps walking route, its short link followed only to Google Maps (CW-06, CW-07)
"Add tour" in the planner now offers "Import walk from Google Maps". The
dialog takes the link of a route shared in Google Maps, either a
maps.app.goo.gl short link or the long google.com/maps/dir link, or the
share text of the Google Maps app with the one link in it. It shows the
stops in their order with their names, marks a stop the link gives no
position as such, says that the route comes from Google Maps with a link to
open it there, and that TREK plans the way between the stops itself and
does not take the line drawn in Google Maps over. Nothing is written until
"Load as walk": that opens a new, unsaved city walk in the planner with the
positioned stops and their names, routed on foot by TREK, and warns first
when it replaces unsaved work. A stop without a position is left out and
never looked up or guessed; with fewer than two positions nothing can be
loaded. While the walk is not saved, a calm notice in the planner says
where it came from, also after a reload of the draft. Cancelling or a
failure leaves every tour as it was.

Only a route the link marks unambiguously as on foot is read. A car, bike
or transit route, a link that does not say, or one that says two things is
refused with a reason the dialog gives in the reader's language. Each stop
takes its position from its own block of the link's data, so a line
dragged through extra points or a stop given as coordinates no longer
leaves the stops without positions or shifts them. The road trip and
places imports read links as before.

A short link is followed by a resolver of its own: every hop is checked
against the Google Maps short link shapes and the strict SSRF guard before
it is looked up or asked, so ALLOW_INTERNAL_NETWORK and
ALLOW_LINK_LOCAL_IPS do not open it. Each request goes to the vetted
addresses only, follows nothing by itself, reads no body and carries no
credentials; the chain has one deadline including name lookups, at most
three hops and a cap on the Location header, and the long link it ends on
must be Google Maps and is never fetched. The preview is POST
/api/trips/:tripId/tours/import/google-maps/preview for members who may
edit places, with a 502 when Google could not be reached, and the MCP tool
preview_google_maps_walk reads the same.
2026-10-20 07:09:57 +00:00
Maurice 2203ff3a2e feat(tours): a city walk's arrivals and opening hours for the day it is on, at each stop in the planner and the detail (CW-02, CW-04)
A city walk now shows, at each stop, when the walk gets there, until when
the stay lasts, and what the stop's opening hours say about that visit. The
same lines appear under each stop row in the planner and under each stop in
the walk's detail, in the user's 12 or 24 hour format. A conflict names the
stop, the arrival, the end of the stay and the hours it was checked against,
for instance "Lenbachhaus closes at 18:00, during the stay from 17:20 to
18:20 (open 10:00 to 18:00)", in the warning colour. Nothing is changed by
these hints: no stop, order or route moves.

The times belong to one day the walk is on. Opened from a day of the plan,
the detail keeps that day, and checking stops off stays bound to it.
Opened otherwise, and in the planner, a small pill above the stops names
the day and its start, and the walk's other days can be picked from it; it
starts on the first day with a start time. The pick is only what is looked
at and is never saved, and another day never takes over the times quietly.
Without a day, a date or a start time the line says what is missing and no
arrival is shown. TREK keeps no time zone on a trip, so start, arrivals and
opening hours are all read as the local time of the plan, and an arrival
after midnight is said to be on the next day without being checked.

Opening hours come from the trip place a stop stands for, as the travellers
entered them, and otherwise from OpenStreetMap; a pill at each stop names
the source, and a notice says the hours are a hint to check. A stop without
hours stays unknown and is never shown as open, hours that are not in a
plain form are shown as written, and an arrival that is only a lower bound,
because a stay before it is not entered yet, is not judged unless no later
arrival could escape the conflict. A trip place that a stop stands for also
gives the stop its name before "Stop N".

The arrival at a stop is found where the route passes it in the walk's
order, so the end of a loop and a stop passed on the way out and back get
the right time. The rules live once in the shared package, for the server
to give the same answers. The walk's detail reads its stops again after a
save, its own or a teammate's.
2026-10-20 07:09:57 +00:00
Maurice 0d7e3ceb3d fix(maps): read the index terms when a row is judged, not when the module loads (PLN-02)
A suite that stubs the places index client without its term table could no
longer load the maps service, because the list of every term was built when
the module was first imported. It is read where a row is judged now.
2026-10-20 07:09:57 +00:00
Maurice 93a508a0e6 fix(tours): count a city walk's visits event in the realtime inventory (CW-03)
The registry of realtime events gained tours:visits when a city walk's
stops could be checked off for every member of the trip, but the test that
pins the inventory still counted 76 trip events and 109 in all. It now
counts 77 and 110 and says which event the 77th is.
2026-10-20 07:09:57 +00:00
Maurice 0b92dcc86b feat(tours): show a city walk's progress on the phone's day row, and check its stops off offline (CW-03)
The day row on the phone now shows how many stops of a city walk were
visited on that day, "2 of 5 visited", like the desktop row does. Both rows
read it from one place, and a tap on the phone names it in full without
opening the row.

A walk's stops can be checked off, skipped and taken back without a
connection. The mark shows at once, waits in the offline queue and goes out
when the connection is back, in the order the marks were made and behind any
older mark of the same day that still waits. The day plan, the walk's detail
and a reload read the visits kept on this device with the waiting marks on
top, so a mark made offline stays on screen until the server has it. A mark
the server answers with "not found", because the stop, the day or the walk
was removed meanwhile, counts as done and holds back no later mark of that
day. Any other refusal, the Tours addon turned off among them, keeps the mark
as a failed change to try again from the offline settings. The visits are
read again when the connection comes and goes, and once more after the
waiting marks went out, so a mark the server dropped or refused leaves the
day plan. A trip's visits that answer after another trip was opened no longer
show there.

The offline sync keeps each trip's walk visits and reads the stops of a walk
this device has never opened, so it can be checked off offline too. A walk
whose stops this device could not read lists no stand-in stops and no count,
and says to open it again while online. A walk put on a day while offline
says its stops can be checked off once it has synced, instead of offering
buttons that cannot work yet. A mark that fails says to open the walk again
and try once more, in every language.

Marks carry no version, so the last one to reach the server wins, as with
the other changes sent after being offline. The offline guide says so.
2026-10-20 07:09:57 +00:00
Maurice c836ee3988 fix(tours): a city walk saved offline shows its stays and total at once, and the planner plans the total its overview shows (CW-02)
A city walk created or changed while offline now carries its stops, the
minutes its stays are entered with and how many have none yet, worked out
from its points the way the server will answer them. The day row, the tour
detail and the summary show the walking time with the stays as soon as the
walk is saved, with "at least" where a stay is missing, and a stay changed
while the write waits shows its new total rather than the one of the last
answer. The server and the client count the stops in one shared place.

The planner plans a walk whose walking time is not known yet with at least
the stays entered, as its overview says, so the planned field and the check
against the longest plan read the same total.

In the tour detail a longer time such as an "at least" total wraps on a
phone instead of being cut, and the boxes of a row keep one height. A total
entered by hand falls back to its short name where its column is too narrow.
2026-10-20 07:09:57 +00:00
Maurice 188953158a fix(tours): a city walk's walking time, stays and total read apart and alike everywhere, and a stay plans no route again (CW-02)
A stay, a name, an importance or a protection set in a stop's dialog no
longer plans a city walk's route again: the walk stays ready to save, its
time overview follows at once, and one undo takes the change back without
routing either. A stop moved or added plans the route again as before. A
walk saves no breaks, so a cafe never counts as a stay and as a break at
once, and a break left from another type neither counts nor holds its save
up.

The planner and the tour detail show a walk's walking time, its stays and
their total apart and in the same words: 45 minutes of walking, a museum of
90 minutes and a cafe of 30 minutes make 2 h 45 min. A stay not entered yet
makes the stays and the total at least what is known, stays nobody entered
read not known yet, and stays entered as 0 read 0 min. A total entered by
hand keeps its own label, and the computed total shows beside it. The head
of the tour detail, the day row on the desktop and the phone, the tour
summary and the library of other trips show that same total, with a ≥ where
a stay is missing. Their time on the move is called walking, and the summary
says a walk is planned with its stays rather than breaks.

The tour list and the library carry the minutes a walk's stops are entered
with and how many have no stay yet, counted from the walk's points, so the
way back of a loop is no stop and its start's stay counts once. A loop run
the other way keeps its start as the stop there, with its key, name and
stay.

The planner's waypoint list keeps the rows of a leg in a file of their own,
and the tour detail its name heading in a part of its own, so both stay
within their size limits.
2026-10-20 07:09:57 +00:00
Maurice 20f8266439 fix(tours): keep a city walk's visits through a save from the planner, and count a loop's start once (CW-02, CW-03, CW-09)
A walk saved from the planner kept its stops without the keys the server
gave them, so the next save from the same open planner handed every stop a
new key and dropped the visits checked off in the day plan meanwhile. The
planner now takes over the keys a save answers, point by point, without
planning the route again, so a stop keeps its key and its visits from one
save to the next.

The edits of a stop's dialog now reach the planner: a changed name, stay,
importance or protection counted as no change and was dropped. It is a step
of its own that can be undone, Save stays available, and the route is not
planned again for it.

A save that takes stops out of a walk, or makes it another type, tells every
client of the trip which visits are left on each day the walk is on, the
client that saved included, so the progress in the day row no longer counts
a stop the walk has lost.

The way back of a loop is the start again and no stop of its own. Closing a
loop gives it only the start's spot and the place it stands for, and the
planner's stays, the stops of the tour detail, the stop count of the tour
list, the visits and the progress an assistant reads leave it out, so a loop
through three stops counts three, the start's stay counts once and the way
back cannot be checked off on its own. A loop saved before keeps working.
The rule lives in one helper the server and the client share, and the tour
list takes its stop count from the walk's points through it.

The tests that seed a walk for the assistant's progress go through the ORM
factories.
2026-10-20 07:09:57 +00:00
Maurice e5f01e570a fix(tours): check a city walk's stops off and save a stop from its dialog, with the walk under test (CW-01 to CW-05, CW-09)
Marking a stop of a city walk visited or skipped answered with a server
error every time: the write that keeps a changed status stamped its time as
a function call, which neither SQLite nor Postgres takes. It now uses the
clock the database itself spells, so a stop checked off, skipped or taken
back stays that way and the day plan of everyone on the trip follows.

The Save button of a stop's dialog in the planner did nothing, because it
sat outside the dialog's form. It saves the name, stay, importance and
protection again. A walk saved with two points that carry one key, as a
loop back to the start does, keeps that key on the first point and gives
the other one of its own, so checking one off never marks both. A walk put
on one day twice is read for an assistant from its first assignment.

The server tests cover the visits from the repository to the routes: per
day, through a moved and a removed assignment, a copy of the walk and a
copied trip without visits, a removed stop taking its visits along, and
the refusals for another trip, a stop the walk does not have, a member who
may not edit days and an unknown key or body. The MCP progress of a walk,
the station fields and counts of the tour list, and the contracts in
shared are tested too; the existing tests expect the keys every saved
point now carries, the station count of a list row and the new entity.
The client tests cover the stop dialog and its pills, the walk themes and
categories of the search along a tour, the stop menu of the waypoint list,
the walk's timing, routing and type choice, and answer the trip planner's
read of the visits in the tests instead of leaving it unanswered.
2026-10-20 07:09:56 +00:00
Maurice 2a8dbf91a8 fix(tours): keep the city walk helpers only their own files use inside them (CW-01, CW-03, CW-04)
The filter for a walk's stations in the planner, the opening windows of a
walk's schedule and the station shape of a walk's progress were exported,
although nothing outside their own file reads them, so the check for unused
exports counted three more than it allows. They are local to their files now;
a city walk plans, checks off and times its stops as before.
2026-10-20 07:09:56 +00:00
Maurice ec9e1a7533 fix(tours): name the Overpass kinds of a search along a tour at once while every mirror is down (PLN-02, CW-05)
While the public Overpass mirrors do not answer, every search along a hike
with viewpoints, huts or drinking water waited out the whole twelve second
deadline only to name those kinds as not searchable. Once every mirror has
failed the search along a line, and no other search along a line was
answered in the meantime, the next searches for three minutes leave Overpass
alone and name its kinds at once, next to the places the index found; a later
stretch of a long line that was already being searched does not wait either.
A stretch answered shortly before keeps its places from the cache during the
pause. After the three minutes the next search asks again for real, and any
answer ends the pause. The log holds one line per pause with the time it
ends, next to the line of the search that failed. The explore buttons of the
map and the other Overpass lookups are unchanged.
2026-10-20 07:09:56 +00:00
Maurice 3c823e07f4 fix(tours): search along a tour through the places index along the line, and name what could not be searched (PLN-02, CW-05)
The search along a tour found nothing. It asked the places index for all
kinds of a stretch at once, above the 100 rows and 200 characters of terms
the index takes, so the index refused and every stretch went to the public
Overpass mirrors, which do not answer from this server. Now the index is
asked in small circles strung along the line, one request per kind and
circle within its limits, and an empty circle counts as an answer, so a
city walk lists its sights, museums, parks, cafes, restaurants and markets
in well under a second. The kinds the index does not know (viewpoints,
huts, drinking water, toilets and the like) are asked of Overpass along the
whole line at the same time, one query per stretch of up to 50 km, three at
a time, each with a short deadline of its own; a line past 400 km is
searched along its start and says so. When the index answers no circle at
all, its kinds go to Overpass beside the others instead of after them. A
kind no source could be searched for is named in a calm notice above the
list instead of reporting nothing found, and the reason is written to the
log. Unnamed taps, toilets, shelters, picnic spots, viewpoints, summits,
slipways and repair stands stay in the list and on the map under the name
of their kind; an unnamed garden, cafe or memorial is left out. The places
nearest the line are kept when there are more than sixty, listed from the
start, with the opening hours the source has.

A walk's parks are parks and gardens only, and its museums are museums
and galleries only, no longer the beaches, summits, theatres and cinemas of
the map's explore buttons; a water park the index files under parks is not
one of them. The index also answers markets and summits. The explore
buttons keep their single request, and a search of several kinds that
would exceed the index limits is asked one kind at a time instead of being
refused. The MCP tool search_tour_pois and its wiki row describe the walk
kinds and the new unavailable list, and the OVERPASS_TIMEOUT_MS row says
the search along a tour keeps its own shorter deadline.
2026-10-20 07:09:56 +00:00
Maurice 9ed944c1e6 fix(tours): city walk gates: entity collection, formatting, a walk's name hint and the tests' visits repository
The day assignments entity carries the station visits that hang off it, as
the generator writes it. A city walk gets its own placeholder for an untitled
name, the tour service tests hand in the visits repository the service now
takes, and the files the walk touched are formatted. The theme icons of the
place search overlap by their start margin, so they stack the same way in
both reading directions.
2026-10-20 07:09:56 +00:00
Maurice 2955c61c86 feat(tours): a city walk's arrivals against its stops' opening hours, and a compact place search (CW-04, CW-05)
Opened from a day with a time, each stop shows its estimated arrival: the
start, the walking time shared out along the route, and the stays before
it, at the earliest while a stay is unknown. A stop whose opening hours say
it is closed then, or closes before the stay ends, says so with the times;
hours that cannot be read are shown as they are, and a stop without hours is
unknown, never open. The themes are a two by two grid and the categories one
line with a list to tick, so the search no longer fills the rail. The button
that discards a draft for a new tour says so in two words.
2026-10-20 07:09:56 +00:00
Maurice c97105ce61 feat(tours): fill a city walk from a theme: culture, parks, food or a bit of everything (CW-05)
The search along the route offers a walk's own categories (sights, museums,
parks, views, coffee shops, places to eat, markets) and four themes that set
them at once. A found place says which theme brought it, its source and how
far it lies from the line, straight across; taken in as a stop, it keeps its
name and opening hours. Nothing is taken in without the existing confirm.
2026-10-20 07:09:56 +00:00
Maurice 290961cced feat(tours): check a city walk's stops off for the day it is on (CW-03)
The walk's detail opened from a day lists its stops in order, each with its
stay, importance and protection, and marks one visited or skipped for that
day, or takes it back. The day row shows how many were visited. Marks reach
the other travellers at once and change no route, place or order; a copy or
another day starts with none.
2026-10-20 07:09:56 +00:00
Maurice 4c594876ef feat(tours): plan a city walk with its stops, stays, importance and protection (CW-01, CW-02, CW-10)
City walk is a type of the planner: routed on foot without a hiking limit, on
the standard map. Each stop is edited in its own dialog, its name, its stay
(empty is not known, 0 a point passed through), its importance and whether a
proposed change must leave it; none of it plans the route again. The walk's
time is walking plus the stays, at least that much while a stay is unknown,
with no breaks on top.
2026-10-20 07:09:56 +00:00
Maurice d5d2d6b25e feat(tours): city walks in the contract and on the server (CW-01, CW-02, CW-03, CW-09, CW-10)
A tour can be a city walk. Its points are stations that keep a key across
saves, a name, a stay (0 for a point passed through, empty when unknown), an
importance and a protection; the visits of a walk's stations are kept per day
assignment, so another day or a copy starts with nothing visited. A station
that is gone takes its visits with it. An assistant reads how far a walk got
on a day, what is open and which times are known.
2026-10-20 07:09:56 +00:00
Maurice e3268735f2 ci: fold the gate jobs into two per package, so the long jobs fit the 20 runners
With three gate jobs per package a code change started ~29 jobs, 22 of
them running for minutes, against the organisation's 20 concurrent jobs.
Two test shards waited three minutes for a slot and the run took 9.9
minutes instead of 8.5. Server lint and checks, and client types and
checks, now share a job each (about 4-5 minutes, still shorter than a
shard), which leaves 20 long jobs. The note at the top of test.yml says
to count them before adding one.
2026-10-20 08:35:41 +02:00
Maurice 9a59703a34 docs(ci): complete the job list in test.yml and follow the renamed parity job
The list at the top of test.yml now names every step group its jobs hold,
the contributing guide names the parity job as it is called now, and
db-mock.ts says test-db reaches the auth controller only when a rate limit
is reset.
2026-10-20 08:27:01 +02:00
Maurice 200ce58e98 test(server): import the auth controller only when a rate limit is reset
test-db.ts imported AuthPublicController at the top for resetRateLimits,
which pulled the auth graph (bcrypt, otplib, nodemailer, the MCP SDK) into
every file using the helper; most never reset a rate limit. Every caller
already awaits the helper.
2026-10-20 08:22:40 +02:00
Maurice a965b2c75e perf(shared): keep the DOMPurify sanitisers out of the root barrel
isomorphic-dompurify builds a jsdom window as soon as it loads on Node,
and the root barrel is what the server and each of its ~900 test files
import, so every one of them loaded jsdom without ever sanitising. The
sanitisers move behind @trek/shared/sanitize (a build entry of its own,
like roadtrip), escapeHtml stays in the root from its own module, and
the client's translation context imports the sanitiser from the subpath.
The production server stops loading jsdom at boot as well.
2026-10-20 08:22:40 +02:00
Maurice 91a834010b ci(sonar): leave the React root and the mascot videos out of the analysis
Every scan spent 300 of its 450-830 seconds in the JS security sensor's
React render simulation, which starts at client/src/main.tsx and ran into
its fixed time limit each time. main.tsx holds the stylesheets and the
mount call; what it renders is analysed through its own files. The .mp4
clips under client/src were read as text and only produced encoding
warnings.
2026-10-20 08:22:40 +02:00
Maurice a8d06193bd ci: split the gates into types, lint and checks, and name every job by package
The two quality jobs ran 8-9 minutes each and became the floor of the run
once the tests were sharded. Each is now three jobs (types, lint, checks)
holding the same steps in the same order. Plugin facts moves into the
server checks and the SDK typecheck into the SDK coverage job, both a few
seconds each. Every job is named "<package>: <what>", and the top of
test.yml lists what each one holds.

The client and server suites run in seven shards each, which evens out
the slices (server shards ran up to 6.5 min at six, client ones 3-5 at
eight). Client jobs install shared and client in one npm ci instead of two.

The swc step is replaced by a check: the lockfile carries the Linux
binary, and the step's npm install --legacy-peer-deps pruned eight peer
packages, so the server tests ran on a tree the lockfile does not describe.
2026-10-20 08:22:40 +02:00
Maurice 97b748555f ci: run the tests beside the gates, and the client and server suites in shards
The test jobs waited for the quality jobs (~9 min) and the client suite ran
on one runner (20-35 min), so a run took 45-60 min before ci-ok answered.
Every test job now starts right after changes. The client suite runs in
eight slices and the server suite in six, each with coverage into a blob
report; client-coverage and server-coverage merge the blobs, check the
thresholds against the whole and upload the lcov the scan reads. The scan
needs the gates directly, so a failed gate still stops it.
2026-10-20 07:57:23 +02:00
Maurice 187c4e2fc8 style(tours): format the GPX activity helper 2026-10-19 23:25:26 +02:00
Maurice dfd02940b5 test(tours): ask the trail grade cache one request after another, without an await in a loop 2026-10-19 23:12:30 +02:00
3869 changed files with 303320 additions and 65727 deletions
+3
View File
@@ -26,5 +26,8 @@
*.eot binary
*.pdf binary
*.zip binary
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
*.sgr binary
*.gz binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+329 -154
View File
@@ -9,23 +9,73 @@ permissions:
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# client-e2e drives the real app in Chromium and WebKit, client-bundle
# builds the client and checks each map engine keeps a chunk of its own,
# and postgres-probe runs the dialect helpers and the repository
# statements it reaches against a Postgres service. None of them feeds the scan.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# Job names read "<package>: <what>", so the checks list on a PR sorts by
# package. What each job holds:
#
# Repo: Changed Areas decides what the change needs to run (below)
# Shared: i18n Parity every locale has en's files and keys
# Shared: Coverage shared's tests with coverage
# Server: Types builds shared and the server, typechecks shared,
# the server's src, tests and scripts, smokes the
# production require chain, the strict ratchet,
# the generated entities and Kysely types
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
# Prettier, the plugin facts, the DB call graph,
# the shared ratchet code, the source ratchets
# (size, boundaries, dialect, tx, test SQL, test
# mocks, hand-built services, query API, MCP zod,
# error envelopes, response contracts), open
# request schemas, knip
# Server: Tests n/7 one slice of the server suite, with coverage
# Server: Coverage merges the slices, checks the per-domain
# thresholds, uploads lcov
# Server: S3 Contract (MinIO)
# the storage driver against MinIO
# Server: Postgres Probe the repository SQL against Postgres
# Client: Types & Checks typecheck and the strict ratchet; then the page
# pattern and the source ratchets (RTL, size,
# Prettier, layers, offline path, duplication,
# desktop/phone pairs, skips, i18n keys, theme
# tokens)
# Client: Lint ESLint (typed), errors and warning counts
# Client: Tests n/7 one slice of the client suite, with coverage
# Client: Coverage merges the slices, checks the thresholds,
# uploads lcov
# Client: Bundle the production build, one chunk per map engine
# Client: End-to-End the app in Chromium and WebKit
# Plugin SDK: Types & Coverage
# Repo: Docker Image builds the image and boots it until ready
# Repo: Helm Chart & Compose
# Repo: Sonar Scan SonarCloud analysis, not required
# ci-ok the one required check (below)
#
# Job layout:
# - Everything starts right after changes: the gates and the tests run side
# by side, so a run takes as long as its slowest job and not the gates
# plus the tests. The tests used to wait for the gates, which put ~9
# minutes in front of every test result. The gates were one job per
# package (~8-9 minutes each); each is now two jobs of ~4-5 minutes,
# split where the time is (the server's typechecks, the client's lint),
# so neither outlasts a test shard.
# - The client and server suites run in shards (client-tests,
# server-tests), each running a slice with coverage and uploading a blob
# report; client-coverage and server-coverage merge the blobs, check the
# thresholds against the whole and upload lcov. A red shard is usually a
# failing test, a red merge job usually a threshold. None of the other
# test jobs feeds the scan.
# - Job count: GitHub runs at most 20 jobs of the organisation at once and
# does not say in which order it starts queued ones. A code change starts
# the 14 shards and about a dozen other jobs; seven of those (parity, the
# SDK, the bundle, S3, Postgres, shared coverage, the chart) are done in
# a minute or two, so what queues at the start waits about that long. The
# jobs that run for minutes (shards, gates, e2e, the image) must not
# outnumber the 20 slots, or one of them waits for another to finish and
# the run grows by a whole job: a layout with six gate jobs did exactly
# that. Check the count before adding a long job or a shard.
# - Scan `needs:` the gates and the four coverage jobs. A coverage job
# failing must stop the scan — a missing lcov file would make every line
# of that package report as uncovered, and the dashboard would show a
# collapse that did not happen.
# - Fork PRs and runs Dependabot triggers don't receive the repo's Actions
# secrets, so the two jobs that need them skip themselves: s3-contract
# (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). ci-ok counts a skip as
@@ -38,7 +88,7 @@ permissions:
# required check that never reports blocks the PR for good.
# - docker-image builds the Dockerfile and boots the result, and
# deploy-manifests lints and renders the Helm chart and checks the
# compose files. Both run beside the preflight rather than after it.
# compose files. Both run beside the gates rather than after them.
# - ci-ok `needs:` every gate, test, image and chart job, and is the one
# check the branch ruleset requires. It fails when any of them failed or
# was cancelled and passes when they passed or were skipped (by changes,
@@ -69,7 +119,7 @@ concurrency:
jobs:
changes:
name: Changed Areas
name: 'Repo: Changed Areas'
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -144,36 +194,8 @@ jobs:
node scripts/ci/changed-areas.mjs --all
fi
plugin-facts:
name: Plugin Permission Facts
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: i18n Key Parity
name: 'Shared: i18n Parity'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -189,8 +211,8 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
server-quality:
name: Server Types & Lint
server-types:
name: 'Server: Types'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -239,8 +261,11 @@ jobs:
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it.
# catches it. src and tests together are past 570,000 lines and need
# about 4.3 GB, over Node's default heap.
run: cd server && npm run typecheck:tests
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
@@ -257,6 +282,27 @@ jobs:
# migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types
server-lint-checks:
name: 'Server: Lint & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
@@ -276,6 +322,16 @@ jobs:
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
- name: Formatting check
# Prettier over src/ and tests/: a file outside
# scripts/format-baseline.json must be formatted, and the list only
@@ -369,12 +425,12 @@ jobs:
# knip (knip.jsonc) over client, server and shared: unused files,
# exports, types and dependencies per file may not rise above
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
# rather than in client-quality because it needs every workspace's
# dependencies, which only this job installs.
# rather than in client-types-checks because it needs every workspace's
# dependencies, which the client jobs do not install.
run: npm run lint:knip
client-quality:
name: Client Types & Lint
client-types-checks:
name: 'Client: Types & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -390,7 +446,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -405,25 +461,6 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings
- name: Page pattern check
run: cd client && npm run lint:pages
@@ -487,36 +524,13 @@ jobs:
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
run: cd client && npm run theme:lint
plugin-sdk-quality:
name: Plugin SDK Types
client-lint:
name: 'Client: Lint'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
client-bundle:
name: Client Bundle
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -527,7 +541,47 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings
client-bundle:
name: 'Client: Bundle'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -543,10 +597,11 @@ jobs:
run: cd client && npm run check:gl-split
client-e2e:
name: Client End-to-End
name: 'Client: End-to-End'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -600,17 +655,17 @@ jobs:
retention-days: 7
s3-contract:
name: S3 Driver Contract (MinIO)
name: 'Server: S3 Contract (MinIO)'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
needs: changes
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. A run Dependabot triggers gets
# only Dependabot secrets, so its PRs (same repo, not a fork) hit the same
# wall. Pushes and manual dispatches by anyone else do have secrets, so
# only those two are filtered out. (The implicit success() on this
# expression also keeps the job skipped when a preflight fails.)
# only those two are filtered out.
if: >-
needs.changes.outputs.code == 'true' &&
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
@@ -626,14 +681,14 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Build shared
run: npm run build --workspace=shared
@@ -691,10 +746,10 @@ jobs:
# artifact seeds the file and a change that fixes a statement can lower it
# without a local Postgres.
postgres-probe:
name: Postgres Probe
name: 'Server: Postgres Probe'
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
needs: changes
if: needs.changes.outputs.code == 'true'
services:
postgres:
@@ -747,11 +802,20 @@ jobs:
if-no-files-found: ignore
retention-days: 14
server-coverage:
name: Server Coverage
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
# without the thresholds and the coverage reports: a slice's blob carries
# its coverage to server-coverage, which checks the whole. fail-fast is off,
# so one red run shows every failing slice and not just the first.
server-tests:
name: 'Server: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: actions/checkout@v6
@@ -764,20 +828,63 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Run a slice of the tests with coverage
run: >-
cd server && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: server-blob-${{ matrix.shard }}
path: server/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
server-coverage:
name: 'Server: Coverage'
needs: server-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: server-blob-*
path: server/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd server && npx vitest run --merge-reports --coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -786,11 +893,18 @@ jobs:
path: server/coverage/
retention-days: 1
client-coverage:
name: Client Coverage
# The client suite in seven slices, the same way as server-tests. The two
# suites cost about the same per slice at seven each (~4-6 minutes).
client-tests:
name: 'Client: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: actions/checkout@v6
@@ -805,13 +919,56 @@ jobs:
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Run a slice of the tests with coverage
run: >-
cd client && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: client-blob-${{ matrix.shard }}
path: client/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
client-coverage:
name: 'Client: Coverage'
needs: client-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: client-blob-*
path: client/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd client && npx vitest run --merge-reports --coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -821,10 +978,11 @@ jobs:
retention-days: 1
shared-coverage:
name: Shared Coverage
name: 'Shared: Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -847,11 +1005,14 @@ jobs:
path: shared/coverage/
retention-days: 1
plugin-sdk-coverage:
name: Plugin SDK Coverage
# Typecheck and tests in one job: each takes seconds, and a runner of its
# own would cost more in setup than it saves.
plugin-sdk:
name: 'Plugin SDK: Types & Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
@@ -870,6 +1031,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run tests with coverage
run: npm run test:coverage
@@ -881,7 +1045,7 @@ jobs:
retention-days: 1
scan:
name: Scan
name: 'Repo: Sonar Scan'
runs-on: ubuntu-24.04
timeout-minutes: 45
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
@@ -891,9 +1055,18 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
# show a collapse that did not happen. The gates run beside the tests now,
# so they are listed here too, and a failed gate still stops the scan.
needs:
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-coverage
- client-coverage
- shared-coverage
- plugin-sdk
steps:
- uses: actions/checkout@v6
@@ -954,7 +1127,7 @@ jobs:
# reached dev unseen. It is separate from the vulnerability scan on purpose,
# so a newly published CVE cannot hide a broken build or the other way round.
docker-image:
name: Docker Image
name: 'Repo: Docker Image'
needs: changes
if: needs.changes.outputs.image == 'true'
runs-on: ubuntu-24.04
@@ -1031,7 +1204,7 @@ jobs:
# the output checked against the Kubernetes API schemas. Both tools come
# from their release pages, pinned by version and checksum.
deploy-manifests:
name: Helm Chart & Compose
name: 'Repo: Helm Chart & Compose'
needs: changes
if: needs.changes.outputs.deploy == 'true'
runs-on: ubuntu-24.04
@@ -1102,19 +1275,21 @@ jobs:
ci-ok:
needs:
- changes
- plugin-facts
- i18n-parity
- server-quality
- client-quality
- plugin-sdk-quality
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- client-bundle
- client-e2e
- s3-contract
- postgres-probe
- server-tests
- server-coverage
- client-tests
- client-coverage
- shared-coverage
- plugin-sdk-coverage
- plugin-sdk
- docker-image
- deploy-manifests
# always(): without it a failed dependency would skip this job, and the
+2
View File
@@ -55,6 +55,8 @@ coverage
coverage-*/
*.lcov
.nyc_output
# Blob reports of a sharded vitest run (test.yml)
.vitest-reports
# Cache
.npm
+3 -1
View File
@@ -134,8 +134,10 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-lint-checks), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **Job names read "package: what"** (`Server: Types`, `Client: Tests 3/7`), and the list at the top of `test.yml` says what each job holds. A new job follows the scheme and joins that list, and the job-count note there says when a long job needs a slot freed first.
- **Tests run beside the gates, in shards.** The client and server suites run in slices on parallel runners (`client-tests`, `server-tests`, through each package's `vitest.shard.config.ts`), and `client-coverage`/`server-coverage` merge their blob reports and check the thresholds against the whole. A red slice is usually a failing test, a red merge job usually a threshold.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
+2 -2
View File
@@ -102,8 +102,8 @@ env:
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
+1 -1
View File
@@ -21,7 +21,7 @@ in a script.
| Result picture | `guide.media.result` | the full frame after the last step, pointer parked in the corner |
| Hero (screen overview) | `captureHero` | the full frame of the screen as the reader finds it |
| Output | `promote.mjs` | PNG → WebP quality 84; steps 1440 px wide, heroes and results 1920 px; into `public/help-media/<guide-id>/step-<n>.webp`, `result.webp`, `ctx/<screen-id>.webp` |
| No videos | | stills only, by decision |
| Loops | `StepAction.clip`, `dragLoop`, `promote.mjs` | stills are the format. A step whose motion a still cannot show (a route being drawn, a shaping point pulled, a flyover, the compass turning) also gets a short silent loop: frames of the still's window with the step's number and a drawn pointer, joined into `step-<n>.loop.webp` (1440 px, 80 ms a frame, the last held 1.6 s). Register the step under `clips` in the context row; only those are recorded. A browser drag (day plan, lists, road trip) stays a still with its arrow, since a recording leaves the dragged row out. The panel plays it in place of the still, except under reduced motion. No videos with sound, no screen recordings |
The app's own appearance is the default theme, English, light mode, the seeded
demo data (`e2e/screenshots/seed.ts`) plus the fixtures in `fixtures.ts`. Do not
+135 -12
View File
@@ -2,6 +2,7 @@ import { test, expect, type Page, type Locator } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureAdminFixtures, pluginPackage } from './fixtures'
import { deleteUser, resetDefaults } from './fixtures-admin'
import { toggleAddon } from './external'
import { adminContext, adminTabContexts, adminGuides } from '../../src/help/contexts/admin'
import type { HelpGuide } from '../../src/help/types'
@@ -44,6 +45,12 @@ const block = (page: Page, title: string) => page.locator('h3').filter({ hasText
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** A row of the user table by its username. */
const userRow = (page: Page, username: string) => page.locator('table tbody tr').filter({ hasText: username }).first()
/** A labelled field of the Map card on User Defaults: its label and everything under it. */
const mapField = (page: Page, label: string) => section(page, /^Map$/).locator('label').filter({ hasText: label }).first().locator('xpath=..')
/** The Map engine row's label, which also holds its reset link once the engine is set. */
const engineLabel = (page: Page) => section(page, /^Map$/).locator('label').filter({ hasText: 'Map engine' }).first()
/** The TREK Places API card at the top of API Keys, found by its pill. */
const trekApiCard = (page: Page) => page.getByText('Recommended default', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"rounded-[14px]")][1]')
/** An addon tile by the addon's name. */
const tile = (page: Page, name: string) => page.locator('h4').filter({ hasText: new RegExp(`^${name}$`) }).first().locator('xpath=ancestor::article[1]')
@@ -97,6 +104,8 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
// Back to the seed's two accounts, so the Users hero and the next run look the same.
cleanup: p => deleteUser(p.request, 'lena'),
},
'edit-user': {
guide: guide('edit-user'),
@@ -181,11 +190,11 @@ const SCRIPTS: Record<string, GuideScript> = {
},
// ── User Defaults ────────────────────────────────────────────────────────
'default-map': {
guide: guide('default-map'),
'default-user-settings': {
guide: guide('default-user-settings'),
start: opener('admin-defaults'),
steps: [
only(p => section(p, /^Map$/)),
only(p => section(p, 'Default User Settings')),
{
// The reset link only exists next to a field an admin has set.
prepare: async p => {
@@ -197,10 +206,72 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => section(p, 'Default User Settings').locator('label').filter({ hasText: 'Color Mode' }).locator('xpath=..'),
act: async p => {
await section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ }).first().click()
await expect(section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ })).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
],
cleanup: p => resetDefaults(p.request, ['dark_mode']),
},
'default-map': {
guide: guide('default-map'),
start: opener('admin-defaults'),
steps: [
// The field and the CARTO key under it; the 16:10 frame takes in both.
only(p => mapField(p, 'Map Template')),
{
// Mapbox draws every row the step names (style, token, 3D, quality).
prepare: async p => {
await section(p, /^Map$/).getByRole('button', { name: 'Mapbox (3D)', exact: true }).click()
await expect(section(p, /^Map$/).getByText('High-quality mode', { exact: true })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: p => section(p, /^Map$/).getByRole('button', { name: 'Mapbox (3D)', exact: true }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]'),
},
{
// The engine was set in step 2, so its label now carries the reset link.
prepare: async p => {
await expect(engineLabel(p).locator('button').filter({ hasText: /^reset$/ })).toBeVisible({ timeout: 10_000 })
},
target: p => engineLabel(p),
act: async p => {
await engineLabel(p).locator('button').filter({ hasText: /^reset$/ }).click()
await expect(engineLabel(p).locator('button').filter({ hasText: /^reset$/ })).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
],
cleanup: p => resetDefaults(p.request, ['map_provider', 'mapbox_style', 'maplibre_style']),
},
'own-routing-engine': {
guide: guide('own-routing-engine'),
start: opener('admin-defaults'),
// Typed, never committed: the field saves on blur only when the value changed,
// and emptying it first leaves the saved value (none) as it was.
steps: [
{
prepare: async p => {
await typeInto(p, p.getByLabel('Own routing engine', { exact: true }), 'https://osrm.example.org')
await beat(p, 300)
},
target: p => p.getByLabel('Own routing engine', { exact: true }).locator('xpath=..'),
act: async p => {
await p.getByLabel('Own routing engine', { exact: true }).fill('')
await p.getByLabel('Own routing engine', { exact: true }).blur()
},
},
{
prepare: async p => {
await typeInto(p, p.getByLabel('Own Valhalla instance', { exact: true }), 'https://valhalla.example.org')
await beat(p, 300)
},
target: p => p.getByLabel('Own Valhalla instance', { exact: true }).locator('xpath=..'),
act: async p => {
await p.getByLabel('Own Valhalla instance', { exact: true }).fill('')
await p.getByLabel('Own Valhalla instance', { exact: true }).blur()
},
},
],
},
// ── Personalization ──────────────────────────────────────────────────────
@@ -209,15 +280,19 @@ const SCRIPTS: Record<string, GuideScript> = {
start: opener('admin-config'),
steps: [
{
target: p => section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }),
act: async p => {
// The composer with the name typed and the tick beside it.
prepare: async p => {
await section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }).click()
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
await expect(input).toBeVisible()
await typeInto(p, input, 'City Break')
await beat(p, 300)
// Leave the seed as it is: the second icon next to the field discards the draft.
await input.locator('xpath=..').locator('button').nth(1).click()
},
target: p => p.getByPlaceholder('Template name (e.g. Beach Holiday)').locator('xpath=..'),
act: async p => {
// Leave the seed as it is: Cancel beside the field discards the draft.
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
await input.locator('xpath=..').getByRole('button', { name: 'Cancel' }).click()
await expect(input).toHaveCount(0)
},
},
@@ -250,10 +325,12 @@ const SCRIPTS: Record<string, GuideScript> = {
await expect(p.getByPlaceholder('Category name')).toHaveCount(0)
},
},
// Edit and Delete are always on the row; the pointer rests on Edit.
{
target: p => section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"group")][1]'),
hover: async p => {
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().hover()
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first()
.locator('xpath=ancestor::div[contains(@class,"group")][1]').getByRole('button', { name: 'Edit' }).hover()
},
},
],
@@ -301,6 +378,15 @@ const SCRIPTS: Record<string, GuideScript> = {
only(p => section(p, 'Require two-factor authentication (2FA)')),
],
},
'admin-passkeys': {
guide: guide('admin-passkeys'),
start: opener('admin-settings'),
steps: [
// Not flipped: the switch saves at once, and the seed keeps passkeys off.
only(p => section(p, 'Passkey login').getByRole('button', { name: 'Enable passkey login' }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
only(p => section(p, 'Passkey login')),
],
},
oidc: {
guide: guide('oidc'),
start: opener('admin-settings'),
@@ -313,14 +399,15 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('instance-keys'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Google Maps API Key')),
only(p => trekApiCard(p)),
{
prepare: async p => {
const details = p.locator('details').filter({ hasText: 'What the key may be used for' })
if (!(await details.evaluate(el => (el as HTMLDetailsElement).open))) await details.locator('summary').click()
await settle(p)
},
target: p => p.locator('details').filter({ hasText: 'What the key may be used for' }),
// The whole Google block: key, Test and the fold opened under it.
target: p => block(p, 'Google Maps API Key'),
},
only(p => block(p, 'Unsplash API Key')),
],
@@ -329,8 +416,8 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('places-transit'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Place search provider')),
only(p => block(p, 'Transit Provider')),
only(p => block(p, 'Place search provider')),
],
},
'file-types': {
@@ -340,6 +427,29 @@ const SCRIPTS: Record<string, GuideScript> = {
only(p => section(p, 'Allowed File Types')),
],
},
'rotate-jwt-secret': {
guide: guide('rotate-jwt-secret'),
start: opener('admin-settings'),
steps: [
{
target: p => section(p, 'Danger Zone').getByRole('button', { name: 'Rotate', exact: true }),
act: async p => {
await section(p, 'Danger Zone').getByRole('button', { name: 'Rotate', exact: true }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"], > div').first(),
// Never confirmed: rotating would sign the run itself out.
hover: async p => { await modal(p).getByRole('button', { name: 'Rotate & Log out' }).hover() },
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
},
},
],
},
// ── Addons ───────────────────────────────────────────────────────────────
'toggle-addon': {
@@ -452,6 +562,8 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
only(p => section(p, /^Categories$/)),
// Nothing changed, so the button is still greyed; the bar is what the step names.
only(p => p.getByRole('button', { name: 'Save changes' }).locator('xpath=..')),
only(p => section(p, /^Health$/)),
],
},
@@ -472,6 +584,17 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => section(p, 'Admin Ntfy')),
only(p => section(p, 'Admin Ntfy').getByRole('button', { name: 'Send test ntfy' })),
// The admin matrix: the card whose title is the tab's own name.
only(p => section(p, /^Notifications$/)),
],
},
'notification-defaults': {
guide: guide('notification-defaults'),
start: opener('admin-notifications'),
steps: [
only(p => p.locator('#notif-defaults')),
// Hovered, not clicked: the tooltip names the next state and the seed keeps its defaults.
only(p => p.locator('#notif-defaults button[data-state]').first()),
],
},
@@ -480,8 +603,8 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('mcp-tokens-admin'),
start: opener('admin-mcp-tokens'),
steps: [
only(p => p.locator('h2, h3').filter({ hasText: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
only(p => p.locator('h2, h3').filter({ hasText: 'OAuth Sessions' }).first().locator('xpath=ancestor::section[1]')),
only(p => p.locator('h2, h3').filter({ hasText: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
],
},
+164 -1
View File
@@ -12,7 +12,8 @@ import type { HelpGuide } from '../../src/help/types'
* Actions for the Atlas guides, keyed by the ids in `src/help/contexts/atlas.ts`.
* They run in the order the context lists them and leave their marks on the
* map on purpose: Greece is marked and removed again, a region of Austria and
* Bavaria stay, Iceland and Petra land on the bucket list. The countries
* Bavaria stay, Iceland and Petra land on the bucket list; the tour left out
* in the Tours tab is counted again in `cleanup`. The countries
* are drawn on a canvas, so the way in is always the search box: it flies to
* the country and opens the same popup a click on the map would.
*/
@@ -208,6 +209,33 @@ async function regionTooltipBox(page: Page, country: string): Promise<Box> {
return box
}
/** The Places badge in a country's detail card ("7 Places"), which opens the places dialog. */
const placesBadge = (page: Page, country: string) =>
detailCard(page, country).getByRole('button', { name: /^\d+ Places?$/ })
/** The dialog of a country's places, named after the country. */
const placesDialog = (page: Page, country: string) => page.getByRole('dialog', { name: country })
/** The Tours tab of the panel (Tours addon), and the tour activities it shows. */
const toursTab = (page: Page) => panel(page).getByRole('button', { name: 'Tours', exact: true })
const tourPane = (page: Page) => page.getByTestId('atlas-tour-activity')
/** The Period pill: its name is the sr-only "Period: " before the value. */
const periodPill = (scope: Locator) => scope.getByRole('button', { name: /^Period:/ })
/** The open list of a pill, portalled to the body and named after what it picks. */
const pillList = (page: Page, label: 'Period' | 'Type') => page.getByRole('group', { name: label, exact: true })
const showToursButton = (page: Page) => tourPane(page).getByRole('button', { name: 'Show tours', exact: true })
const tourDialog = (page: Page) => page.getByRole('dialog', { name: 'My tour activities' })
const firstTourRow = (page: Page) => tourDialog(page).getByTestId('tour-activity-list').locator('> li').first()
/** The tours the user has left out of their activities, by place id. */
async function excludedTours(page: Page): Promise<Set<number>> {
const res = await page.request.get('/api/tours/activity-stats')
if (!res.ok()) throw new Error(`could not read the tour activities: ${res.status()}`)
const { entries } = (await res.json()) as { entries: { place_id: number; excluded: boolean }[] }
return new Set(entries.filter(e => e.excluded).map(e => e.place_id))
}
/** What was left out before the tour guide ran, so `cleanup` counts exactly its own change again. */
let excludedBefore = new Set<number>()
const SCRIPTS: Record<string, GuideScript> = {
'mark-country': {
guide: guide('mark-country'),
@@ -283,6 +311,40 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'country-places': {
guide: guide('country-places'),
start: openAtlas,
steps: [
{
prepare: async p => { await searchCountry(p, 'Japan') },
target: p => countryHit(p, 'Japan'),
act: async p => {
await pickCountry(p, 'Japan')
await expect(placesBadge(p, 'Japan')).toBeVisible()
},
},
{
target: p => placesBadge(p, 'Japan'),
act: async p => {
await placesBadge(p, 'Japan').click()
await expect(placesDialog(p, 'Japan')).toBeVisible()
await settle(p)
},
},
{
// The whole dialog: the step names the groups by trip, the search
// above them when the list is long, and the trip heading to click.
target: p => placesDialog(p, 'Japan'),
hover: p => placesDialog(p, 'Japan').getByRole('button', { name: /Autumn in Japan/ }).first().hover(),
},
],
cleanup: async p => {
if (await placesDialog(p, 'Japan').count()) {
await placesDialog(p, 'Japan').getByRole('button', { name: 'Close', exact: true }).click()
}
},
},
'planned-countries': {
guide: guide('planned-countries'),
start: openAtlas,
@@ -482,6 +544,103 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'tour-activity-stats': {
guide: guide('tour-activity-stats'),
start: async p => {
await openAtlas(p)
excludedBefore = await excludedTours(p)
},
steps: [
{
target: toursTab,
act: async p => {
await toursTab(p).click()
// The tab reads live once it is first shown.
await expect(tourPane(p).getByTestId('atlas-tour-activity-distance')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// The two pills with the period list open under the first: the step
// names the periods, and they only show while the list is open.
prepare: async p => {
await periodPill(tourPane(p)).click()
await expect(pillList(p, 'Period')).toBeVisible()
await spanOver(p, [await boxOf(periodPill(tourPane(p)).locator('xpath=..')), await boxOf(pillList(p, 'Period'))])
},
target: span,
hover: p => pillList(p, 'Period').getByRole('button', { name: 'Last 12 months', exact: true }).hover(),
act: async p => {
await clearSpan(p)
// All time keeps every tour of the seed in the sums the next steps read.
await pillList(p, 'Period').getByRole('button', { name: 'All time', exact: true }).click()
await expect(pillList(p, 'Period')).toHaveCount(0)
},
},
{
// The distance with its count, the climb and the type columns are
// siblings without a box of their own, so the ring gets one over them.
prepare: async p => {
const pane = tourPane(p)
const parts = [
pane.getByTestId('atlas-tour-activity-distance').locator('xpath=..'),
pane.getByTestId('atlas-tour-activity-ascent'),
pane.getByTestId('atlas-tour-activity-descent'),
...(await pane.locator('[data-testid^="atlas-tour-activity-type-"]').all()),
]
const boxes: Box[] = []
for (const part of parts) {
const box = (await part.count()) ? await part.first().boundingBox() : null
if (box) boxes.push(box)
}
await spanOver(p, boxes)
},
target: span,
// The badge at the distance's corner names the tour count in its tooltip.
hover: async p => {
const count = tourPane(p).getByTestId('atlas-tour-activity-count')
await ((await count.count()) ? count : tourPane(p).getByTestId('atlas-tour-activity-distance')).hover()
},
act: clearSpan,
},
{
// The side column: the Planned and Recorded badges, what does not
// count yet, and the i whose tooltip states the rule.
target: p => showToursButton(p).locator('xpath=..'),
hover: p => tourPane(p).getByRole('button', { name: 'My tour activities', exact: true }).hover(),
},
{
target: showToursButton,
act: async p => {
await showToursButton(p).click()
await expect(tourDialog(p).getByTestId('tour-activity-list')).toBeVisible()
await settle(p)
},
},
{
target: p => firstTourRow(p),
hover: p => firstTourRow(p).locator('[data-testid^="tour-activity-toggle-"]').hover(),
act: async p => {
// Found again by its own id afterwards: the list may sort a left-out tour elsewhere.
const id = await firstTourRow(p).getAttribute('data-testid')
await firstTourRow(p).getByRole('button', { name: 'Leave out', exact: true }).click()
const row = tourDialog(p).getByTestId(id ?? '')
await expect(row.getByText('Left out', { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(row.getByRole('button', { name: 'Count again', exact: true })).toBeEnabled()
await settle(p)
},
},
],
cleanup: async p => {
// Count again every tour this guide left out, by the route the button uses.
for (const placeId of await excludedTours(p)) {
if (!excludedBefore.has(placeId)) {
await p.request.put('/api/tours/activity-stats/exclusions', { data: { place_id: placeId, excluded: false } })
}
}
},
},
'dawarich-countries': {
guide: guide('dawarich-countries'),
start: async p => {
@@ -609,6 +768,10 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureAtlasFixtures(request)
// Planned and recorded tours of several types on past, dated days, for the
// Tours tab of the panel. Loaded lazily: the file belongs to the Tours guides.
const { ensureToursFixtures } = await import('./tour-fixtures')
await ensureToursFixtures(request)
// After the Atlas fixtures: the connection adds two wishes with coordinates
// (Fushimi Inari Taisha, Wadi Rum) to the list the fixtures create.
await ensureDawarichConnection(request)
+223 -10
View File
@@ -1,8 +1,13 @@
import { test, expect, type Page, type Locator, type Request } from '@playwright/test'
import path from 'node:path'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, nudgeClock, VIEWPORT, OUT_DIR, type GuideScript } from './guide'
import { ensureExtraTrips } from './fixtures'
import { ensureToursFixtures } from './tour-fixtures'
import {
ensureTourTargetTrip, ensureSecondList, deleteList, sendInviteToMe, withdrawInvitedList,
TOUR_TARGET_TRIP, SECOND_LIST, INVITED_LIST,
} from './fixtures-collections'
import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections'
import type { HelpGuide } from '../../src/help/types'
@@ -13,12 +18,17 @@ import type { HelpGuide } from '../../src/help/types'
* second list, a Kyoto place found by search, three more Kyoto temples taken
* from the trip, a label, a member and a file round trip, in the order the
* context lists them, and leave the list side of it in place. Whatever they put
* into a trip comes out again in that guide's cleanup.
* into a trip comes out again in that guide's cleanup. The two Tours guides at
* the end work on the tour the Tours fixtures keep in "Dolomites ideas".
*/
const KYOTO = 'Kyoto shortlist'
const LISBON = 'Lisbon coffee'
const JAPAN = 'Autumn in Japan'
/** The list the Tours fixtures keep a whole tour in. */
const DOLOMITES = 'Dolomites ideas'
/** The two temples the duplicate guide copies into a second list. */
const DUPLICATED = ['Kinkaku-ji Temple', 'Kiyomizu-dera Temple']
/**
* Kyoto places the import guide puts into the Japan trip first. The seed's trip
@@ -59,9 +69,21 @@ const importTrip = (page: Page, title: string) => modal(page).getByRole('button'
* is on screen.
*/
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
const placeRows = (page: Page) => page.locator('.col-lrow')
/**
* A place row. Kept tours wear the row class too and come first, so they are
* left out here; `tourRows` finds them.
*/
const placeRows = (page: Page) => page.locator('.col-lrow:not(.col-lrow-tour)')
const placeRow = (page: Page, name: string) => placeRows(page).filter({ hasText: name }).first()
/** The whole tours a list keeps, the first rows above its places. */
const tourRows = (page: Page) => page.locator('.col-lrow-tour')
/** The search box of the open list: on the map where there is one, above the list where there is none. */
const listSearch = (page: Page) => page.locator('.col-map-search:visible, .col-search:visible').first()
const selectionBar = (page: Page) => page.locator('.col-selbar')
/** The pending invite to the list Mira shares, in the lists rail. */
const inviteCard = (page: Page) => rail(page).locator('.col-invite').filter({ hasText: INVITED_LIST.name })
/** The target trip's row under Into the trip in the take-over dialog. */
const takeOverTrip = (page: Page) => modal(page).getByRole('radio').filter({ hasText: TOUR_TARGET_TRIP })
/** The detail sheet beside the list. */
const detail = (page: Page) => page.locator('.col-detail').first()
/** The map beside the list, with its floating controls. */
@@ -94,6 +116,9 @@ async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Prom
}
}
/** The tour the take-over made in the target trip, removed again in its cleanup. */
let takenTour: { tripId: number; placeId: number } | null = null
/** The places the import guide put into the Japan trip, removed again in its cleanup. */
let addedToTrip: number[] = []
/** The Japan trip's places before the copy guide ran; everything else is the copy's. */
@@ -116,14 +141,12 @@ async function blurFocus(page: Page): Promise<void> {
* Leaflet times a pan from `+new Date()`, and the browser's clock is pinned to
* the picture day (guide.ts), so a pan started for a picture never gets past its
* first frame: the map stays where it was and the place the text says it pans
* to is not in view. Moving the fixed time on for a moment lets the running
* animation see that its time is up and finish, exactly where it would have
* ended; the clock goes back to the picture day straight after.
* to is not in view. Moving the fixed time on lets the running animation see
* that its time is up and finish, exactly where it would have ended.
*/
async function finishMapMotion(page: Page): Promise<void> {
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000))
await nudgeClock(page, 10_000)
await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))))
await page.clock.setFixedTime(PICTURE_DAY)
}
/**
@@ -263,7 +286,7 @@ async function selectPlaces(page: Page, names: string[]): Promise<void> {
/** Pick an option in a CustomSelect that has a search box. */
async function pickSearchable(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
const search = page.getByPlaceholder('...')
const search = page.getByPlaceholder(/^(\.\.\.|Search)$/)
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
await search.locator('xpath=../..').getByRole('button', { name: text, exact: true }).first().click()
@@ -568,6 +591,53 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
'move-to-list': {
guide: guide('move-to-list'),
start: async p => {
await ensureSecondList(p.request)
await openList(p, KYOTO)
},
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await selectPlaces(p, DUPLICATED)
await beat(p, 300)
},
},
{
// Duplicate rather than Move: the two temples stay on the list the
// guides after this one picture.
target: p => selectionBar(p).getByRole('button', { name: 'Duplicate to list' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Duplicate to list' }).click()
await expect(modal(p)).toBeVisible()
await expect(modal(p).getByRole('button').filter({ hasText: SECOND_LIST.name })).toBeVisible()
// A long list of lists starts with its search focused.
await blurFocus(p)
await settle(p)
},
},
{
target: modalCard,
act: async p => {
await modal(p).getByRole('button').filter({ hasText: SECOND_LIST.name }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await listRow(p, SECOND_LIST.name).click()
await expect(hero(p)).toContainText(SECOND_LIST.name)
await expect(p.locator('.col-loading')).toHaveCount(0)
for (const name of DUPLICATED) await expect(placeRow(p, name)).toBeVisible()
// A list that had no places mounts its map now, on the window's camera.
await pageReady(p, { reframe: true })
},
},
],
cleanup: async p => {
await deleteList(p.request, SECOND_LIST.name)
},
},
'copy-to-trip': {
guide: guide('copy-to-trip'),
start: async p => {
@@ -649,6 +719,37 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'accept-list-invite': {
guide: guide('accept-list-invite'),
start: async p => {
// Mira makes a list of her own and invites the demo account before the page loads.
await sendInviteToMe(p.request)
await openCollections(p)
await expect(inviteCard(p)).toBeVisible({ timeout: 15_000 })
},
steps: [
{
target: inviteCard,
},
{
target: p => inviteCard(p).getByRole('button', { name: 'Accept' }),
act: async p => {
await inviteCard(p).getByRole('button', { name: 'Accept' }).click()
await expect(listRow(p, INVITED_LIST.name)).toBeVisible({ timeout: 15_000 })
await listRow(p, INVITED_LIST.name).click()
await expect(hero(p)).toContainText(INVITED_LIST.name)
await expect(p.locator('.col-loading')).toHaveCount(0)
for (const place of INVITED_LIST.places) await expect(placeRow(p, place.name)).toBeVisible()
await pageReady(p, { reframe: true })
},
},
],
cleanup: async () => {
// Her list goes again, and with it the demo account's membership.
await withdrawInvitedList()
},
},
'export-list': {
guide: guide('export-list'),
start: p => openList(p, KYOTO),
@@ -760,6 +861,118 @@ const SCRIPTS: Record<string, GuideScript> = {
},
],
},
'tour-collection-add-to-trip': {
guide: guide('tour-collection-add-to-trip'),
start: async p => {
await ensureToursFixtures(p.request)
await ensureTourTargetTrip(p.request)
await openList(p, DOLOMITES)
await expect(tourRows(p).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => tourRows(p).first().getByRole('button', { name: 'Add to a trip' }),
act: async p => {
await tourRows(p).first().getByRole('button', { name: 'Add to a trip' }).click()
await expect(modal(p)).toBeVisible()
await expect(takeOverTrip(p)).toBeVisible({ timeout: 15_000 })
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByRole('radiogroup', { name: 'Into the trip' }),
act: async p => {
await takeOverTrip(p).click()
await expect(takeOverTrip(p)).toHaveAttribute('aria-checked', 'true')
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByRole('radiogroup', { name: 'Waypoints' }),
act: async p => {
// With its waypoints where the kept tour has any; Line only otherwise.
const withPoints = modal(p).getByTestId('kept-tour-points-with')
const choice = (await withPoints.isEnabled()) ? withPoints : modal(p).getByTestId('kept-tour-points-without')
await choice.click()
await expect(choice).toHaveAttribute('aria-checked', 'true')
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByTestId('kept-tour-confirm'),
act: async p => {
const tripId = await tripIdOf(p, TOUR_TARGET_TRIP)
const answer = p.waitForResponse(r => r.url().includes('/tours/from-collection') && r.request().method() === 'POST')
await modal(p).getByTestId('kept-tour-confirm').click()
const res = await answer
if (!res.ok()) throw new Error(`the take-over answered ${res.status()}: ${await res.text()}`)
const body = (await res.json()) as { tour: { place_id: number } }
takenTour = { tripId, placeId: body.tour.place_id }
await expect(modal(p).getByRole('button', { name: 'Open trip' })).toBeVisible({ timeout: 15_000 })
await blurFocus(p)
await settle(p)
},
},
],
cleanup: async p => {
if (await modal(p).count()) await p.keyboard.press('Escape')
// The new tour leaves the trip again, so the trip screens keep their seed.
if (takenTour) {
await deleteTripPlaces(p, takenTour.tripId, [takenTour.placeId])
takenTour = null
}
},
},
'tour-collection-manage': {
guide: guide('tour-collection-manage'),
start: async p => {
await ensureToursFixtures(p.request)
await openList(p, DOLOMITES)
await expect(tourRows(p).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => tourRows(p).first(),
},
{
target: listSearch,
act: async p => {
const name = ((await tourRows(p).first().locator('.t').textContent()) ?? '').trim()
const word = name.split(/\s+/)[0] ?? ''
if (!word) throw new Error('the kept tour has no name to search for')
await typeInto(p, listSearch(p).locator('input'), word)
await expect(tourRows(p).first()).toBeVisible()
await blurFocus(p)
await settle(p)
},
},
{
target: p => tourRows(p).first().locator('[data-testid^="kept-tour-remove-"]'),
act: async p => {
const before = await tourRows(p).count()
await tourRows(p).first().locator('[data-testid^="kept-tour-remove-"]').click()
// The bin carries the name as its label; the dialog's button carries it as text.
const confirm = p.getByRole('button', { name: 'Remove from the collection' }).filter({ hasText: 'Remove from the collection' })
await expect(confirm).toBeVisible()
await confirm.click()
await expect(tourRows(p)).toHaveCount(before - 1, { timeout: 15_000 })
await listSearch(p).locator('input').fill('')
await pageReady(p)
},
},
],
cleanup: async p => {
// The tour goes back into the list for the next run and the Tours screens.
await ensureToursFixtures(p.request)
},
},
}
/** The list file the export guide downloads and the import guide reads back. */
+189 -16
View File
@@ -3,8 +3,10 @@ import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { monthName, monthsAhead, pickerLabelOn } from '../dates'
import { ensureExtraTrips, coverFixture, card, EXTRA_TRIPS } from './fixtures'
import { ensureDashboardFixtures, tripIdByTitle, LISBON_FLIGHT } from './fixtures-dashboard'
import { dashboardGuides, dashboardContext } from '../../src/help/contexts/dashboard'
import type { HelpGuide } from '../../src/help/types'
import { coverPhoto } from '../screenshots/photos'
/**
* Actions for the dashboard guides. Each script is keyed by the guide id from
@@ -54,7 +56,7 @@ async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, mont
await page.getByRole('button', { name: dayLabel, exact: true }).click()
}
/** Hover-revealed action on a card. */
/** One of the four actions in the top-right corner of a card's photo. */
const cardAction = (page: Page, title: string, name: string) => card(page, title).locator(`.trip-action-btn[aria-label="${name}"]`)
/** The trip the create guide makes, eight months out, named after the month it lands in. */
@@ -64,6 +66,35 @@ const NEW_TRIP = `Amalfi Coast in ${monthName(8)}`
const tripSearch = (page: Page) => page.getByRole('searchbox', { name: 'Search all trips by title, date or place' })
const JAPAN = 'Autumn in Japan'
/** The trip the date-less guide makes and its cleanup drops again. */
const IDEA_TRIP = 'Patagonia, someday'
/** A field block of the trip form by the label above it (the label is not tied to its input). */
const formBlock = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).locator('xpath=..')
/** A widget in the right column by its heading. */
const widget = (page: Page, title: string) => page.locator('.tool').filter({ has: page.locator('.tool-title', { hasText: title }) })
/** Drop every trip with this exact title over the API, so the grid is back to the seed. */
async function dropTrips(page: Page, title: string): Promise<void> {
const res = await page.request.get('/api/trips')
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
const trips = Array.isArray(body) ? body : (body.trips ?? [])
for (const t of trips.filter(x => x.title === title)) {
const dropped = await page.request.delete(`/api/trips/${t.id}`)
if (!dropped.ok()) throw new Error(`could not drop "${t.title}": ${dropped.status()}`)
}
}
/** The Norway trip's reminder before the reminder guide changed it, put back by its cleanup. */
let norwayReminder = 0
/** The Lisbon flight in the Upcoming reservations widget (fixtures-dashboard.ts). */
const upcomingRow = (page: Page) => widget(page, 'Upcoming reservations').locator('.upc-item').filter({ hasText: LISBON_FLIGHT })
/** A clock in the Timezones widget by its city. */
const clockRow = (page: Page, city: string) => widget(page, 'Timezones').locator('.tz-row').filter({ hasText: city })
const SCRIPTS: Record<string, GuideScript> = {
'search-trips': {
guide: guide('search-trips'),
@@ -129,6 +160,43 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
'dateless-trip': {
guide: guide('dateless-trip'),
start: async p => {
await dropTrips(p, IDEA_TRIP)
await openDashboard(p)
},
steps: [
{
target: p => p.locator('.add-trip-card'),
act: async p => {
await p.locator('.add-trip-card').click()
await expect(modal(p)).toBeVisible()
await typeInto(p, titleField(p), IDEA_TRIP)
},
},
{
// Only there while both dates are empty, which they are on a new trip.
target: p => formBlock(p, 'Number of Days'),
act: async p => {
await formBlock(p, 'Number of Days').locator('input').fill('10')
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create New Trip' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create New Trip' }).click()
await expect(card(p, IDEA_TRIP)).toBeVisible({ timeout: 15_000 })
await expect(card(p, IDEA_TRIP).getByText('Open dates')).toBeVisible()
await card(p, IDEA_TRIP).scrollIntoViewIfNeeded()
await settle(p)
},
},
],
cleanup: async p => { await dropTrips(p, IDEA_TRIP) },
},
'edit-trip': {
guide: guide('edit-trip'),
start: openDashboard,
@@ -156,7 +224,13 @@ const SCRIPTS: Record<string, GuideScript> = {
'cover-image': {
guide: guide('cover-image'),
start: openDashboard,
// The showcase gives Lisbon its photo; the guide starts from a trip without
// one and puts the same photo back, so the next guide sees the seed again.
start: async p => {
const id = await tripIdByTitle(p.request, LISBON)
await p.request.put(`/api/trips/${id}`, { data: { cover_image: null } })
await openDashboard(p)
},
steps: [
{
target: p => cardAction(p, LISBON, 'Edit'),
@@ -165,7 +239,7 @@ const SCRIPTS: Record<string, GuideScript> = {
{
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
act: async p => {
await modal(p).locator('input[type="file"]').setInputFiles(await coverFixture())
await modal(p).locator('input[type="file"]').setInputFiles((await coverPhoto('lisbon')) ?? (await coverFixture()))
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
},
},
@@ -181,6 +255,48 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'trip-reminder': {
guide: guide('trip-reminder'),
start: async p => {
const id = await tripIdByTitle(p.request, NORWAY)
const res = await p.request.get(`/api/trips/${id}`)
const body = (await res.json()) as { trip?: { reminder_days?: number }; reminder_days?: number }
norwayReminder = body.trip?.reminder_days ?? body.reminder_days ?? 0
await openDashboard(p)
},
steps: [
{
target: p => cardAction(p, NORWAY, 'Edit'),
act: async p => { await cardAction(p, NORWAY, 'Edit').click(); await expect(modal(p)).toBeVisible() },
},
{
// The presets only show while trip reminders are on (ensureDashboardFixtures).
prepare: async p => {
await expect(formBlock(p, 'Reminder').getByRole('button', { name: 'Custom' })).toBeVisible()
},
target: p => formBlock(p, 'Reminder'),
act: async p => {
await formBlock(p, 'Reminder').getByRole('button', { name: 'Custom' }).click()
await formBlock(p, 'Reminder').locator('input').fill('14')
await expect(formBlock(p, 'Reminder').getByText('days before departure')).toBeVisible()
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Update' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update' }).click()
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
},
},
],
cleanup: async p => {
const id = await tripIdByTitle(p.request, NORWAY)
const res = await p.request.put(`/api/trips/${id}`, { data: { reminder_days: norwayReminder } })
if (!res.ok()) throw new Error(`could not put the Norway reminder back: ${res.status()}`)
},
},
'duplicate-trip': {
guide: guide('duplicate-trip'),
start: openDashboard,
@@ -293,22 +409,63 @@ const SCRIPTS: Record<string, GuideScript> = {
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Subscribe to all trips calendar' }),
act: async p => { await p.getByRole('button', { name: 'Subscribe to all trips calendar' }).click() },
target: p => p.getByRole('button', { name: 'Subscribe to all trips' }),
act: async p => {
await p.getByRole('button', { name: 'Subscribe to all trips' }).click()
const enable = p.getByRole('button', { name: 'Enable calendar subscription' })
const turnOff = p.getByRole('button', { name: 'Turn off' })
await expect(enable.or(turnOff)).toBeVisible({ timeout: 15_000 })
// A run that died after enabling left a feed behind; start from none.
if (await turnOff.isVisible()) {
await turnOff.click()
await expect(enable).toBeVisible({ timeout: 15_000 })
}
},
},
{
target: p => p.getByRole('button', { name: 'Enable calendar subscription' }),
act: async p => {
await p.getByRole('button', { name: 'Enable calendar subscription' }).click()
await expect(p.getByText(/Add to Google Calendar/i)).toBeVisible({ timeout: 15_000 })
await expect(p.getByText('Add to Google Calendar')).toBeVisible({ timeout: 15_000 })
},
},
{
// The subscription dialog sits in the shared dialog frame.
target: p => p.locator('.trek-modal-backdrop').last().locator('> [role="presentation"], > div').first(),
// Open the manual links too, so the picture shows every way the step names.
prepare: async p => {
await p.getByText('Or copy a link manually', { exact: true }).click()
await beat(p, 400)
},
target: p => p.getByRole('dialog').last(),
},
],
cleanup: async p => {
const turnOff = p.getByRole('button', { name: 'Turn off' })
if (await turnOff.isVisible()) {
await turnOff.click()
await expect(p.getByRole('button', { name: 'Enable calendar subscription' })).toBeVisible({ timeout: 15_000 })
}
await p.keyboard.press('Escape')
},
},
'upcoming-reservations': {
guide: guide('upcoming-reservations'),
start: async p => {
await openDashboard(p)
await expect(upcomingRow(p)).toBeVisible({ timeout: 15_000 })
},
steps: [
{
target: p => widget(p, 'Upcoming reservations'),
},
{
target: p => upcomingRow(p),
act: async p => {
await upcomingRow(p).click()
await expect(p).toHaveURL(/\/trips\/\d+/, { timeout: 15_000 })
},
},
],
cleanup: async p => { await p.keyboard.press('Escape') },
},
widgets: {
@@ -378,21 +535,36 @@ const SCRIPTS: Record<string, GuideScript> = {
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Add timezone' }),
act: async p => {
// The picker opens under the + and lists the zones by their IANA names.
prepare: async p => {
await p.getByRole('button', { name: 'Add timezone' }).click()
await beat(p, 600)
await widget(p, 'Timezones').getByRole('button', { name: 'Search timezone…' }).click()
await typeInto(p, p.getByLabel('Search', { exact: true }), 'Lisbon')
await expect(p.getByRole('button', { name: 'Europe/Lisbon', exact: true })).toBeVisible()
},
target: p => widget(p, 'Timezones'),
act: async p => {
await p.getByRole('button', { name: 'Europe/Lisbon', exact: true }).click()
await expect(clockRow(p, 'Lisbon')).toBeVisible()
},
},
{
prepare: async p => {
// Close the picker again so the row's remove button is what the picture shows.
await p.getByRole('button', { name: 'Add timezone' }).click()
await p.locator('.tz-row').first().hover()
await clockRow(p, 'Lisbon').hover()
await beat(p, 300)
},
target: p => clockRow(p, 'Lisbon').locator('.tz-del'),
act: async p => {
await clockRow(p, 'Lisbon').locator('.tz-del').click()
await expect(clockRow(p, 'Lisbon')).toHaveCount(0)
},
target: p => p.locator('.tz-row').first().locator('.tz-del'),
},
],
cleanup: async p => {
// Take the clock out again should the guide have stopped half way.
const row = clockRow(p, 'Lisbon')
if (await row.count()) { await row.hover(); await row.locator('.tz-del').click() }
},
},
}
@@ -402,6 +574,7 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
await ensureDashboardFixtures(request)
})
test('every registered dashboard guide has a script, and only those', async () => {
+29
View File
@@ -0,0 +1,29 @@
import type { APIRequestContext } from '@playwright/test';
/**
* Small API helpers the admin guides use to put the seed back after a guide
* changed something (a user it created, a default it set). Kept out of
* `fixtures.ts`, which every screen shares.
*/
/** Deletes the account with this username, if there is one. */
export async function deleteUser(api: APIRequestContext, username: string): Promise<void> {
const res = await api.get('/api/admin/users');
if (!res.ok()) throw new Error(`could not list the users: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { users?: { id: number; username: string }[] };
const user = (body.users ?? []).find((u) => u.username === username);
if (!user) return;
const del = await api.delete(`/api/admin/users/${user.id}`);
if (!del.ok()) throw new Error(`could not delete user "${username}": ${del.status()} ${await del.text()}`);
}
/**
* Returns these default user settings to TREK's built-in value, the way the
* reset link beside a field does (one field per request, as the page sends it).
*/
export async function resetDefaults(api: APIRequestContext, keys: string[]): Promise<void> {
for (const key of keys) {
const res = await api.put('/api/admin/default-user-settings', { data: { [key]: null } });
if (!res.ok()) throw new Error(`could not reset the default "${key}": ${res.status()} ${await res.text()}`);
}
}
+139
View File
@@ -0,0 +1,139 @@
import { request as apiRequest, type APIRequestContext } from '@playwright/test';
import { E2E_BASE_URL } from '../../playwright.config';
import { SPARE_TRIP } from './fixtures';
/**
* What the Collections guides need beyond the seed and the shared fixtures:
* a trip a kept tour can go into, a second list for the duplicate guide and a
* list another user invites the demo account to. Every helper reads first and
* only writes what is missing, so a retried guide finds the same state.
*/
type Row = { id: number; title?: string; name?: string };
const rows = <T>(body: unknown, key: string): T[] =>
Array.isArray(body) ? (body as T[]) : (((body as Record<string, unknown>)?.[key] as T[] | undefined) ?? []);
/** The trip the kept tour is taken into: the Alps trip the Journey guides use as well. */
export const TOUR_TARGET_TRIP = SPARE_TRIP.title;
export async function ensureTourTargetTrip(api: APIRequestContext): Promise<number> {
const res = await api.get('/api/trips');
const trips = rows<Row>(await res.json(), 'trips');
const have = trips.find((t) => t.title === SPARE_TRIP.title);
if (have) return have.id;
const created = await api.post('/api/trips', { data: SPARE_TRIP });
if (!created.ok())
throw new Error(`could not create "${SPARE_TRIP.title}": ${created.status()} ${await created.text()}`);
const body = (await created.json()) as { trip?: { id: number }; id?: number };
const id = body.trip?.id ?? body.id;
if (id == null) throw new Error(`"${SPARE_TRIP.title}" came back without an id`);
return id;
}
// ── Lists over the API ─────────────────────────────────────────────────────────
export async function listIdOf(api: APIRequestContext, name: string): Promise<number | null> {
const res = await api.get('/api/addons/collections');
if (!res.ok()) throw new Error(`GET collections: ${res.status()}`);
const lists = rows<Row>(await res.json(), 'collections');
return lists.find((l) => l.name === name)?.id ?? null;
}
export async function createList(
api: APIRequestContext,
name: string,
color: string,
description?: string
): Promise<number> {
const res = await api.post('/api/addons/collections', { data: { name, color, description } });
if (!res.ok()) throw new Error(`could not create the list "${name}": ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { id?: number; collection?: { id: number } };
const id = body.id ?? body.collection?.id;
if (id == null) throw new Error(`the list "${name}" came back without an id`);
return id;
}
export async function deleteList(api: APIRequestContext, name: string): Promise<void> {
const id = await listIdOf(api, name);
if (id == null) return;
const res = await api.delete(`/api/addons/collections/${id}`);
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE list "${name}": ${res.status()}`);
}
/** The empty list the duplicate guide copies two temples into; removed again in its cleanup. */
export const SECOND_LIST = { name: 'Kyoto temples', color: '#0ea5e9' };
export async function ensureSecondList(api: APIRequestContext): Promise<void> {
if ((await listIdOf(api, SECOND_LIST.name)) == null) await createList(api, SECOND_LIST.name, SECOND_LIST.color);
}
// ── An invite from another user ───────────────────────────────────────────────
/** The seeded member who shares a list with the demo account. */
const INVITER = { email: 'mira@example.com', password: 'DemoSeed12345!' };
/** Mira's list, with two places, so the accepted invite opens on something real. */
export const INVITED_LIST = {
name: 'Osaka street food',
color: '#ef4444',
description: 'Takoyaki, okonomiyaki and the stalls of Kuromon.',
places: [
{ name: 'Kuromon Market', lat: 34.6655, lng: 135.5068, address: '2 Chome-4-1 Nipponbashi, Chuo Ward, Osaka' },
{ name: 'Dotonbori', lat: 34.6687, lng: 135.5013, address: 'Dotonbori, Chuo Ward, Osaka' },
],
};
/**
* A request context acting as the inviter. Its own context with a bearer
* token: a login on the shared one would set the session cookie there, and
* every later request would be hers.
*/
async function asInviter(): Promise<APIRequestContext> {
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined });
const login = await anon.post('/api/auth/login', { data: INVITER });
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined;
await anon.dispose();
if (!token) throw new Error(`could not log in as ${INVITER.email}`);
return apiRequest.newContext({
baseURL: E2E_BASE_URL,
storageState: undefined,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
/**
* Mira makes her list afresh and invites the demo account as an editor. A list
* left over from an earlier attempt is deleted first, so the invite is always
* pending when the guide starts.
*/
export async function sendInviteToMe(api: APIRequestContext): Promise<void> {
const me = await api.get('/api/auth/me');
const myId = ((await me.json()) as { user: { id: number } }).user.id;
const mira = await asInviter();
try {
await deleteList(mira, INVITED_LIST.name);
const id = await createList(mira, INVITED_LIST.name, INVITED_LIST.color, INVITED_LIST.description);
for (const place of INVITED_LIST.places) {
const res = await mira.post('/api/addons/collections/places', { data: { collection_id: id, ...place } });
if (!res.ok())
throw new Error(`could not add "${place.name}" to "${INVITED_LIST.name}": ${res.status()} ${await res.text()}`);
}
const invite = await mira.post('/api/addons/collections/invite', {
data: { collection_id: id, user_id: myId, role: 'editor' },
});
if (!invite.ok()) throw new Error(`could not invite the demo account: ${invite.status()} ${await invite.text()}`);
} finally {
await mira.dispose();
}
}
/** The inviter deletes her list again, which takes it off the demo account's rail. */
export async function withdrawInvitedList(): Promise<void> {
const mira = await asInviter();
try {
await deleteList(mira, INVITED_LIST.name);
} finally {
await mira.dispose();
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { APIRequestContext } from '@playwright/test';
import { at, day } from '../dates';
import { EXTRA_TRIPS } from './fixtures';
/**
* What the dashboard guides need beyond the seed and `ensureExtraTrips`.
*
* The seeded Japan trip ends on the picture day, so every one of its bookings
* is behind us and the Upcoming reservations widget would read "Nothing booked
* yet." The Lisbon weekend, 25 days out, gets three bookings of its own: a
* flight, a table that is not confirmed yet (so the Pending tag shows) and a
* day trip. Trip reminders are switched on, which is the instance default, so
* the reminder guide finds the presets and not the "disabled" hint.
*/
/** The flight, the hotel and the Sintra train come from the showcase (showcase.ts); the guides add a dinner. */
export const LISBON_FLIGHT = 'TP579 FRA → LIS';
export const LISBON_BOOKINGS = [
{
title: 'Dinner at Cervejaria Ramiro',
type: 'restaurant',
status: 'pending',
location: 'Av. Almirante Reis 1, Lisbon',
offset: 25,
time: '20:30',
confirmation_number: '',
},
] as const;
const listIn = <T>(body: unknown, key: string): T[] =>
Array.isArray(body) ? (body as T[]) : (((body as Record<string, unknown>)?.[key] as T[] | undefined) ?? []);
async function json(res: Awaited<ReturnType<APIRequestContext['get']>>, what: string): Promise<unknown> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
return res.json();
}
/** Id of a trip by its title; throws when it is missing. */
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number> {
const trip = listIn<{ id: number; title: string }>(
await json(await api.get('/api/trips'), 'list trips'),
'trips'
).find((t) => t.title === title);
if (!trip) throw new Error(`fixture trip "${title}" is missing`);
return trip.id;
}
export async function ensureDashboardFixtures(api: APIRequestContext): Promise<void> {
const settings = await api.put('/api/auth/app-settings', { data: { notify_trip_reminder: 'true' } });
if (!settings.ok())
throw new Error(`could not switch trip reminders on: ${settings.status()} ${await settings.text()}`);
const tripId = await tripIdByTitle(api, EXTRA_TRIPS[0].title);
const reservations = listIn<{ title: string }>(
await json(await api.get(`/api/trips/${tripId}/reservations`), 'list reservations'),
'reservations'
);
const days = listIn<{ id: number; date?: string | null }>(
await json(await api.get(`/api/trips/${tripId}/days`), 'list days'),
'days'
);
for (const { offset, time, ...booking } of LISBON_BOOKINGS) {
if (reservations.some((r) => r.title === booking.title)) continue;
const dayId = days.find((d) => d.date === day(offset))?.id;
const created = await api.post(`/api/trips/${tripId}/reservations`, {
data: {
...booking,
confirmation_number: booking.confirmation_number || undefined,
day_id: dayId,
reservation_time: at(offset, time),
},
});
if (!created.ok())
throw new Error(`could not create "${booking.title}": ${created.status()} ${await created.text()}`);
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { APIRequestContext, APIResponse } from '@playwright/test';
import { day } from '../dates';
import { disconnectDawarich, ensureDawarichConnection } from './external';
/**
* What the journal guides about a recorded day need beyond the seed: the
* Dawarich from `media.env` connected, a handful of stays on one day of the
* trip, and those stays synced into TREK, so the day of the journal carries
* its stay row, its route button and its take-over button.
*
* The recording itself is `dawarich-track.ts`, uploaded by
* `ensureDawarichConnection`. Dawarich detects stays from points only in a
* background job of its own, so the stays are put there here, as visits, on
* the Arashiyama day: the times and places are the ones the recording dwells
* at, so the stays sit on the route.
*
* Everything a guide leaves is taken back in `releaseJournalDawarich`: the
* stays still waiting are dismissed (the trip planner lists waiting stays at
* the head of its Places column), a kept route is removed, the automatic
* preparation is switched off, entries the guide wrote are deleted and the
* addon goes off again. The next guide's `connectJournalDawarich` brings the
* dismissed stays back.
*/
/** The day the stays are on: the trip's sixth day, Arashiyama and Nishiki. */
export const STAY_DAY = day(-4);
const JST = '+09:00';
const STAYS = [
{ name: 'Togetsukyo Bridge', lat: 35.013, lng: 135.6776, from: '09:30', to: '10:00' },
{ name: 'Arashiyama Bamboo Grove', lat: 35.017, lng: 135.6716, from: '10:10', to: '11:10' },
{ name: 'Nishiki Market', lat: 35.005, lng: 135.7649, from: '12:30', to: '14:00' },
{ name: 'Gion', lat: 35.0037, lng: 135.7752, from: '14:20', to: '15:20' },
];
function env(name: string): string {
const value = process.env[name];
if (!value)
throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`);
return value.replace(/\/+$/, '');
}
async function ok<T>(res: APIResponse, what: string): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
const instant = (time: string) => new Date(`${STAY_DAY}T${time}:00${JST}`).toISOString();
/** The stays on the day as visits in Dawarich; those already there by name are left alone. */
async function ensureDawarichStays(): Promise<void> {
const base = env('HELP_MEDIA_DAWARICH_URL');
const key = env('HELP_MEDIA_DAWARICH_API_KEY');
const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
const from = instant('00:00');
const to = instant('23:59');
const listed = await fetch(`${base}/api/v1/visits?start_at=${from}&end_at=${to}`, { headers });
if (!listed.ok) throw new Error(`Dawarich visits ${STAY_DAY}: HTTP ${listed.status} ${await listed.text()}`);
const body = (await listed.json()) as { name?: string }[] | { visits?: { name?: string }[] };
const have = new Set((Array.isArray(body) ? body : (body.visits ?? [])).map((v) => v.name));
for (const stay of STAYS) {
if (have.has(stay.name)) continue;
const created = await fetch(`${base}/api/v1/visits`, {
method: 'POST',
headers,
body: JSON.stringify({
visit: {
name: stay.name,
latitude: stay.lat,
longitude: stay.lng,
started_at: instant(stay.from),
ended_at: instant(stay.to),
},
}),
});
if (!created.ok) throw new Error(`Dawarich visit "${stay.name}": HTTP ${created.status} ${await created.text()}`);
}
}
interface Suggestion {
id: number;
state: string;
}
async function suggestions(api: APIRequestContext, state: 'new' | 'dismissed'): Promise<Suggestion[]> {
const body = await ok<{ suggestions: Suggestion[] }>(
await api.get(`/api/integrations/dawarich/suggestions?state=${state}`),
`list ${state} stays`
);
return body.suggestions;
}
async function setState(api: APIRequestContext, list: Suggestion[], state: 'new' | 'dismissed'): Promise<void> {
for (const s of list) {
await ok(
await api.put(`/api/integrations/dawarich/suggestions/${s.id}/state`, { data: { state } }),
`set stay ${s.id} ${state}`
);
}
}
/** The ids of the journal's written entries, for `releaseJournalDawarich` to keep. */
export async function writtenEntryIds(api: APIRequestContext, journeyId: number): Promise<Set<number>> {
const { entries } = await ok<{ entries: { id: number; type: string }[] }>(
await api.get(`/api/journeys/${journeyId}/entries`),
'list entries'
);
return new Set(entries.filter((e) => e.type !== 'skeleton').map((e) => e.id));
}
/** The addon on, the user connected, the stays in Dawarich and synced into TREK, all waiting again. */
export async function connectJournalDawarich(api: APIRequestContext, journeyId: number): Promise<void> {
await ensureDawarichConnection(api);
await ensureDawarichStays();
await ok(await api.post('/api/integrations/dawarich/sync'), 'sync Dawarich');
await setState(api, await suggestions(api, 'dismissed'), 'new');
await ok(
await api.patch(`/api/journeys/${journeyId}`, { data: { auto_prepare_recordings: false } }),
'switch the preparation off'
);
}
/** Everything a recorded-day guide changed, put back; `keep` are the entries that were there before it. */
export async function releaseJournalDawarich(
api: APIRequestContext,
journeyId: number,
keep: Set<number>
): Promise<void> {
await setState(api, await suggestions(api, 'new'), 'dismissed');
const { days } = await ok<{ days: { date: string }[] }>(
await api.get(`/api/journeys/${journeyId}/recordings`),
'list kept days'
);
for (const kept of days) {
await ok(
await api.delete(`/api/journeys/${journeyId}/recordings/${kept.date}`),
`remove the route of ${kept.date}`
);
}
await ok(
await api.patch(`/api/journeys/${journeyId}`, { data: { auto_prepare_recordings: false } }),
'switch the preparation off'
);
for (const id of await writtenEntryIds(api, journeyId)) {
if (!keep.has(id)) await ok(await api.delete(`/api/journeys/entries/${id}`), `delete entry ${id}`);
}
await disconnectDawarich(api);
}
+158
View File
@@ -0,0 +1,158 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
import { asMember, PARTNER } from './fixtures-vacay';
/**
* The in-app notifications the notification guides read, mark and delete.
*
* The seed already leaves the admin a few "New Message" notifications, one per
* chat line mira and jonas posted in the Japan trip. On top of those the
* members do two more things a trip's people really do, through their own
* bearer tokens: jonas saves a booking of the Japan trip unchanged (the
* "Booking Updated" notification, with View Trip) and mira shares her Vacay
* calendar with the admin (the "Vacay Calendar Shared" one, with View Vacay).
* Neither changes anything another picture shows; removeNotificationFixtures
* takes the share and both notifications back.
*
* Every helper is idempotent: a guide that deleted one gets it again at its start.
*/
const INBOX = '/api/notifications/in-app';
const VACAY = '/api/addons/vacay';
/** The member who changes the booking. Not mira: two notifications from one sender read like one. */
export const BOOKER = { username: 'jonas', email: 'jonas@example.com', password: PARTNER.password };
/** A booking the seed puts on the Japan trip; jonas saves it as it is. */
const BOOKING = 'LH716 FRA → HND';
/** The titles as the list renders them (`notif.*.title` in en). */
export const BOOKING_TITLE = 'Booking Updated';
export const SHARE_TITLE = 'Vacay Calendar Shared';
export const MESSAGE_TITLE = 'New Message';
const BOOKING_KEY = 'notif.booking_change.title';
const SHARE_KEY = 'notif.vacay_share.title';
interface InboxRow {
id: number;
title_key: string;
sender_username: string | null;
is_read: boolean | number;
}
async function ok(res: { ok(): boolean; status(): number; text(): Promise<string> }, what: string): Promise<void> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
}
/** The admin's newest notifications, as many as the server hands out at once. */
export async function inbox(api: APIRequestContext): Promise<InboxRow[]> {
const res = await api.get(`${INBOX}?limit=50`);
await ok(res, 'could not load the notifications');
return ((await res.json()) as { notifications: InboxRow[] }).notifications;
}
const find = (rows: InboxRow[], key: string, sender: string): InboxRow | undefined =>
rows.find((n) => n.title_key === key && n.sender_username === sender);
async function adminId(api: APIRequestContext): Promise<number> {
return ((await (await api.get('/api/auth/me')).json()) as { user: { id: number } }).user.id;
}
/** jonas saves the seeded flight unchanged; the server tells every other member. */
async function bookingChangeFromJonas(): Promise<void> {
const { tripId } = seededTrip();
const jonas = await asMember(BOOKER);
try {
const list = await jonas.get(`/api/trips/${tripId}/reservations`);
await ok(list, 'jonas could not load the bookings');
const { reservations } = (await list.json()) as { reservations: { id: number; title: string }[] };
const booking = reservations.find((r) => r.title === BOOKING) ?? reservations[0];
if (!booking) throw new Error('the seeded trip has no booking for jonas to save');
await ok(
await jonas.put(`/api/trips/${tripId}/reservations/${booking.id}`, { data: { title: booking.title } }),
'jonas could not save the booking'
);
} finally {
await jonas.dispose();
}
}
/** mira shares her Vacay calendar with the admin, as she would from her own Vacay. */
async function shareFromMira(api: APIRequestContext): Promise<void> {
const target = await adminId(api);
const mira = await asMember(PARTNER);
try {
const res = await mira.post(`${VACAY}/shares`, { data: { user_id: target } });
// Already shared: the notification was deleted, the share is still there.
// Share again from scratch so the server sends a fresh one.
if (!res.ok() && (await res.text()).includes('Already shared')) {
const shares = (await (await mira.get(`${VACAY}/shares`)).json()) as {
outgoing?: { id: number; user_id: number }[];
};
for (const s of (shares.outgoing ?? []).filter((x) => x.user_id === target))
await mira.delete(`${VACAY}/shares/${s.id}`);
await ok(
await mira.post(`${VACAY}/shares`, { data: { user_id: target } }),
'mira could not share her calendar again'
);
} else {
await ok(res, 'mira could not share her calendar');
}
} finally {
await mira.dispose();
}
}
/** Wait until the fire-and-forget send has written the row. */
async function waitFor(api: APIRequestContext, key: string, sender: string): Promise<InboxRow> {
for (let i = 0; i < 20; i++) {
const row = find(await inbox(api), key, sender);
if (row) return row;
await new Promise((res) => setTimeout(res, 250));
}
throw new Error(`no "${key}" notification from ${sender} arrived`);
}
/**
* The two notifications from the members, both unread, the share newest. A
* guide before may have read or deleted one; this brings it back as it was.
*/
export async function ensureNotificationFixtures(api: APIRequestContext): Promise<void> {
let rows = await inbox(api);
if (!find(rows, BOOKING_KEY, BOOKER.username)) await bookingChangeFromJonas();
await waitFor(api, BOOKING_KEY, BOOKER.username);
rows = await inbox(api);
if (!find(rows, SHARE_KEY, PARTNER.username)) await shareFromMira(api);
await waitFor(api, SHARE_KEY, PARTNER.username);
for (const n of await inbox(api)) {
const ours =
(n.title_key === BOOKING_KEY && n.sender_username === BOOKER.username) ||
(n.title_key === SHARE_KEY && n.sender_username === PARTNER.username);
if (ours && !n.is_read) continue;
if (ours) await ok(await api.put(`${INBOX}/${n.id}/unread`), 'could not mark a notification unread');
}
}
/** The ids of every unread notification, to give back after a guide that reads them all. */
export async function unreadIds(api: APIRequestContext): Promise<number[]> {
return (await inbox(api)).filter((n) => !n.is_read).map((n) => n.id);
}
/** Mark these unread again; one that is gone by now is skipped. */
export async function markUnread(api: APIRequestContext, ids: number[]): Promise<void> {
for (const id of ids) await api.put(`${INBOX}/${id}/unread`);
}
/** Back to the seed: mira's share and both notifications from the members go. */
export async function removeNotificationFixtures(api: APIRequestContext): Promise<void> {
const shares = (await (await api.get(`${VACAY}/shares`)).json()) as { incoming: { id: number; username: string }[] };
for (const s of shares.incoming.filter((x) => x.username === PARTNER.username)) {
await ok(await api.delete(`${VACAY}/shares/${s.id}`), `could not remove ${PARTNER.username}'s share`);
}
for (const n of await inbox(api)) {
const ours =
(n.title_key === BOOKING_KEY && n.sender_username === BOOKER.username) ||
(n.title_key === SHARE_KEY && n.sender_username === PARTNER.username);
if (ours) await api.delete(`${INBOX}/${n.id}`);
}
}
+221
View File
@@ -0,0 +1,221 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { toggleAddon } from './external';
import { pluginPackage, seededTrip } from './fixtures';
/**
* What the settings guides need beyond the seed: a wanderer to connect to, a
* plugin so the Plugins tab exists, and two parked offline changes so the
* Sync conflicts card and the failed changes have something to show.
*
* The wanderer guides follow the pattern of `external.ts`: the address and the
* token come from `e2e/help/media.env`, and without them those guides fail at
* their start while every other guide runs as before.
*
* # wanderer (Settings, Integrations: wanderer). A token from wanderer under
* # Settings, Account, API tokens; it starts with wanderer_key_.
* HELP_MEDIA_WANDERER_URL=http://192.168.1.10:3030
* HELP_MEDIA_WANDERER_TOKEN=wanderer_key_...
*
* TREK's wanderer connection takes a URL and an API token, nothing else (see
* `wandererSettingsRequestSchema`), so no user name or password is needed. As
* with the other services, the address has to be the machine's LAN address and
* the media backend needs ALLOW_INTERNAL_NETWORK=true.
*/
export function mediaEnv(name: string): string {
const value = process.env[name];
if (!value)
throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`);
return value.replace(/\/+$/, '');
}
// ── wanderer ────────────────────────────────────────────────────────────────
export const wandererUrl = (): string => mediaEnv('HELP_MEDIA_WANDERER_URL');
export const wandererToken = (): string => mediaEnv('HELP_MEDIA_WANDERER_TOKEN');
/** The admin's wanderer switch under Tours, as it was before the guide turned it on. */
let wandererWasOn: boolean | null = null;
/**
* The Tours addon on and wanderer allowed under it, so the wanderer card shows
* in Integrations, with no connection yet. Remembers the admin switch, so
* `disconnectWanderer` puts it back the way the seed had it.
*/
export async function allowWanderer(api: APIRequestContext): Promise<void> {
// Both variables first: a missing one should fail here, not halfway into a picture.
wandererUrl();
wandererToken();
await toggleAddon(api, 'tours', true);
if (wandererWasOn === null) {
const current = await api.get('/api/admin/tours-wanderer');
wandererWasOn = current.ok() ? !!((await current.json()) as { enabled?: boolean }).enabled : false;
}
const res = await api.put('/api/admin/tours-wanderer', { data: { enabled: true } });
if (!res.ok()) throw new Error(`could not allow wanderer under Tours: ${res.status()} ${await res.text()}`);
await api.delete('/api/integrations/wanderer/settings');
}
/** Connected through the API and tested, loudly, for the guides that start from a connection. */
export async function ensureWandererConnection(api: APIRequestContext): Promise<void> {
await allowWanderer(api);
const saved = await api.put('/api/integrations/wanderer/settings', {
data: { url: wandererUrl(), token: wandererToken(), allowInsecureTls: false },
});
if (!saved.ok()) throw new Error(`could not save the wanderer connection: ${saved.status()} ${await saved.text()}`);
const test = await api.post('/api/integrations/wanderer/test', { data: {} });
const body = (await test.json()) as { ok?: boolean; code?: string };
if (!body.ok)
throw new Error(
`wanderer is not reachable from the media backend: ${body.code ?? test.status()} (is ALLOW_INTERNAL_NETWORK=true?)`
);
await api.put('/api/integrations/wanderer/watch', { data: { enabled: false } });
}
/** The watch off, the connection gone, and the admin switch back where it was. */
export async function disconnectWanderer(api: APIRequestContext): Promise<void> {
await api.put('/api/integrations/wanderer/watch', { data: { enabled: false } });
await api.delete('/api/integrations/wanderer/settings');
if (wandererWasOn === false) await api.put('/api/admin/tours-wanderer', { data: { enabled: false } });
wandererWasOn = null;
}
// ── Plugins ─────────────────────────────────────────────────────────────────
/**
* The Plugins tab only exists while a plugin is active. The seed installs two
* from the registry, which needs the network, and the admin file uninstalls
* Trip Doctor in its cleanup; so when no plugin is active any more, the SDK's
* Trip Doctor example is sideloaded and switched on. Answers whether it did,
* so the caller can take it away again.
*/
export async function ensureActivePlugin(api: APIRequestContext): Promise<boolean> {
const feed = await api.get('/api/plugins');
const { plugins = [] } = (await feed.json()) as { plugins?: { id: string }[] };
if (plugins.length > 0) return false;
const upload = await api.post('/api/admin/plugins/upload', {
multipart: { file: { name: 'trip-doctor.tar.gz', mimeType: 'application/gzip', buffer: pluginPackage() } },
});
if (!upload.ok()) throw new Error(`could not upload the plugin: ${upload.status()} ${await upload.text()}`);
const active = await api.post('/api/admin/plugins/trip-doctor/activate', { data: { consent: true } });
if (!active.ok()) throw new Error(`could not activate the plugin: ${active.status()} ${await active.text()}`);
return true;
}
export async function removeSideloadedPlugin(api: APIRequestContext): Promise<void> {
await api.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } });
}
// ── Offline: parked changes ─────────────────────────────────────────────────
/** The two rows this file parks in the offline queue; their ids make them easy to take out again. */
const CONFLICT_ID = 'help-media-conflict-nishiki';
const FAILED_ID = 'help-media-failed-todaiji';
export const CONFLICT_PLACE = 'Nishiki Market';
export const FAILED_PLACE = 'Todai-ji';
async function placeId(api: APIRequestContext, tripId: number, name: string): Promise<number> {
const res = await api.get(`/api/trips/${tripId}/places`);
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[];
const place = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.name === name);
if (!place) throw new Error(`the seeded trip has no place "${name}"`);
return place.id;
}
/**
* A change that clashes with the server and one the server keeps refusing,
* written straight into this browser's offline queue (Dexie table
* `mutationQueue` of `trek-offline-u<userId>`), the way the mutation queue
* parks them. Neither is ever replayed on its own: a parked row waits for
* Keep mine, Keep theirs, Try again or Discard, none of which the guide
* clicks. Needs the app loaded once, so the database exists.
*/
export async function parkOfflineChanges(page: Page): Promise<void> {
const { tripId } = seededTrip();
const me = (await (await page.request.get('/api/auth/me')).json()) as { user: { id: number } };
const conflictPlace = await placeId(page.request, tripId, CONFLICT_PLACE);
const failedPlace = await placeId(page.request, tripId, FAILED_PLACE);
const now = Date.now();
const rows = [
{
id: CONFLICT_ID,
tripId,
method: 'PUT',
url: `/trips/${tripId}/places/${conflictPlace}`,
body: { name: CONFLICT_PLACE, notes: 'Go before 10, the stalls open early.' },
createdAt: now - 60_000,
status: 'conflict',
attempts: 1,
lastError: 'conflict',
resource: 'places',
entityId: conflictPlace,
baseUpdatedAt: null,
conflictServer: { name: CONFLICT_PLACE },
conflictAt: now - 30_000,
schemaVersion: 1,
},
{
id: FAILED_ID,
tripId,
method: 'PUT',
url: `/trips/${tripId}/places/${failedPlace}`,
body: { name: FAILED_PLACE, notes: 'Deer crackers at the gate.' },
createdAt: now - 50_000,
status: 'failed',
attempts: 5,
lastError: 'HTTP 500',
resource: 'places',
entityId: failedPlace,
baseUpdatedAt: null,
schemaVersion: 1,
},
];
await page.evaluate(
async ({ name, rows }) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const open = indexedDB.open(name);
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
try {
if (!db.objectStoreNames.contains('mutationQueue'))
throw new Error(`${name} has no mutationQueue; was the app loaded?`);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('mutationQueue', 'readwrite');
for (const row of rows) tx.objectStore('mutationQueue').put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
},
{ name: `trek-offline-u${me.user.id}`, rows }
);
}
/** Takes the two parked rows out again, whatever the guide did with them. */
export async function clearParkedChanges(page: Page): Promise<void> {
const me = (await (await page.request.get('/api/auth/me')).json()) as { user: { id: number } };
await page.evaluate(
async ({ name, ids }) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const open = indexedDB.open(name);
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
try {
if (!db.objectStoreNames.contains('mutationQueue')) return;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('mutationQueue', 'readwrite');
for (const id of ids) tx.objectStore('mutationQueue').delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
},
{ name: `trek-offline-u${me.user.id}`, ids: [CONFLICT_ID, FAILED_ID] }
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
/**
* What the two note guides of the Collab tab need beyond `ensureCollabFixtures`.
* Kept apart from the shared fixtures because nothing outside
* `trip-collab.guide.ts` reads it.
*/
/**
* The note `manage-notes` opens, pins, edits and finally deletes. It is made
* fresh at the start of that guide rather than in the seed, so no other picture
* of the tab (the hero among them) carries a note that is about to go.
*/
export const MANAGE_NOTE = {
title: 'Onsen etiquette',
category: 'Accommodation',
color: '#ef4444',
content:
'Wash at the stools **before** you get into the bath.\n\n- The small towel stays out of the water\n- No swimwear\n- Ask at the front desk about tattoos',
website: 'https://www.japan.travel/en/guide/onsen/',
};
/** The line the edit step adds to it. */
export const MANAGE_NOTE_EDIT = '- Drink water afterwards, the baths run hot';
/**
* The category and colour every seeded note had before `note-categories` renames
* and recolours them (`e2e/screenshots/seed.ts`, step 11b), by title.
*/
export const SEEDED_NOTE_CATEGORIES: Record<string, { category: string; color: string }> = {
'Rail passes': { category: 'Transport', color: '#3b82f6' },
'Ryokan etiquette': { category: 'Accommodation', color: '#ef4444' },
'Rainy-day alternatives': { category: 'Ideas', color: '#22c55e' },
};
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`;
async function notesOf(
api: APIRequestContext
): Promise<{ id: number; title: string; category: string | null; color: string | null }[]> {
const res = await api.get(`${collab()}/notes`);
const body = (await res.json()) as {
notes?: { id: number; title: string; category: string | null; color: string | null }[];
};
return body.notes ?? [];
}
/** Removes every note of that title. */
export async function removeNotes(api: APIRequestContext, title: string): Promise<void> {
for (const note of (await notesOf(api)).filter((n) => n.title === title)) {
await api.delete(`${collab()}/notes/${note.id}`);
}
}
/** One open, unpinned MANAGE_NOTE; a copy left by a run that stopped short is thrown away first. */
export async function ensureManageNote(api: APIRequestContext): Promise<void> {
await removeNotes(api, MANAGE_NOTE.title);
const created = await api.post(`${collab()}/notes`, { data: MANAGE_NOTE });
if (!created.ok())
throw new Error(`could not write the note "${MANAGE_NOTE.title}": ${created.status()} ${await created.text()}`);
}
/** Puts every seeded note back into its seeded category and colour. Safe to run when nothing changed. */
export async function restoreNoteCategories(api: APIRequestContext): Promise<void> {
for (const note of await notesOf(api)) {
const seeded = SEEDED_NOTE_CATEGORIES[note.title];
if (!seeded || (note.category === seeded.category && note.color === seeded.color)) continue;
const res = await api.put(`${collab()}/notes/${note.id}`, { data: seeded });
if (!res.ok()) throw new Error(`could not restore the note "${note.title}": ${res.status()} ${await res.text()}`);
}
}
+121
View File
@@ -0,0 +1,121 @@
import type { APIRequestContext } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { day } from '../dates';
import { seededTrip } from './fixtures';
/**
* What the receipts guide of the Costs tab needs beyond the shared fixtures: an
* expense of its own to attach a receipt to, and the receipt itself. Nothing in
* the repository may carry a real person's paperwork, so the till slip is drawn
* here, the way `fixtures.ts` draws its documents: a white strip of thermal
* paper with the shop, the lines and the total, which is what a phone photo of
* a receipt cropped to the paper looks like.
*/
/** The expense the receipt belongs to; its total is the slip's total. */
export const RAMEN = {
name: 'Ichiran ramen in Shibuya',
category: 'food',
total_price: 4450,
currency: 'JPY',
expense_date: day(-6),
};
/** The file the guide attaches, by the name the editor and the preview show. */
export const RECEIPT_FILE = 'ichiran-receipt.png';
const LINES: [string, string, string][] = [
['Ramen, classic', '3', '2,940'],
['Kaedama (extra noodles)', '2', '420'],
['Matcha almond tofu', '2', '700'],
['Soft-boiled egg', '3', '390'],
];
const tmpDir = (): string => {
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
return dir;
};
const esc = (v: string) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* The till slip as a PNG in `e2e/.tmp`. Drawn on every call: the receipt names
* its day, which is relative to the picture day, so a file left over from an
* earlier run would carry that run's date.
*/
export async function receiptFixture(): Promise<string> {
const stamp = RAMEN.expense_date.replace(/-/g, '/');
const file = path.join(tmpDir(), RECEIPT_FILE);
const W = 1000;
const H = 1500;
const paperX = 200;
const paperW = 600;
const left = paperX + 48;
const right = paperX + paperW - 48;
const mono = 'font-family="\'Courier New\', Courier, monospace"';
const text = (x: number, y: number, s: string, opts = '') =>
`<text x="${x}" y="${y}" ${mono} ${opts.includes('font-size') ? '' : 'font-size="24"'} fill="#1f2328" ${opts}>${esc(s)}</text>`;
const rule = (y: number) =>
`<line x1="${left}" y1="${y}" x2="${right}" y2="${y}" stroke="#1f2328" stroke-width="2" stroke-dasharray="8 6"/>`;
const rows: string[] = [];
let y = 560;
for (const [name, qty, price] of LINES) {
rows.push(text(left, y, name));
rows.push(text(left + 330, y, `x${qty}`));
rows.push(text(right, y, price, 'text-anchor="end"'));
y += 52;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<rect width="${W}" height="${H}" fill="#d9dce1"/>
<rect x="${paperX + 10}" y="70" width="${paperW}" height="${H - 140}" fill="#000" opacity="0.12" rx="4"/>
<rect x="${paperX}" y="60" width="${paperW}" height="${H - 140}" fill="#fcfcfa" rx="4"/>
${text(W / 2, 170, 'ICHIRAN', 'font-size="46" font-weight="bold" text-anchor="middle"')}
${text(W / 2, 214, 'Shibuya store', 'text-anchor="middle"')}
${text(W / 2, 252, '1-22-7 Jinnan, Shibuya-ku, Tokyo', 'font-size="20" text-anchor="middle"')}
${text(W / 2, 284, 'Tel 03-3463-3667', 'font-size="20" text-anchor="middle"')}
${rule(330)}
${text(left, 380, `${stamp} 13:42`)}
${text(right, 380, 'No. 0418', 'text-anchor="end"')}
${text(left, 424, 'Booth 14-16')}
${text(right, 424, 'Guests 3', 'text-anchor="end"')}
${rule(470)}
${rows.join('\n ')}
${rule(y)}
${text(left, y + 60, 'TOTAL', 'font-size="32" font-weight="bold"')}
${text(right, y + 60, '¥4,450', 'font-size="32" font-weight="bold" text-anchor="end"')}
${text(left, y + 104, '(incl. 10% tax)', 'font-size="20"')}
${text(right, y + 104, '¥404', 'font-size="20" text-anchor="end"')}
${text(left, y + 160, 'Paid by card')}
${text(right, y + 160, '¥4,450', 'text-anchor="end"')}
${rule(y + 206)}
${text(W / 2, y + 260, 'Thank you, please come again', 'font-size="20" text-anchor="middle"')}
${text(W / 2, y + 296, 'Open 24 hours', 'font-size="20" text-anchor="middle"')}
</svg>`;
await sharp(Buffer.from(svg)).png().toFile(file);
return file;
}
type ApiFile = { id: number; original_name: string };
/**
* The receipt off the trip again, trash included. Remove receipt in the editor
* only unlinks it, and deleting the expense leaves it too, so without this the
* Files tab of every later picture would carry a ramen receipt.
*/
export async function removeReceiptFiles(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/files`);
if (!res.ok()) throw new Error(`could not list the trip's files: ${res.status()}`);
const { files = [] } = (await res.json()) as { files?: ApiFile[] };
for (const file of files.filter((f) => f.original_name === RECEIPT_FILE)) {
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { APIRequestContext } from '@playwright/test';
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_FOLDER, resetDocSync } from './external';
import { drawPdf } from './fixtures';
/**
* What the binding-settings guide needs beyond the Files fixtures: the trip
* already bound to the Nextcloud folder, so the guide opens on the card and
* not on the connection form that `files-sync` walks through. It takes the same
* three calls the dialog makes (save the connection, list the folders, bind
* one), starting from no binding at all, so it can run any number of times.
* The store's address and app password come from media.env (external.ts).
*/
interface Scope {
scopeKey: string;
label: string;
remoteRootId: string | null;
remoteRootPath: string | null;
}
async function ok<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: HTTP ${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
/** Binds the trip to the fixture folder in Nextcloud with the dialog's defaults; returns the link's id. */
export async function ensureNextcloudBinding(api: APIRequestContext, tripId: number): Promise<number> {
await resetDocSync(api, tripId);
await ensureNextcloudFolder(drawPdf);
const base = `/api/trips/${tripId}/docsync`;
const connection = await ok<{ id: number }>(
await api.put(`${base}/connections`, { data: NEXTCLOUD_CONNECTION() }),
'save the Nextcloud connection'
);
const { scopes = [] } = await ok<{ scopes?: Scope[] }>(
await api.get(`${base}/connections/${connection.id}/scopes`),
'list the Nextcloud folders'
);
// The picker finds the folder by its text the same way (folderRow in the guide).
const scope =
scopes.find((s) => s.label === NEXTCLOUD_FOLDER) ?? scopes.find((s) => s.label.includes(NEXTCLOUD_FOLDER));
if (!scope)
throw new Error(`no folder "${NEXTCLOUD_FOLDER}" in the store (found: ${scopes.map((s) => s.label).join(', ')})`);
const link = await ok<{ id: number }>(
await api.post(`${base}/links`, {
data: {
connectionId: connection.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
},
}),
'bind the trip to the folder'
);
return link.id;
}
+135
View File
@@ -0,0 +1,135 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
/**
* Putting the packing list back the way the seed left it.
*
* The seed's list is long (six lists, most of it ticked) and several guides
* change it in ways that are hard to undo one by one: a list dragged to the
* front, pieces counted in, a weight typed, items imported or brought in by a
* template. So the trip-lists script takes one snapshot of the list before the
* first guide and every packing guide puts that snapshot back, before it starts
* and again when it is done. Nothing here hard-codes the seed's names, so a
* richer seed needs no change in this file.
*/
export interface PackingSnapshotRow {
id: number;
name: string;
category: string | null;
checked: number;
quantity: number;
weight_grams: number | null;
packed_quantity: number | null;
bag_id: number | null;
is_private: number;
sort_order: number;
}
type RawRow = Partial<PackingSnapshotRow> & { id: number; name: string };
function normalise(row: RawRow): PackingSnapshotRow {
return {
id: row.id,
name: row.name,
category: row.category ?? null,
checked: row.checked ? 1 : 0,
quantity: row.quantity || 1,
weight_grams: row.weight_grams ?? null,
packed_quantity: row.packed_quantity ?? null,
bag_id: row.bag_id ?? null,
is_private: row.is_private ? 1 : 0,
sort_order: row.sort_order ?? 0,
};
}
async function packingRows(api: APIRequestContext): Promise<PackingSnapshotRow[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/packing`);
if (!res.ok()) throw new Error(`could not read the packing list: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { items?: RawRow[] };
return (body.items ?? []).map(normalise).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
}
/** The packing list as it is now, in its order. */
export async function snapshotPacking(api: APIRequestContext): Promise<PackingSnapshotRow[]> {
return packingRows(api);
}
const differs = (a: PackingSnapshotRow, b: PackingSnapshotRow): boolean =>
a.name !== b.name ||
a.category !== b.category ||
a.checked !== b.checked ||
a.quantity !== b.quantity ||
a.weight_grams !== b.weight_grams ||
a.packed_quantity !== b.packed_quantity ||
a.bag_id !== b.bag_id;
/**
* Make the trip's packing list equal to the snapshot again: what a guide added
* goes, what it deleted comes back, ticks, quantities, counts and weights are
* written back, and the order is restored. Cheap when nothing changed: one read.
*/
export async function restorePacking(api: APIRequestContext, snapshot: PackingSnapshotRow[]): Promise<void> {
const { tripId } = seededTrip();
const current = await packingRows(api);
const wanted = new Set(snapshot.map((row) => row.id));
for (const row of current.filter((r) => !wanted.has(r.id))) {
await api.delete(`/api/trips/${tripId}/packing/${row.id}`);
}
const ids: number[] = [];
for (const row of snapshot) {
const now = current.find((r) => r.id === row.id);
if (now) {
ids.push(row.id);
if (!differs(now, row)) continue;
// Naming `checked` resets the piece counter on the server, which is what a
// seeded row has: none. A partial count is written after it when there was one.
const data: Record<string, unknown> = {
name: row.name,
checked: row.checked,
quantity: row.quantity,
weight_grams: row.weight_grams,
};
if (row.category) data.category = row.category;
if (now.bag_id !== row.bag_id) data.bag_id = row.bag_id;
const res = await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data });
if (!res.ok()) throw new Error(`could not restore "${row.name}": ${res.status()} ${await res.text()}`);
if (row.packed_quantity != null) {
await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { packed_quantity: row.packed_quantity } });
}
continue;
}
const created = await api.post(`/api/trips/${tripId}/packing`, {
data: {
name: row.name,
...(row.category ? { category: row.category } : {}),
visibility: row.is_private ? 'personal' : 'common',
quantity: row.quantity,
weight_grams: row.weight_grams,
checked: row.checked,
},
});
if (!created.ok())
throw new Error(`could not re-create "${row.name}": ${created.status()} ${await created.text()}`);
const { item } = (await created.json()) as { item: { id: number } };
ids.push(item.id);
}
const after = await packingRows(api);
const inOrder = after.map((r) => r.id);
if (inOrder.length === ids.length && inOrder.every((id, i) => id === ids[i])) return;
await api.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds: ids } });
}
/** Untick one item for a guide that starts from an item still to pack. */
export async function untickPacking(api: APIRequestContext, name: string): Promise<void> {
const { tripId } = seededTrip();
const row = (await packingRows(api)).find((r) => r.name === name);
if (!row) throw new Error(`no packing item "${name}" in the seed`);
if (!row.checked) return;
const res = await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { checked: 0 } });
if (!res.ok()) throw new Error(`could not untick "${name}": ${res.status()} ${await res.text()}`);
}
+71
View File
@@ -0,0 +1,71 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
import { TOUR_NAMES } from './tour-fixtures';
/**
* What the tour guide on the plan map needs beyond `ensureToursFixtures`: one
* of the trip's tours on a day, so the day's route has a tour to run to and on
* from. The Tours fixtures may already put one there; then nothing is added.
* Otherwise the Fushimi Inari hike goes onto day 5, the day that already holds
* Fushimi Inari Taisha, and `removeTourFromDay` takes exactly that assignment
* out again in `afterAll`, so the screens after this one see the seed as it was.
*/
interface DayAssignment {
id: number;
place_id: number;
place?: { name?: string };
}
/** The tour names, whatever shape the Tours fixtures export them in (a list or a map of them). */
export function tourNames(): string[] {
const values = Object.values(TOUR_NAMES as unknown as Record<string, unknown>);
return values.filter((v): v is string => typeof v === 'string');
}
export interface TourOnDay {
/** The day's number in the days column, 1-based. */
day: number;
/** The tour's name, as the list and its card show it. */
name: string;
/** The assignment this fixture created, to remove again; null when the tour was already on a day. */
created: { dayId: number; id: number } | null;
}
async function assignmentsOf(api: APIRequestContext, tripId: number, dayId: number): Promise<DayAssignment[]> {
const res = await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`);
if (!res.ok()) throw new Error(`could not read the assignments of day ${dayId}: ${res.status()}`);
const body = (await res.json()) as { assignments?: DayAssignment[] };
return body.assignments ?? [];
}
export async function ensureTourOnDay(api: APIRequestContext): Promise<TourOnDay> {
const { tripId, dayIds } = seededTrip();
const names = tourNames();
if (!names.length) throw new Error('the Tours fixtures export no tour names');
for (let i = 0; i < dayIds.length; i++) {
const onDay = (await assignmentsOf(api, tripId, dayIds[i])).find((a) => names.includes(a.place?.name ?? ''));
if (onDay?.place?.name) return { day: i + 1, name: onDay.place.name, created: null };
}
const res = await api.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 ?? []);
const tours = places.filter((p) => names.includes(p.name));
const tour = tours.find((p) => /fushimi/i.test(p.name)) ?? tours[0];
if (!tour) throw new Error(`none of the tours (${names.join(', ')}) is in the trip`);
const dayId = dayIds[4];
const created = await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: tour.id } });
if (!created.ok())
throw new Error(`could not put "${tour.name}" on day 5: ${created.status()} ${await created.text()}`);
const { assignment } = (await created.json()) as { assignment: { id: number } };
return { day: 5, name: tour.name, created: { dayId, id: assignment.id } };
}
export async function removeTourFromDay(api: APIRequestContext, onDay: TourOnDay | null): Promise<void> {
if (!onDay?.created) return;
const { tripId } = seededTrip();
await api.delete(`/api/trips/${tripId}/days/${onDay.created.dayId}/assignments/${onDay.created.id}`);
}
+58
View File
@@ -0,0 +1,58 @@
import type { APIRequestContext } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { coverFixture, seededTrip } from './fixtures';
/**
* What the place card's own guides need beyond the shared fixtures: a photo
* already attached to a place, so the round picture offers From attached files.
* The cover drawing is a mountain ridge at sunset, which is what an evening on
* Mount Inari looks like, so it goes onto Fushimi Inari Taisha.
*/
export const PHOTO_PLACE = 'Fushimi Inari Taisha';
export const PHOTO_FILE = 'inari-evening.jpg';
type ApiFile = { id: number; original_name: string; place_id?: number | null };
async function tripFiles(api: APIRequestContext): Promise<ApiFile[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/files`);
if (!res.ok()) throw new Error(`could not list the trip's files: ${res.status()}`);
const body = (await res.json()) as { files?: ApiFile[] };
return body.files ?? [];
}
async function placeId(api: APIRequestContext, name: string): Promise<number> {
const { tripId } = seededTrip();
const res = await api.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 ?? []);
const found = places.find((p) => p.name === name);
if (!found) throw new Error(`the seeded place "${name}" is missing`);
return found.id;
}
/** Attach the photo to the place, once. */
export async function ensurePlacePhoto(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const id = await placeId(api, PHOTO_PLACE);
if ((await tripFiles(api)).some((f) => f.original_name === PHOTO_FILE && f.place_id === id)) return;
const res = await api.post(`/api/trips/${tripId}/files`, {
multipart: {
file: { name: PHOTO_FILE, mimeType: 'image/jpeg', buffer: readFileSync(await coverFixture()) },
place_id: String(id),
},
});
if (!res.ok()) throw new Error(`could not attach ${PHOTO_FILE}: ${res.status()} ${await res.text()}`);
}
/** Take the photo off the trip again, trash included, and the picture made from it. */
export async function removePlacePhoto(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const id = await placeId(api, PHOTO_PLACE);
await api.put(`/api/trips/${tripId}/places/${id}`, { data: { image_url: null } });
for (const file of (await tripFiles(api)).filter((f) => f.original_name === PHOTO_FILE)) {
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { APIRequestContext } from '@playwright/test';
import { ensureDawarichConnection, toggleAddon } from './external';
/**
* What the places column's own guides need beyond the seed and `fixtures.ts`.
* Not a `.guide.ts`, so importing it registers no tests.
*/
interface Suggestion {
id: number;
state: string;
name: string;
acceptedPlaceId: number | null;
}
async function tripSuggestions(api: APIRequestContext, tripId: number): Promise<Suggestion[]> {
const res = await api.get(`/api/integrations/dawarich/suggestions?tripId=${tripId}`);
if (!res.ok()) throw new Error(`could not list the Dawarich stays: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { suggestions?: Suggestion[] };
return body.suggestions ?? [];
}
/**
* Dawarich connected and its stays of the seeded trip read into TREK, at
* least two of them waiting, so the guide can take one over and put another
* aside. The connection keeps automatic checks off (`ensureDawarichConnection`);
* this asks once, by hand, the way Check now does. What an earlier attempt
* accepted or dismissed is taken back first, so a rerun starts from the same list.
*/
export async function ensureDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
await ensureDawarichConnection(api);
const synced = await api.post('/api/integrations/dawarich/sync');
if (!synced.ok())
throw new Error(`could not read the stays from Dawarich: ${synced.status()} ${await synced.text()}`);
await undoDawarichStays(api, tripId);
const waiting = (await tripSuggestions(api, tripId)).filter((s) => s.state === 'new');
if (waiting.length < 2) {
throw new Error(
`Dawarich gave ${waiting.length} waiting stays for the trip; the guide needs two. ` +
'Does the instance detect visits in the uploaded recording (Dawarich > Visits)?'
);
}
}
/** Puts every dismissed stay of the trip back into review. */
export async function resetDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
for (const s of await tripSuggestions(api, tripId)) {
if (s.state !== 'dismissed') continue;
const res = await api.put(`/api/integrations/dawarich/suggestions/${s.id}/state`, { data: { state: 'new' } });
if (!res.ok()) throw new Error(`could not put back the stay ${s.name}: ${res.status()} ${await res.text()}`);
}
}
/**
* Tours is on for every install, and with it on the places column has no
* Import file. The file guide pictures the column of an install that switched
* it off, and switches it back on afterwards.
*/
export const setTours = (api: APIRequestContext, enabled: boolean): Promise<void> => toggleAddon(api, 'tours', enabled);
/**
* Takes back what the Dawarich guide did: the places it accepted are deleted,
* which returns their stays to review on the server, and the dismissed stays
* are put back. By the ids the stays carry, never by name: a stay is often
* named like a place of the seed, and deleting by name would take that one too.
*/
export async function undoDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
for (const s of await tripSuggestions(api, tripId)) {
if (s.state !== 'accepted' || s.acceptedPlaceId == null) continue;
const res = await api.delete(`/api/trips/${tripId}/places/${s.acceptedPlaceId}`);
if (!res.ok() && res.status() !== 404)
throw new Error(`could not delete the place from ${s.name}: ${res.status()} ${await res.text()}`);
}
await resetDawarichStays(api, tripId);
}
+337
View File
@@ -0,0 +1,337 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { at, day } from '../dates';
import { seededTrip } from './fixtures';
import { PICTURE_DAY } from './guide';
/**
* What a few road trip guides need beyond `ensureRoadtripFixtures`, each one
* written by the guide that needs it and taken away again in its cleanup: the
* other guides of the screen draw the same rail, and a fuel stop or a ferry
* left behind would change their drive.
*
* Day N of "Autumn in Japan" is `day(N - 10)`: the trip ends on the picture day.
*/
type Place = { id: number; name: string };
type Assignment = { id: number; place_id: number };
async function placesOf(api: APIRequestContext): Promise<Place[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/places`);
const body = (await res.json()) as { places?: Place[] } | Place[];
return Array.isArray(body) ? body : (body.places ?? []);
}
export async function assignmentsOn(api: APIRequestContext, dayIndex: number): Promise<Assignment[]> {
const { tripId, dayIds } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`);
const body = (await res.json()) as { assignments?: Assignment[] };
return body.assignments ?? [];
}
export async function placeIdOf(api: APIRequestContext, name: string): Promise<number | null> {
return (await placesOf(api)).find((p) => p.name === name)?.id ?? null;
}
// ── A fuel stop on day 4, for the fill-level guide ───────────────────────────
/** A motorway service area on the Tomei, between Tokyo and Hakone. */
export const FUEL_STOP = {
name: 'Ebina Service Area',
lat: 35.4178,
lng: 139.3955,
address: 'Ebina, Kanagawa',
description: 'Fuel and food on the Tomei Expressway, halfway to Hakone.',
stop_type: 'fuel',
};
/**
* Put the service area on day 4 between Shinjuku Gyoen and Hakone Shrine, the
* leg it really lies on. Idempotent: an existing one is only put back in place.
*/
export async function ensureFuelStop(api: APIRequestContext): Promise<void> {
const { tripId, dayIds } = seededTrip();
let id = await placeIdOf(api, FUEL_STOP.name);
if (id === null) {
const created = await api.post(`/api/trips/${tripId}/places`, { data: FUEL_STOP });
if (!created.ok())
throw new Error(`could not create "${FUEL_STOP.name}": ${created.status()} ${await created.text()}`);
id = await placeIdOf(api, FUEL_STOP.name);
}
if (id === null) throw new Error(`"${FUEL_STOP.name}" is missing after creating it`);
if (!(await assignmentsOn(api, 3)).some((a) => a.place_id === id)) {
const pinned = await api.post(`/api/trips/${tripId}/days/${dayIds[3]}/assignments`, { data: { place_id: id } });
if (!pinned.ok())
throw new Error(`could not put the fuel stop on day 4: ${pinned.status()} ${await pinned.text()}`);
}
// Right before Hakone Shrine, so the drive is Shinjuku, the service area, Hakone.
const hakone = await placeIdOf(api, 'Hakone Shrine');
const day4 = await assignmentsOn(api, 3);
const fuel = day4.find((a) => a.place_id === id);
const rest = day4.filter((a) => a !== fuel);
const slot = rest.findIndex((a) => a.place_id === hakone);
if (fuel && slot >= 0) {
const orderedIds = [...rest.slice(0, slot), fuel, ...rest.slice(slot)].map((a) => a.id);
await api.put(`/api/trips/${tripId}/days/${dayIds[3]}/assignments/reorder`, { data: { orderedIds } });
}
}
export async function removeFuelStop(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const id = await placeIdOf(api, FUEL_STOP.name);
if (id !== null) await api.delete(`/api/trips/${tripId}/places/${id}`);
}
// ── A ferry on day 3, for the bookings guide ─────────────────────────────────
/** The boat across Lake Ashi, between the two Hakone stops of day 3. */
export const FERRY = {
title: 'Lake Ashi cruise Togendai → Moto-Hakone',
from: { name: 'Togendai Port', lat: 35.2386, lng: 139.0128, time: '11:50' },
to: { name: 'Moto-Hakone Port', lat: 35.1973, lng: 139.025, time: '12:25' },
};
type Booking = { id: number; title: string };
async function bookingsOf(api: APIRequestContext): Promise<Booking[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/reservations`);
const body = (await res.json()) as { reservations?: Booking[] } | Booking[];
return Array.isArray(body) ? body : (body.reservations ?? []);
}
/**
* A ferry booking with both piers located, so the drive ends at one and starts
* again at the other. 11:50 leaves the half hour of check-in a ferry is pinned
* ahead of the timetable after the stop at Lake Ashi, so the badge reads on time.
*/
export async function ensureFerry(api: APIRequestContext): Promise<void> {
if ((await bookingsOf(api)).some((b) => b.title === FERRY.title)) return;
const { tripId, dayIds } = seededTrip();
const created = await api.post(`/api/trips/${tripId}/reservations`, {
data: {
title: FERRY.title,
type: 'ferry',
status: 'confirmed',
day_id: dayIds[2],
end_day_id: dayIds[2],
reservation_time: at(-7, FERRY.from.time),
reservation_end_time: at(-7, FERRY.to.time),
confirmation_number: 'HKN-3307',
location: FERRY.from.name,
endpoints: [
{
role: 'from',
sequence: 0,
name: FERRY.from.name,
lat: FERRY.from.lat,
lng: FERRY.from.lng,
timezone: 'Asia/Tokyo',
local_date: day(-7),
local_time: FERRY.from.time,
},
{
role: 'to',
sequence: 1,
name: FERRY.to.name,
lat: FERRY.to.lat,
lng: FERRY.to.lng,
timezone: 'Asia/Tokyo',
local_date: day(-7),
local_time: FERRY.to.time,
},
],
},
});
if (!created.ok()) throw new Error(`could not create the ferry: ${created.status()} ${await created.text()}`);
}
export async function removeFerry(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
for (const booking of (await bookingsOf(api)).filter((b) => b.title === FERRY.title)) {
await api.delete(`/api/trips/${tripId}/reservations/${booking.id}`);
}
}
// ── A night found along the route, taken away again ──────────────────────────
/** Cancel the stays booked for a place, with their bookings, then the place. */
export async function removeNightAt(api: APIRequestContext, name: string): Promise<void> {
if (!name) return;
const { tripId } = seededTrip();
const id = await placeIdOf(api, name);
if (id === null) return;
const res = await api.get(`/api/trips/${tripId}/accommodations`);
const body = (await res.json()) as { accommodations?: { id: number; place_id: number }[] };
for (const stay of (body.accommodations ?? []).filter((a) => a.place_id === id)) {
await api.delete(`/api/trips/${tripId}/accommodations/${stay.id}`);
}
await api.delete(`/api/trips/${tripId}/places/${id}`);
}
// ── The Google Maps import, answered without asking Google ───────────────────
/** A route from Hakone round Mount Fuji, the way the preview reads one. */
export const GOOGLE_ROUTE = {
url: 'https://maps.app.goo.gl/hakoneFujiFiveLakes',
stops: [
{ name: 'Gotemba Premium Outlets', lat: 35.3076, lng: 138.964 },
{ name: 'Oshino Hakkai', lat: 35.4602, lng: 138.8327 },
{ name: 'Lake Kawaguchi', lat: 35.5171, lng: 138.7519 },
{ name: 'Fuji viewpoint (dropped pin)', lat: null, lng: null },
],
};
/**
* The preview is the one request that goes to Google, so it is answered here;
* the import itself is TREK's own and runs for real.
*/
export async function stubGooglePreview(page: Page): Promise<void> {
await page.route('**/api/roadtrip/google-maps-preview', (route) =>
route.fulfill({ status: 200, json: { stops: GOOGLE_ROUTE.stops } })
);
}
export async function removeImportedStops(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const names = GOOGLE_ROUTE.stops.filter((s) => s.lat !== null).map((s) => s.name);
for (const place of (await placesOf(api)).filter((p) => names.includes(p.name))) {
await api.delete(`/api/trips/${tripId}/places/${place.id}`);
}
}
// ── Current warnings, answered without the live feeds ────────────────────────
/** A storm over Hakone and the Izu peninsula, the shape GDACS reports one in. */
export const HAZARD_TITLE = 'Tropical storm, green alert';
/**
* The warnings come live from DWD and GDACS, and neither has to have anything
* near Japan on the day the pictures are taken. The feed is answered here.
*/
export async function stubHazards(page: Page): Promise<void> {
const now = PICTURE_DAY.toISOString();
const until = new Date(PICTURE_DAY.getTime() + 36 * 3600_000).toISOString();
// On the context, not the page: in dev the service worker makes this GET itself,
// and only the context's routes see what a service worker asks for.
await page.context().route('**/api/trips/*/roadtrip/hazards*', (route) =>
route.fulfill({
status: 200,
json: {
fetchedAt: now,
sources: [
{ source: 'DWD', status: 'ok' },
{ source: 'GDACS', status: 'ok' },
],
hazards: [
{
id: 'gdacs-tc-demo',
source: 'GDACS',
title: HAZARD_TITLE,
description: 'Heavy rain and gusts of up to 90 km/h around Hakone and the Izu peninsula.',
updatedAt: now,
validUntil: until,
url: 'https://www.gdacs.org/',
alertScore: 0.5,
geometry: {
type: 'Polygon',
coordinates: [
[
[138.82, 35.02],
[139.18, 34.98],
[139.36, 35.16],
[139.3, 35.4],
[138.98, 35.46],
[138.78, 35.26],
[138.82, 35.02],
],
],
},
},
],
},
})
);
}
export async function hideHazards(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
await api.put(`/api/trips/${tripId}/roadtrip/preferences`, { data: { roadtrip_show_hazards: false } });
}
// ── The flight out, as the drive reads it ────────────────────────────────────
/** The seed's flight; it leaves Frankfurt on day 1, after day 1's first stops in Tokyo. */
const FLIGHT_TITLE = 'LH716 FRA → HND';
type Endpoint = {
role: string;
sequence: number;
name: string;
code?: string | null;
lat: number;
lng: number;
timezone?: string | null;
local_date?: string | null;
local_time?: string | null;
};
type FlightRow = {
id: number;
title: string;
reservation_time: string | null;
reservation_end_time: string | null;
day_id: number | null;
end_day_id: number | null;
endpoints?: Endpoint[];
};
let flightAsSeeded: FlightRow | null = null;
async function flightOf(api: APIRequestContext): Promise<FlightRow | null> {
return ((await bookingsOf(api)) as unknown as FlightRow[]).find((b) => b.title === FLIGHT_TITLE) ?? null;
}
async function putFlight(api: APIRequestContext, id: number, data: Partial<FlightRow>): Promise<void> {
const { tripId } = seededTrip();
const res = await api.put(`/api/trips/${tripId}/reservations/${id}`, { data });
if (!res.ok()) throw new Error(`could not move the flight: ${res.status()} ${await res.text()}`);
}
/**
* The drive starts where the flight lands. The seed has it leave Frankfurt at
* 13:05 on day 1, after Senso-ji, so the road trip would drive from Tokyo to
* Frankfurt and back. For these pictures it is the overnight flight it should
* be, into Haneda at 06:50 on day 1, and only its Haneda end is located.
*/
export async function overnightFlight(api: APIRequestContext): Promise<void> {
const flight = await flightOf(api);
if (!flight) throw new Error(`no booking "${FLIGHT_TITLE}" to move overnight`);
flightAsSeeded ??= flight;
const ends = flight.endpoints ?? [];
await putFlight(api, flight.id, {
reservation_time: at(-10, '17:55'),
reservation_end_time: at(-9, '06:50'),
// The server keeps a booking on the trip's first day even when it leaves the
// evening before, so the drive would still start in Frankfurt. A ride without a
// located end at both sides is left to the day plan, so the Frankfurt end goes.
endpoints: ends.filter((e) => e.role !== 'from').map((e) => ({ ...e, local_date: day(-9), local_time: '06:50' })),
});
const moved = await flightOf(api);
if ((moved?.endpoints ?? []).some((e) => e.role === 'from')) {
throw new Error(`the flight still starts in Frankfurt: ${JSON.stringify(moved?.endpoints)}`);
}
}
/** The flight back as the seed has it, for the screens after these. */
export async function restoreFlight(api: APIRequestContext): Promise<void> {
const seeded = flightAsSeeded;
if (!seeded) return;
await putFlight(api, seeded.id, {
reservation_time: seeded.reservation_time,
reservation_end_time: seeded.reservation_end_time,
day_id: seeded.day_id,
end_day_id: seeded.end_day_id,
endpoints: seeded.endpoints,
});
flightAsSeeded = null;
}
@@ -0,0 +1,190 @@
import type { APIRequestContext } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { short } from '../dates';
import { drawPdf, seededTrip } from './fixtures';
import { TOUR_NAMES, tourDayOffset } from './tour-fixtures';
/**
* What the tour card's guides need beyond `ensureToursFixtures`: a packing
* category of the via ferrata gear for the ferrata tour to be linked to (two
* of its items packed, so the bar is partly full), the hut booking a guide
* uploads to the hut trek, and the small API helpers the guides clean up with.
* Everything here is idempotent: it finds its rows by name before it makes them.
*/
/** The packing category the ferrata tour gets linked to in `tour-packing-link`. */
export const FERRATA_PACKING = {
category: 'Via ferrata',
items: ['Helmet', 'Climbing harness', 'Via ferrata set', 'Leather gloves', 'Head torch'],
packed: ['Helmet', 'Climbing harness'],
};
/** The confirmation the files guide puts next to the hut trek. */
export const HUT_BOOKING_FILE = 'rifugio-puez-half-board.pdf';
type Res = { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> };
async function json<T>(res: Res, what: string): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 500)}`);
return (await res.json()) as T;
}
interface TourRow {
place_id: number;
name: string;
}
export async function toursOf(api: APIRequestContext, tripId: number): Promise<TourRow[]> {
return (await json<{ tours: TourRow[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
.tours;
}
/** A tour's place id by its name; throws when the fixtures did not make it. */
export async function tourIdOf(api: APIRequestContext, tripId: number, name: string): Promise<number> {
const found = (await toursOf(api, tripId)).find((t) => t.name === name);
if (!found) throw new Error(`no tour "${name}" in trip ${tripId}: ensureToursFixtures did not make it`);
return found.place_id;
}
interface PackingItem {
id: number;
name: string;
category: string | null;
checked?: number | boolean;
}
async function packingOf(api: APIRequestContext, tripId: number): Promise<PackingItem[]> {
const body = await json<{ items?: PackingItem[] } | PackingItem[]>(
await api.get(`/api/trips/${tripId}/packing`),
'packing'
);
return Array.isArray(body) ? body : (body.items ?? []);
}
/**
* The via ferrata gear in the Dolomites packing list, two of it packed, and
* the ferrata tour without a packing link, which is where the guide starts.
*/
export async function ensureTourDetailFixtures(api: APIRequestContext, tripId: number): Promise<void> {
let items = await packingOf(api, tripId);
for (const name of FERRATA_PACKING.items) {
if (items.some((i) => i.name === name && i.category === FERRATA_PACKING.category)) continue;
await json(
await api.post(`/api/trips/${tripId}/packing`, {
data: { name, category: FERRATA_PACKING.category, visibility: 'common' },
}),
`packing item ${name}`
);
}
items = await packingOf(api, tripId);
for (const name of FERRATA_PACKING.packed) {
const item = items.find((i) => i.name === name && i.category === FERRATA_PACKING.category);
if (item && !item.checked) {
await json(await api.put(`/api/trips/${tripId}/packing/${item.id}`, { data: { checked: true } }), `pack ${name}`);
}
}
await unlinkPacking(api, tripId, await tourIdOf(api, tripId, TOUR_NAMES.ferrata));
}
/** Take a tour's packing link away; a tour without one is left as it is. */
export async function unlinkPacking(api: APIRequestContext, tripId: number, placeId: number): Promise<void> {
const res = await api.delete(`/api/trips/${tripId}/tours/${placeId}/packing-link`);
if (!res.ok() && res.status() !== 404)
throw new Error(`unlink packing: ${res.status()} ${(await res.text()).slice(0, 300)}`);
}
/** Delete every tour of the trip with this name (the copies a guide made). Tours are places. */
export async function deleteToursNamed(api: APIRequestContext, tripId: number, name: string): Promise<void> {
for (const tour of (await toursOf(api, tripId)).filter((t) => t.name === name)) {
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
/** Take the tours of this name out of a collection again. */
export async function unkeepTour(api: APIRequestContext, collectionId: number, name: string): Promise<void> {
const res = await api.get(`/api/addons/collections/${collectionId}/tours`);
if (!res.ok()) return;
const { tours = [] } = (await res.json()) as { tours?: { id: number; name: string }[] };
for (const kept of tours.filter((t) => t.name === name)) {
await api.delete(`/api/addons/collections/${collectionId}/tours/${kept.id}`);
}
}
/** Remove an uploaded file from the trip, out of the bin as well. */
export async function deleteTripFilesNamed(api: APIRequestContext, tripId: number, name: string): Promise<void> {
const res = await api.get(`/api/trips/${tripId}/files`);
if (!res.ok()) return;
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] };
for (const file of files.filter((f) => f.original_name === name)) {
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
}
}
/** The Fushimi Inari hike of the Japan trip and its linked recordings, as the tour detail reports them. */
async function inariActivities(
api: APIRequestContext
): Promise<{ hikeId: number; activities: { id: number; track: { place_id: number } }[] }> {
const { tripId } = seededTrip();
const hikeId = await tourIdOf(api, tripId, TOUR_NAMES.inariHike);
const detail = await json<{ activities?: { id: number; track: { place_id: number } }[] }>(
await api.get(`/api/trips/${tripId}/tours/${hikeId}`),
'the Fushimi Inari hike'
);
return { hikeId, activities: detail.activities ?? [] };
}
/**
* Unlink the recording from the planned hike, so `tour-activity-link` starts
* from a tour with no actual track. The recording itself stays in the trip as
* an import, which is what the guide then picks.
*/
export async function unlinkInariRecording(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const { hikeId, activities } = await inariActivities(api);
for (const activity of activities) {
await json(
await api.delete(`/api/trips/${tripId}/tours/${hikeId}/activities/${activity.id}`),
'unlink the recording'
);
}
}
/**
* Take the recording out of the Japan trip again once the activity guides are
* done: `ensureTourRecording` is opt-in because the recording is one more
* tour in a trip every other screen photographs.
*/
export async function removeInariRecording(api: APIRequestContext): Promise<void> {
try {
await unlinkInariRecording(api);
} catch (err) {
console.warn(`[tour-detail] the recording could not be unlinked before it is removed: ${String(err)}`);
}
await deleteToursNamed(api, seededTrip().tripId, TOUR_NAMES.inariRecording);
}
/** The hut's half-board confirmation, drawn on every call so its date follows the trip. */
export function hutBookingFixture(): string {
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, HUT_BOOKING_FILE);
writeFileSync(
file,
drawPdf({
issuer: 'Rifugio Puez',
title: 'Booking confirmation, half board',
reference: 'Booking PZ-2417, Puez-Odle Nature Park, 2475 m',
fields: [
['Guests', '2 adults, alpine club members'],
['Night', `${short(tourDayOffset(4))}, one night`],
['Room', 'Dormitory, 6 beds, 2 places'],
['Board', 'Dinner at 18:30, breakfast from 06:30'],
['Deposit', 'EUR 40 paid, rest in cash at the hut'],
],
footer: 'Cancel by phone up to 48 hours before arrival. No card payment at the hut.',
})
);
return file;
}
+148
View File
@@ -0,0 +1,148 @@
import { expect, type Page } from '@playwright/test';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, settle } from './guide';
import { JAPAN_TRIP, TOUR_TRIP } from './tour-fixtures';
import { tripReady } from './trip-shared';
/**
* What the hike and run help pictures need besides `tour-fixtures.ts`: a way
* into the Tours tab of the Dolomites or the Japan trip, a tour opened in the
* editor, a route that has finished planning, and the tours a guide saved
* taken away again. Nothing here creates data of its own; the two tours the
* plan guides save through the UI are deleted by name in their cleanup.
*/
/** The two tours the plan guides save, by the names they type. */
export const SAVED_BY_GUIDES = {
hike: 'Up to Rifugio Firenze',
run: 'Trail run to Rifugio Firenze',
} as const;
async function tripIdOf(page: Page, title: string): Promise<number> {
const res = await page.request.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
const found = trips.find((t) => t.title === title);
if (!found) throw new Error(`no trip "${title}": call ensureToursFixtures in beforeAll`);
return found.id;
}
/**
* The Tours tab of a trip, with its list on the right loaded. `tripReady`
* waits for the Trip settings pill, which every tab of the v5 header carries.
* The account's draft of the trip goes first: the planner restores it on load,
* so a draft the guide before left behind (its page can still write one in the
* second after its cleanup) would open the editor on that tour instead of the
* neutral rail, and a click on a tour row would then ask to discard it.
*/
export async function openToursTab(page: Page, trip: 'summer' | 'japan' = 'summer'): Promise<number> {
const id = await tripIdOf(page, trip === 'japan' ? JAPAN_TRIP : TOUR_TRIP);
await page.request.delete(`/api/trips/${id}/tour-draft`).catch(() => undefined);
await page.goto(`/trips/${id}?tab=tour-planner`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await expect(toursRail(page).getByRole('option').first()).toBeVisible({ timeout: 30_000 });
await settle(page);
await page.waitForTimeout(1200);
return id;
}
/** The left column: the neutral rail and the editor both carry this name. */
export const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
/** The right column, Tours of this trip. */
export const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip', exact: true });
/** A tour's row in Tours of this trip. */
export const tourRow = (page: Page, name: string) =>
toursRail(page)
.getByRole('option', { name: new RegExp(`^${name}`) })
.first();
/** Words the status dot beside the editor's title carries while it is still working. */
const BUSY = /^(Calculating[a-z ]* route|Loading elevation|Waiting to calculate|Saving)/;
/** The route of the open tour has been planned and its heights are in. */
export async function routeSettled(page: Page, timeout = 120_000): Promise<void> {
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout });
await expect(page.getByTestId('tour-planner-status').getByLabel(BUSY)).toHaveCount(0, { timeout });
await settle(page);
}
/** Open a saved tour from the right column in the editor and wait for its route. */
export async function openTour(page: Page, name: string): Promise<void> {
await tourRow(page, name).click();
await expect(editorRail(page).getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('#tour-planner-name')).toHaveValue(name, { timeout: 30_000 });
await routeSettled(page);
await page.waitForTimeout(800);
await settle(page);
}
/** Plan new tour from the neutral rail, and the editor that opens. */
export async function startNewTour(page: Page): Promise<void> {
await editorRail(page).getByRole('button', { name: 'Plan new tour' }).click();
await expect(editorRail(page).getByRole('heading', { name: 'New tour', exact: true })).toBeVisible({
timeout: 15_000,
});
await settle(page);
}
/** Pick a tour type in the Tour type field. */
export async function pickTourType(page: Page, type: 'hike' | 'run'): Promise<void> {
const option = page.getByTestId(`tour-type-option-${type}`);
if (!(await option.isVisible().catch(() => false))) await page.getByTestId('tour-type-select').click();
await option.click();
await settle(page);
}
/** Add stops from the trip's own places, through Trip places under the waypoints. */
export async function addTripPlaces(page: Page, ...names: string[]): Promise<void> {
const picker = page.getByTestId('trip-place-picker');
if (!(await picker.isVisible().catch(() => false))) await page.getByTestId('tour-planner-add-place').click();
await expect(picker).toBeVisible();
const search = picker.getByPlaceholder("Search the trip's places");
for (const name of names) {
await search.fill(name);
await picker.getByTestId('trip-place-candidate').filter({ hasText: name }).first().click();
await page.waitForTimeout(400);
}
await search.fill('');
await picker.getByRole('button', { name: 'Close', exact: true }).click();
await expect(picker).toHaveCount(0);
}
/** The Details section of the editor, unfolded. */
export async function openDetails(page: Page): Promise<void> {
const show = editorRail(page).getByRole('button', { name: 'Show details', exact: true });
if (await show.isVisible().catch(() => false)) await show.click();
await expect(page.locator('#tour-planner-details')).toBeVisible();
}
/** Delete the tours a guide saved through the UI, so the next guide finds the list of the seed. */
export async function deleteToursNamed(page: Page, tripId: number, ...names: string[]): Promise<void> {
const res = await page.request.get(`/api/trips/${tripId}/tours`);
if (!res.ok()) return;
const { tours = [] } = (await res.json()) as { tours?: { place_id: number; name: string }[] };
for (const tour of tours.filter((t) => names.includes(t.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
/**
* Forget the editor's unsaved draft of the trip: on the account, where the
* planner keeps it now and restores it on the next page, and the copy older
* builds left in this browser, so a reload starts clean.
*/
export async function forgetDraft(page: Page, tripId: number): Promise<void> {
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
await page
.evaluate((id) => {
try {
localStorage.removeItem(`tour-draft-${id}`);
} catch {
// Storage blocked: nothing was kept either.
}
}, tripId)
.catch(() => undefined);
}
+404
View File
@@ -0,0 +1,404 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
import { TOUR_GPX, TOUR_NAMES, TOUR_TRIP, tourGpxFixture } from './tour-fixtures';
import { closeDayDetails, selectDay, tripReady } from './trip-shared';
/**
* What the horse ride guides share: the way into the Tours tab with the
* Seiser Alm in view, the horse routes the Horse routes card finds there, and
* the GPX file of a recorded ride. Not a `.guide.ts`, so importing it
* registers no tests.
*
* The horse routes come from OpenStreetMap through the server (TREK Places,
* else Overpass), and a fresh database has neither cached. What the area has
* is asked once, in `beforeAll`: when it holds a section long enough to ride
* and a second one a short way off its end (the gap guide needs both), that
* answer is what the card is given for the whole run, so every picture shows
* the same list. When it does not, the card is given two routes cut from the
* ride the fixtures saved on the Seiser Alm, whose line follows real ways
* (a horse route section where one was found, farm tracks otherwise).
*/
type LatLng = [number, number];
interface HorseSection {
number: number;
geometry: LatLng[];
length_m: number;
access: { value: string; length_m: number }[];
signposted: 'with' | 'against' | null;
}
interface HorseRoute {
id: string;
name: string | null;
ref: string | null;
osmUrl: string;
continues: boolean;
sections: HorseSection[];
}
interface HorseAnswer {
routes: HorseRoute[];
truncated: boolean;
source: 'openstreetmap';
attribution: string;
}
/** Which route and section each guide takes, by the test ids the card and the dialog carry. */
export interface HorsePick {
routeId: string;
section: number;
}
export interface HorsePlan {
answer: HorseAnswer;
/** The section the ride starts with. */
first: HorsePick;
/** A section that starts away from the end of `first`, for the gap guide. */
gap: HorsePick;
/** Where the answer came from, for the run's log. */
source: 'live' | 'demo';
}
/** The name the plan guide saves its new ride under; its cleanup deletes it. */
export const NEW_RIDE = 'Morning ride on the Seiser Alm';
/** The recorded ride the import guide brings in, and its file. */
export const RECORDED_RIDE = { name: 'Rasciesa ride', file: 'rasciesa-ride.gpx' };
// ── The trip ──────────────────────────────────────────────────────────────────
export async function tourTripId(api: APIRequestContext): Promise<number> {
const res = await api.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
const found = trips.find((t) => t.title === TOUR_TRIP);
if (!found) throw new Error(`no trip "${TOUR_TRIP}": call ensureToursFixtures in beforeAll`);
return found.id;
}
interface TourListRow {
place_id: number;
name: string;
tour_type?: string;
}
export async function toursOfTrip(api: APIRequestContext, tripId: number): Promise<TourListRow[]> {
const res = await api.get(`/api/trips/${tripId}/tours`);
if (!res.ok()) return [];
return ((await res.json()) as { tours?: TourListRow[] }).tours ?? [];
}
/** Delete the tours a guide made, by name. A tour is a place, and goes the way a place goes. */
export async function deleteToursNamed(api: APIRequestContext, ...names: string[]): Promise<void> {
const tripId = await tourTripId(api);
for (const tour of (await toursOfTrip(api, tripId)).filter((t) => names.includes(t.name))) {
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
/** Drop the caller's unsaved editor draft of the trip; a trip without one answers 404, which is fine. */
export async function forgetTourDraft(api: APIRequestContext, tripId?: number): Promise<void> {
const id = tripId ?? (await tourTripId(api));
const res = await api.delete(`/api/trips/${id}/tour-draft`);
if (!res.ok() && res.status() !== 404)
console.warn(`[trip-tour-horse] could not drop the tour draft: ${res.status()}`);
}
const rawKey = (page: Page) =>
page.getByText(/^(common|dayplan|planner|places|trip|day|tours|packing)\.[A-Za-z0-9.]+$/);
/** The trip tab bar's Tours tab: a plain button named after the tab. */
export const toursTabButton = (page: Page) => page.getByRole('button', { name: 'Tours', exact: true }).first();
export const tripTours = (page: Page) => page.getByRole('listbox', { name: 'Tours of this trip' });
export const tourRow = (page: Page, name: string) =>
tripTours(page).getByRole('option').filter({ hasText: name }).first();
/**
* Let a pan of the map land. Leaflet times it from `+new Date()` and the clock
* is pinned to the picture day, so a pan starts and never gets anywhere; moving
* the pinned clock on a few seconds ends it, as `finishMapPan` does on the
* road trip screen.
*/
export async function finishMapPan(page: Page): Promise<void> {
await page.waitForTimeout(400);
await nudgeClock(page);
await page.waitForTimeout(1200);
await settle(page);
}
/**
* Open the Dolomites trip on the Tours tab with the Seiser Alm in view.
*
* A new draft keeps the map where it is, and the planner opens on the area the
* plan map showed last (`usePlannerFocusPoints`), so the plan is opened on day 2,
* the day of the horse ride, and the Tours tab is reached by its button rather
* than by the address: a page load would forget that area.
*
* The editor's unsaved draft is kept on the account per trip now, not in the
* browser, so a draft another guide left on this trip comes back into the left
* rail on load: Plan new tour is then gone, and a click on a saved tour asks
* first whether to discard it. It is dropped before the page opens.
*/
export async function openToursTab(page: Page): Promise<number> {
const tripId = await tourTripId(page.request);
await forgetTourDraft(page.request, tripId);
await page.goto(`/trips/${tripId}`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
await page.waitForTimeout(4000);
await page.reload();
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
await selectDay(page, 2);
await closeDayDetails(page);
await finishMapPan(page);
await toursTabButton(page).click();
await expect(tripTours(page)).toBeVisible({ timeout: 30_000 });
await expect(
tourRow(page, TOUR_NAMES.horse),
`no "${TOUR_NAMES.horse}": did ensureToursFixtures make it?`
).toBeVisible({
timeout: 30_000,
});
await finishMapPan(page);
return tripId;
}
/** The saved ride open in the editor, its line framed. */
export async function openSavedRide(page: Page): Promise<void> {
await openToursTab(page);
const row = tourRow(page, TOUR_NAMES.horse);
await row.click();
await expect(row).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
await expect(page.getByRole('region', { name: 'Horse routes' })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await finishMapPan(page);
}
// ── The horse routes ──────────────────────────────────────────────────────────
const R = 6_371_000;
function metres(a: LatLng, b: LatLng): number {
const rad = Math.PI / 180;
const dLat = (b[0] - a[0]) * rad;
const dLng = (b[1] - a[1]) * rad;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function lengthOf(line: LatLng[]): number {
return line.slice(1).reduce((sum, p, i) => sum + metres(line[i], p), 0);
}
/** The stretch of `line` from `from` to `to`, as shares of its length. */
function cut(line: LatLng[], from: number, to: number): LatLng[] {
const total = lengthOf(line);
const out: LatLng[] = [];
let run = 0;
for (let i = 0; i < line.length; i++) {
if (i > 0) run += metres(line[i - 1], line[i]);
const share = run / total;
if (share >= from && share <= to) out.push(line[i]);
}
return out;
}
function lineOf(raw: unknown): LatLng[] {
const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((p): p is number[] => Array.isArray(p) && p.length >= 2)
.map((p) => [Number(p[0]), Number(p[1])] as LatLng);
}
/** The saved ride's line, from its detail or, failing that, from the trip's places. */
async function savedRideLine(api: APIRequestContext, tripId: number): Promise<LatLng[]> {
const ride = (await toursOfTrip(api, tripId)).find((t) => t.name === TOUR_NAMES.horse);
if (!ride) throw new Error(`no "${TOUR_NAMES.horse}": call ensureToursFixtures first`);
const detail = await api.get(`/api/trips/${tripId}/tours/${ride.place_id}`);
if (detail.ok()) {
const body = (await detail.json()) as { tour?: { route_geometry?: unknown }; route_geometry?: unknown };
const line = lineOf(body.tour?.route_geometry ?? body.route_geometry);
if (line.length >= 2) return line;
}
const res = await api.get(`/api/trips/${tripId}/places`);
const body = (await res.json()) as
{ places?: { id: number; route_geometry?: unknown }[] } | { id: number; route_geometry?: unknown }[];
const place = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.id === ride.place_id);
const line = lineOf(place?.route_geometry);
if (line.length < 2) throw new Error(`"${TOUR_NAMES.horse}" has no line to cut horse routes from`);
return line;
}
function bboxAround(line: LatLng[], pad: number): string {
const lats = line.map((p) => p[0]);
const lngs = line.map((p) => p[1]);
return [Math.min(...lats) - pad, Math.min(...lngs) - pad, Math.max(...lats) + pad, Math.max(...lngs) + pad]
.map((v) => v.toFixed(5))
.join(',');
}
/** Where a section starts when the dialog offers it to a ride ending at `end` (`preferReversed`). */
function offeredStart(section: HorseSection, end: LatLng | null): LatLng {
const first = section.geometry[0];
const last = section.geometry[section.geometry.length - 1];
if (!end) return section.signposted === 'against' ? last : first;
return metres(end, last) < metres(end, first) ? last : first;
}
function offeredEnd(section: HorseSection, end: LatLng | null): LatLng {
const start = offeredStart(section, end);
const first = section.geometry[0];
return start === first ? section.geometry[section.geometry.length - 1] : first;
}
/**
* The two picks the guides need from an answer, or null where it cannot give
* them: a first section of at least 600 m on an empty ride, and another
* section whose nearer end lies between 100 m and 3 km from where that one
* ends, so the dialog names a gap that is worth bridging by hand.
*/
function picksOf(answer: HorseAnswer): { first: HorsePick; gap: HorsePick } | null {
const all = answer.routes.flatMap((route) => route.sections.map((section) => ({ route, section })));
const first = all.filter((x) => x.section.length_m >= 600).sort((a, b) => b.section.length_m - a.section.length_m)[0];
if (!first) return null;
const end = offeredEnd(first.section, null);
let best: { pick: HorsePick; gap: number } | null = null;
for (const { route, section } of all) {
if (route.id === first.route.id && section.number === first.section.number) continue;
if (section.length_m < 300) continue;
const gap = metres(end, offeredStart(section, end));
if (gap > 100 && gap < 3000 && (!best || gap < best.gap))
best = { pick: { routeId: route.id, section: section.number }, gap };
}
if (!best) return null;
return { first: { routeId: first.route.id, section: first.section.number }, gap: best.pick };
}
/** Two routes along the saved ride's own line: two sections end to end, and one a short gap further on. */
function demoAnswer(line: LatLng[]): HorseAnswer {
const [lat, lng] = line[Math.floor(line.length / 2)];
const osmUrl = `https://www.openstreetmap.org/#map=14/${lat.toFixed(4)}/${lng.toFixed(4)}`;
const section = (
number: number,
from: number,
to: number,
access: [string, number][],
signposted: HorseSection['signposted']
): HorseSection => {
const geometry = cut(line, from, to);
const length = lengthOf(geometry);
return {
number,
geometry,
length_m: Math.round(length),
access: access.map(([value, share]) => ({ value, length_m: Math.round(length * share) })),
signposted,
};
};
return {
routes: [
{
id: 'relation/1',
name: 'Seiser Alm bridle path',
ref: null,
osmUrl,
continues: true,
sections: [
section(
1,
0,
0.38,
[
['designated', 0.8],
['unknown', 0.2],
],
'with'
),
section(2, 0.38, 0.62, [['yes', 1]], null),
],
},
{
id: 'relation/2',
name: 'Saltria horse trail',
ref: null,
osmUrl,
continues: false,
sections: [
section(
1,
0.72,
1,
[
['permissive', 0.6],
['unknown', 0.4],
],
null
),
],
},
],
truncated: false,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
};
}
/** The horse routes every guide of the screen gets, worked out once per run. */
export async function horsePlan(api: APIRequestContext): Promise<HorsePlan> {
const tripId = await tourTripId(api);
const line = await savedRideLine(api, tripId);
try {
const res = await api.get(`/api/trips/${tripId}/tours/horse-routes?bbox=${bboxAround(line, 0.02)}`, {
timeout: 60_000,
});
if (res.ok()) {
const answer = (await res.json()) as HorseAnswer;
const picks = answer.routes?.length ? picksOf(answer) : null;
if (picks) return { answer, ...picks, source: 'live' };
}
} catch {
// Neither TREK Places nor Overpass answered: the demo routes below.
}
const answer = demoAnswer(line);
const picks = picksOf(answer);
if (!picks) throw new Error(`"${TOUR_NAMES.horse}" is too short to cut two horse routes from`);
console.warn(
'[trip-tour-horse] no usable horse routes on the Seiser Alm; the card is given two cut from the saved ride'
);
return { answer, ...picks, source: 'demo' };
}
/** Hand the card this answer whatever view it asks for, so the list is the same in every picture. */
export async function serveHorseRoutes(page: Page, plan: HorsePlan): Promise<void> {
// On the context, not the page: in dev the service worker makes this GET itself,
// and only the context's routes see what a service worker asks for.
await page.context().route(/\/api\/trips\/\d+\/tours\/horse-routes/, (route) => route.fulfill({ json: plan.answer }));
}
/** A route's title as the card and the dialog print it. */
export function routeTitle(plan: HorsePlan, routeId: string): string {
const route = plan.answer.routes.find((r) => r.id === routeId);
return route?.name ?? route?.ref ?? 'Unnamed horse route';
}
// ── The recorded ride ─────────────────────────────────────────────────────────
/**
* A ride recorded on a GPS device, as a file to import: the recorded Rasciesa
* track of the fixtures, named as a ride. Its points, heights and times stay
* those of the recording.
*/
export async function recordedRideFile(): Promise<string> {
const source = await tourGpxFixture('rasciesa');
const file = path.join(path.dirname(source), RECORDED_RIDE.file);
const gpx = readFileSync(source, 'utf8').split(TOUR_GPX.rasciesa.name).join(RECORDED_RIDE.name);
writeFileSync(file, gpx);
return file;
}
@@ -0,0 +1,203 @@
import type { APIRequestContext } from '@playwright/test';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { TOUR_GPX, TOUR_NAMES, tourGpxFixture } from './tour-fixtures';
/**
* What the "getting tours in" guides (`trip-tour-import.guide.ts`) need on top
* of `ensureToursFixtures`: a few API helpers to put the Dolomites tours back
* after a guide imported, planned over or took one over, and the wanderer
* instance the trail import is pictured against.
*
* wanderer is a service outside TREK, set up like the ones in `external.ts`:
* its address and an API token come from `e2e/help/media.env`
* (HELP_MEDIA_WANDERER_URL, HELP_MEDIA_WANDERER_TOKEN, and
* HELP_MEDIA_WANDERER_INSECURE=true for a self-signed certificate). The
* fixture uploads its own trails into that account, so the picture does not
* depend on what happens to be in it. Without the variables only the wanderer
* guide fails, loudly, at its start. The address has to be the machine's LAN
* address and the media backend needs ALLOW_INTERNAL_NETWORK=true, as for the
* other services (README, "The guides that show another service").
*/
// ── TREK ──────────────────────────────────────────────────────────────────────
export interface TourRowLite {
place_id: number;
name: string;
has_waypoints?: boolean;
has_recorded_track?: boolean;
}
async function ok<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 400)}`);
return (await res.json()) as T;
}
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number> {
const { trips } = await ok<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const trip = trips.find((t) => t.title === title);
if (!trip) throw new Error(`no trip "${title}": call ensureToursFixtures in beforeAll`);
return trip.id;
}
export async function toursOfTrip(api: APIRequestContext, tripId: number): Promise<TourRowLite[]> {
return (await ok<{ tours: TourRowLite[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
.tours;
}
export async function tourNamed(
api: APIRequestContext,
tripId: number,
name: string
): Promise<TourRowLite | undefined> {
return (await toursOfTrip(api, tripId)).find((t) => t.name === name);
}
/** Deletes every tour of the trip with one of these names; a tour is a place, so it goes the way of a place. */
export async function deleteToursNamed(api: APIRequestContext, tripId: number, ...names: string[]): Promise<void> {
for (const tour of await toursOfTrip(api, tripId)) {
if (!names.includes(tour.name)) continue;
const res = await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
if (!res.ok()) console.warn(`[tour-import] could not delete "${tour.name}": ${res.status()}`);
}
}
/** The editor's draft is kept on the server per trip and user; a guide that left one behind would restore it into the next. */
export async function forgetTourDraft(api: APIRequestContext, tripId: number): Promise<void> {
const res = await api.delete(`/api/trips/${tripId}/tour-draft`);
if (!res.ok() && res.status() !== 404)
console.warn(`[tour-import] could not drop the tour draft of trip ${tripId}: ${res.status()}`);
}
/**
* Puts an import that was planned over in place back to its recorded track,
* which is what the seed has. Nothing to do for a tour that is still as
* imported.
*/
export async function restoreRecordedTrack(api: APIRequestContext, tripId: number, name: string): Promise<void> {
const tour = await tourNamed(api, tripId, name);
if (!tour?.has_recorded_track) return;
const res = await api.post(`/api/trips/${tripId}/tours/${tour.place_id}/restore-recorded`);
if (!res.ok())
throw new Error(`could not restore the recorded track of "${name}": ${res.status()} ${await res.text()}`);
}
// ── wanderer ──────────────────────────────────────────────────────────────────
function env(name: string): string {
const value = process.env[name];
if (!value)
throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`);
return value.replace(/\/+$/, '');
}
/**
* The trails the fixture keeps in the wanderer account, by name, each drawn
* from a recorded walk of `tour-fixtures.ts` near the Dolomites trip. The
* guide imports the first; the second is there so the list is a list.
*/
export const WANDERER_TRAILS = [
{ name: TOUR_GPX.rasciesa.name, gpx: 'rasciesa' as const },
{ name: 'Seceda ridge from Col Raiser', gpx: 'seceda' as const },
];
/** The trail the guide imports into the Dolomites trip. */
export const WANDERER_TRAIL = WANDERER_TRAILS[0].name;
interface WandererHit {
id: string;
name: string;
}
async function wandererSearch(base: string, token: string, q: string): Promise<WandererHit[]> {
const res = await fetch(`${base}/api/v1/search/trails`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ q, options: { limit: 20 } }),
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`wanderer search: HTTP ${res.status} ${await res.text()}`);
return ((await res.json()) as { hits?: WandererHit[] }).hits ?? [];
}
/** The GPX under the trail's own name: wanderer names an uploaded trail after its track. */
function renamed(file: string, name: string): string {
return readFileSync(file, 'utf8').replace(/<name>[^<]*<\/name>/g, `<name>${name}</name>`);
}
async function upload(base: string, token: string, name: string, gpx: string): Promise<void> {
const headers = { Authorization: `Bearer ${token}` };
// wanderer's documented upload takes the file as the request body.
const raw = await fetch(`${base}/api/v1/trail/upload`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/gpx+xml' },
body: gpx,
signal: AbortSignal.timeout(60_000),
});
if (raw.ok) return;
// Older instances read it as a form field instead.
const form = new FormData();
form.append('file', new Blob([gpx], { type: 'application/gpx+xml' }), `${name}.gpx`);
const multipart = await fetch(`${base}/api/v1/trail/upload`, {
method: 'PUT',
headers,
body: form,
signal: AbortSignal.timeout(60_000),
});
if (!multipart.ok)
throw new Error(`wanderer upload of "${name}": HTTP ${raw.status} / ${multipart.status} ${await multipart.text()}`);
}
/** Puts the fixture's trails into the account the token belongs to, once, and waits until the search finds them. */
export async function ensureWandererTrails(): Promise<void> {
const base = env('HELP_MEDIA_WANDERER_URL');
const token = env('HELP_MEDIA_WANDERER_TOKEN');
for (const trail of WANDERER_TRAILS) {
if ((await wandererSearch(base, token, trail.name)).some((h) => h.name === trail.name)) continue;
await upload(base, token, trail.name, renamed(await tourGpxFixture(trail.gpx), trail.name));
// The search is an index the upload reaches a moment later.
for (let i = 0; i < 30; i++) {
if ((await wandererSearch(base, token, trail.name)).some((h) => h.name === trail.name)) break;
if (i === 29) throw new Error(`wanderer never listed the uploaded trail "${trail.name}"`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
/** The admin switch under Tours, then the signed-in user's connection, tested loudly. */
export async function ensureWandererConnection(api: APIRequestContext): Promise<void> {
await ensureWandererTrails();
await ok(await api.put('/api/admin/tours-wanderer', { data: { enabled: true } }), 'switch wanderer on');
await ok(
await api.put('/api/integrations/wanderer/settings', {
data: {
url: env('HELP_MEDIA_WANDERER_URL'),
token: env('HELP_MEDIA_WANDERER_TOKEN'),
allowInsecureTls: process.env.HELP_MEDIA_WANDERER_INSECURE === 'true',
},
}),
'save the wanderer connection'
);
const test = await ok<{ ok: boolean; code?: string }>(
await api.post('/api/integrations/wanderer/test', { data: {} }),
'test the wanderer connection'
);
if (!test.ok)
throw new Error(
`wanderer is not reachable from the media backend: ${test.code ?? 'unknown'} (is ALLOW_INTERNAL_NETWORK=true?)`
);
}
/** Takes the connection away and switches wanderer off again, as every other screen expects it. */
export async function disconnectWanderer(api: APIRequestContext): Promise<void> {
await api.delete('/api/integrations/wanderer/settings');
await api.put('/api/admin/tours-wanderer', { data: { enabled: false } });
}
/** The recorded walk the import guides pick in the file chooser. */
export const importFile = async (): Promise<string> => path.resolve(await tourGpxFixture('rasciesa'));
export const IMPORTED_NAME = TOUR_GPX.rasciesa.name;
export const RECORDED_NAME = TOUR_NAMES.recorded;
+154
View File
@@ -0,0 +1,154 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { settle } from './guide';
import { openTourTrip, TOUR_NAMES } from './tour-fixtures';
/**
* What the Tours-tab map guides share: the locators of the layer pill, the
* 3D dock and the card that searches along a tour, and the way into the
* planner with one tour open. Nothing here writes to the server: every guide
* on this screen only looks, or changes the open draft without saving it,
* and a draft lives in the browser's storage, which every test starts empty.
*/
/** The tour by its role, for the guides: the hike over the via ferrata is the default, the hut trek has named stops. */
export const MAP_TOUR = TOUR_NAMES.ferrata;
export const ALONG_TOUR = TOUR_NAMES.hutToHut;
export const PADDLE_TOUR = TOUR_NAMES.paddle;
/** The ski week's tour with pistes around it: the Sella Ronda where the piste router planned it, else the winter walk. */
export const SKI_TOURS = [TOUR_NAMES.skiRonda, TOUR_NAMES.winterWalk];
/** The right rail of the Tours tab, and one tour's row in it. */
export const tripTours = (page: Page) => page.getByRole('listbox', { name: 'Tours of this trip' });
export const tourRow = (page: Page, name: string) =>
tripTours(page).getByRole('option').filter({ hasText: name }).first();
/**
* The layer pill at the bottom left of the map (`TourMapLayerSwitcher`): a
* group named after the setting it mirrors, "Map Style". The flat map and the
* 3D map each render exactly one.
*/
export const layerPill = (page: Page) => page.getByRole('group', { name: 'Map Style' });
/** A base map's segment; its name is its visible word on the flat map, its aria-label in the compact 3D pill. */
export const baseSegment = (page: Page, label: 'Map' | 'Topo' | 'Hiking' | 'Cycling' | 'Satellite') =>
layerPill(page).getByRole('button', { name: label, exact: true });
export const terrainToggle = (page: Page) => layerPill(page).getByRole('button', { name: '3D', exact: true });
export const pistesToggle = (page: Page) => page.getByTestId('map-overlay-pistes');
export const seamarksToggle = (page: Page) => page.getByTestId('map-overlay-seamarks');
export const pisteKeyButton = (page: Page) => page.getByTestId('piste-key-button');
/** The key's popover, a portal group named like its button. */
export const pisteKeyPanel = (page: Page) => page.getByRole('group', { name: 'Piste key' });
/** The 3D map's own canvas (MapLibre), and the controls in its bottom row. */
export const terrainCanvas = (page: Page) => page.locator('.maplibregl-canvas');
export const playButton = (page: Page) => page.getByRole('button', { name: 'Play flyover', exact: true });
export const pauseButton = (page: Page) => page.getByRole('button', { name: 'Pause', exact: true });
export const restartButton = (page: Page) => page.getByRole('button', { name: 'Start over', exact: true });
export const overviewButton = (page: Page) => page.getByRole('button', { name: 'Overview', exact: true });
export const speedGroup = (page: Page) => page.getByRole('group', { name: 'Speed' });
export const reliefSlider = (page: Page) => page.getByRole('slider', { name: 'Relief' });
export const gradeSwitch = (page: Page) => page.getByRole('button', { name: 'Grade', exact: true });
export const gradeMix = (page: Page) => page.getByRole('button', { name: 'Grade mix', exact: true });
/** The unfolded bands above the grade mix pill (`id="tour-terrain-grades"`). */
export const gradeBands = (page: Page) => page.locator('#tour-terrain-grades');
/** "Along the tour": a RailCard, a section named by its title. */
export const alongCard = (page: Page) => page.getByRole('region', { name: 'Along the tour' });
export const alongCategories = (page: Page) => page.getByTestId('tour-poi-categories');
/** The open category list, a portal group named "What to look for". */
export const alongCategoryList = (page: Page) => page.getByRole('group', { name: 'What to look for' });
export const alongCorridor = (page: Page) =>
alongCard(page).getByRole('group', { name: 'Within this distance of the route' });
export const alongSearch = (page: Page) => page.getByTestId('tour-poi-search');
export const alongList = (page: Page) => page.getByTestId('tour-poi-list');
/** A place found that is not a stop of the tour yet: its row carries a distance, not the "In the tour" pill. */
export const alongCandidates = (page: Page) =>
alongList(page)
.locator('li')
.filter({ hasNot: page.getByText('In the tour', { exact: true }) });
export const poiCard = (page: Page) => page.getByTestId('tour-poi-card-map');
export const poiSlot = (page: Page) => page.getByTestId('tour-poi-slot');
export const poiAdd = (page: Page) => page.getByTestId('tour-poi-add');
export const poiOutcome = (page: Page) => page.getByTestId('tour-poi-outcome');
export const poiUndo = (page: Page) => page.getByTestId('tour-poi-undo');
/**
* Open the Tours tab of the Dolomites (or the ski week) and open `name` in the
* planner: its row is selected and the layer pill shows the maps of its type.
* A multi-day tour opens whole, as a click on its row does.
*/
export async function openTourInPlanner(page: Page, name: string, trip: 'summer' | 'ski' = 'summer'): Promise<void> {
await openTourTrip(page, { trip, tab: 'tour-planner' });
const row = tourRow(page, name);
await expect(row, `no tour "${name}" in the Tours tab: did ensureToursFixtures make it?`).toBeVisible({
timeout: 30_000,
});
await row.click();
await expect(row).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
await expect(layerPill(page)).toBeVisible();
// The saved route is drawn and framed once the planner has it; the summary card says so.
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** The first of the ski week's tours that is in the trip. */
export async function openSkiTour(page: Page): Promise<string> {
await openTourTrip(page, { trip: 'ski', tab: 'tour-planner' });
for (const name of SKI_TOURS) {
if (await tourRow(page, name).count()) {
await openTourInPlanner(page, name, 'ski');
return name;
}
}
throw new Error(`none of ${SKI_TOURS.join(', ')} is in the ski week: did ensureToursFixtures make them?`);
}
/** Switch the open tour to 3D and wait until the landscape is drawn and the dock is there. */
export async function enterTerrain(page: Page): Promise<void> {
await terrainToggle(page).click();
await expect(terrainCanvas(page)).toBeVisible({ timeout: 60_000 });
await expect(overviewButton(page)).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** Wait until the corridor has loaded and Play flyover replaces the ring. */
export async function waitForFlyover(page: Page): Promise<void> {
await expect(playButton(page).or(pauseButton(page))).toBeVisible({ timeout: 120_000 });
}
/** Ask "Along the tour" with what it starts with (the first three kinds, 500 m) and wait for the list. */
export async function searchAlong(page: Page): Promise<void> {
await alongCard(page).scrollIntoViewIfNeeded();
await alongSearch(page).click();
await expect(alongSearch(page)).toHaveText(/Search again/, { timeout: 90_000 });
await expect(alongList(page), 'the search along the tour found nothing (Overpass or the places index)').toBeVisible();
}
/**
* The first place found whose card can take it in as a stop: one that is not
* in the tour already and whose suggested slot is not on a fixed stretch.
* Tries them in their card, closes the card again and returns the row's index
* among `alongCandidates`, for `candidateRow`.
*/
export async function pickAddablePlace(page: Page): Promise<number> {
const rows = alongCandidates(page);
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i++) {
await candidateRow(page, i).click();
await expect(poiCard(page)).toBeVisible();
const addable = await poiAdd(page).isEnabled();
await closeOverlay(page);
await expect(poiCard(page)).toBeHidden();
if (addable) return i;
}
throw new Error('no place along the tour can be added as a stop');
}
/** The button of the `index`-th place found that is not in the tour yet. */
export const candidateRow = (page: Page, index: number): Locator =>
alongCandidates(page).nth(index).getByRole('button').first();
/** Close whatever popover or card is open over the map. */
export async function closeOverlay(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
}
@@ -0,0 +1,382 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { day } from '../dates';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
import { TOUR_NAMES, TOUR_TRIP, TOUR_TRIP_DATES } from './tour-fixtures';
/**
* What the paddle tour pictures need beyond `ensureToursFixtures`.
*
* The boat tour "Rowing round Lago di Braies" on day 9 of "Summer in the
* Dolomites" is the one every paddle picture shows. Its four legs are drawn by
* hand along the shore, because a fresh database holds no water data, and the
* pictures keep it that way: TREK fetches a region's water data by itself
* when a leg cannot be planned, and an admin planning sets that download
* going at once. `holdPaddleDownloads` switches that off for the run and
* `restorePaddleDownloads` puts the setting back.
*
* Three guides need a little more, and each makes it in its `start` and takes
* it away in its `cleanup` (and `afterAll` again, for a run that failed):
*
* - the wind forecast needs a day within the next two weeks, which the
* summer trip does not have, so `ensureWindTrip` makes a short trip from
* tomorrow with the boat tour taken over onto its first day;
* - the checklist lists what is saved, so `ensureCarryCopy` makes a copy of
* the boat tour for that guide to save a portage and a lock on;
* - the recorded track is a GPX file, written by `paddleGpxFixture` from the
* boat tour's own line, with the scatter and the times a watch records.
*/
export const PADDLE_TOUR = TOUR_NAMES.paddle;
/** The trip the wind guide makes and deletes again: two days from tomorrow. */
export const WIND_TRIP = 'Weekend at Lago di Braies';
/** The copy the checklist guide saves a portage and a lock on. */
export const CARRY_COPY = 'Lago di Braies with a carry';
/** The recorded track the import guide brings in, by its file and the name inside it. */
export const PADDLE_GPX = { name: 'Lago di Braies kayak round', file: 'lago-di-braies-kayak-round.gpx' } as const;
async function json<T>(res: Awaited<ReturnType<APIRequestContext['get']>>, what: string): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
interface TourRow {
place_id: number;
name: string;
tour_type?: string;
}
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number | null> {
const { trips = [] } = await json<{ trips?: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
return trips.find((t) => t.title === title)?.id ?? null;
}
export async function summerTripId(api: APIRequestContext): Promise<number> {
const id = await tripIdByTitle(api, TOUR_TRIP);
if (id === null) throw new Error(`no trip "${TOUR_TRIP}": call ensureToursFixtures in beforeAll`);
return id;
}
export async function toursOf(api: APIRequestContext, tripId: number): Promise<TourRow[]> {
return (await json<{ tours: TourRow[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
.tours;
}
/** The boat tour's place id in the summer trip. */
export async function paddleTourId(api: APIRequestContext): Promise<number> {
const tripId = await summerTripId(api);
const tour = (await toursOf(api, tripId)).find((t) => t.name === PADDLE_TOUR);
if (!tour)
throw new Error(
`no tour "${PADDLE_TOUR}": the Overpass shore of Lago di Braies was not there when the fixtures ran`
);
return tour.place_id;
}
/** Deletes the tours of a trip the test picks; a tour is a place, and goes the way a place goes. */
export async function deleteToursWhere(
api: APIRequestContext,
tripId: number,
pick: (tour: TourRow) => boolean
): Promise<void> {
for (const tour of (await toursOf(api, tripId)).filter(pick)) {
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
// ── The editor's draft ────────────────────────────────────────────────────────
/**
* The planner keeps an unsaved edit on the account per trip and restores it on
* the next page load. A paddle guide that stops half way (its cleanup does not
* run then) would leave its carry and lock in that draft, and the next guide
* would open on it: the editor instead of the neutral rail, and a click on a
* tour row asking Discard unsaved tour changes. A trip without a draft answers
* 404, which is what is wanted.
*/
export async function forgetPaddleDraft(api: APIRequestContext): Promise<void> {
const tripId = await summerTripId(api);
const res = await api.delete(`/api/trips/${tripId}/tour-draft`);
if (!res.ok() && res.status() !== 404)
console.warn(`[trip-tour-paddle] could not drop the tour draft: ${res.status()}`);
}
/**
* The same before a guide opens its page, and the copy older builds kept in the
* browser as `tour-draft-<tripId>`, dropped before the app reads it.
*/
export async function forgetPaddleDraftBeforeLoad(page: Page): Promise<void> {
await forgetPaddleDraft(page.request);
await page.addInitScript(() => {
try {
for (const key of Object.keys(localStorage)) if (key.startsWith('tour-draft-')) localStorage.removeItem(key);
} catch {
// Storage blocked: there is no draft to restore either.
}
});
}
// ── The download switch ───────────────────────────────────────────────────────
type AutoLoad = { mode: 'auto' | 'ask' | 'off'; max_mb: number };
let heldFrom: AutoLoad | null = null;
/**
* No water data downloads while the paddle pictures run. Without this, the
* first leg the planner cannot plan makes the server fetch the smallest
* Geofabrik extract around Lago di Braies (all of north-east Italy) and build
* a water network from it, for minutes, because the picture user is an admin.
*/
export async function holdPaddleDownloads(api: APIRequestContext): Promise<void> {
const current = await json<AutoLoad>(await api.get('/api/admin/paddle-regions/settings'), 'paddle region settings');
if (heldFrom === null) heldFrom = current;
if (current.mode !== 'off') {
await json(
await api.put('/api/admin/paddle-regions/settings', { data: { mode: 'off', max_mb: current.max_mb } }),
'hold paddle downloads'
);
}
}
export async function restorePaddleDownloads(api: APIRequestContext): Promise<void> {
if (heldFrom === null) return;
await api.put('/api/admin/paddle-regions/settings', { data: heldFrom });
heldFrom = null;
}
// ── The wind trip ─────────────────────────────────────────────────────────────
export async function deleteTripsNamed(api: APIRequestContext, title: string): Promise<void> {
const { trips = [] } = await json<{ trips?: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
for (const trip of trips.filter((t) => t.title === title)) await api.delete(`/api/trips/${trip.id}`);
}
/**
* A two-day trip from tomorrow with the boat tour taken over onto its first
* day at 10:00, so its Wind card has a forecast to show. Made fresh every time.
*/
export async function ensureWindTrip(
api: APIRequestContext
): Promise<{ tripId: number; placeId: number; name: string }> {
await deleteTripsNamed(api, WIND_TRIP);
const sourceTrip = await summerTripId(api);
const source = await paddleTourId(api);
const { trip } = await json<{ trip: { id: number } }>(
await api.post('/api/trips', {
data: {
title: WIND_TRIP,
description: 'Two quiet days at the lake, out on the water if the wind allows.',
start_date: day(1),
end_date: day(2),
currency: 'EUR',
},
}),
`create "${WIND_TRIP}"`
);
const taken = await json<{ tour: { place_id: number } }>(
await api.post(`/api/trips/${trip.id}/tours/take-over`, {
data: { source_trip_id: sourceTrip, source_place_id: source },
}),
'take the boat tour over'
);
const { days } = await json<{ days: { id: number; date?: string | null }[] }>(
await api.get(`/api/trips/${trip.id}/days`),
'days'
);
const first = [...days].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '') || a.id - b.id)[0];
if (!first) throw new Error(`"${WIND_TRIP}" has no days`);
await json(
await api.post(`/api/trips/${trip.id}/days/${first.id}/assignments`, { data: { place_id: taken.tour.place_id } }),
'put the boat tour on day 1'
);
const { assignments = [] } = await json<{ assignments?: { id: number; place_id: number }[] }>(
await api.get(`/api/trips/${trip.id}/days/${first.id}/assignments`),
'assignments'
);
const row = assignments.find((a) => a.place_id === taken.tour.place_id);
if (row) await api.put(`/api/trips/${trip.id}/assignments/${row.id}/time`, { data: { place_time: '10:00' } });
const name = (await toursOf(api, trip.id)).find((t) => t.place_id === taken.tour.place_id)?.name ?? PADDLE_TOUR;
return { tripId: trip.id, placeId: taken.tour.place_id, name };
}
// ── The copy for the checklist ────────────────────────────────────────────────
/** A fresh copy of the boat tour in the summer trip, on no day, for a guide to save changes on. */
export async function ensureCarryCopy(api: APIRequestContext): Promise<number> {
const tripId = await summerTripId(api);
await deleteToursWhere(api, tripId, (t) => t.name === CARRY_COPY);
const source = await paddleTourId(api);
const copy = await json<{ tour: { place_id: number } }>(
await api.post(`/api/trips/${tripId}/tours/${source}/copy`, {
data: { name: CARRY_COPY },
}),
'copy the boat tour'
);
return copy.tour.place_id;
}
// ── The recorded track ────────────────────────────────────────────────────────
type Point = [number, number];
function metres(a: Point, b: Point): number {
const rad = Math.PI / 180;
const dLat = (b[0] - a[0]) * rad;
const dLng = (b[1] - a[1]) * rad;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
return 6_371_000 * 2 * Math.asin(Math.sqrt(s));
}
function seeded(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function addDays(iso: string, n: number): string {
const d = new Date(`${iso}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
/** The points of a GPX file, track points or route points, whichever order its attributes come in. */
function gpxPoints(gpx: string): Point[] {
const points: Point[] = [];
for (const match of gpx.matchAll(/<(?:trkpt|rtept)\b([^>]*)>/g)) {
const lat = /\blat="([-\d.]+)"/.exec(match[1]);
const lon = /\blon="([-\d.]+)"/.exec(match[1]);
if (lat && lon) points.push([Number(lat[1]), Number(lon[1])]);
}
return points;
}
/**
* A kayak round of Lago di Braies as a watch records it, made from the boat
* tour's own line (its GPX export): a point every 10 m or so, a few metres of
* GPS scatter, 5.5 km/h on the water with a twenty minute stop on the far
* shore, on a morning last summer. Written to `e2e/.tmp/` and returned as a path.
*/
export async function paddleGpxFixture(api: APIRequestContext): Promise<string> {
const tripId = await summerTripId(api);
const placeId = await paddleTourId(api);
const res = await api.get(`/api/trips/${tripId}/tours/${placeId}/export.gpx`);
if (!res.ok()) throw new Error(`export the boat tour: ${res.status()}`);
const line = gpxPoints(await res.text());
if (line.length < 8) throw new Error('the boat tour exported no line to record a track from');
const dense: Point[] = [line[0]];
for (let i = 1; i < line.length; i++) {
const a = line[i - 1];
const b = line[i];
const n = Math.max(1, Math.round(metres(a, b) / 10));
for (let k = 1; k <= n; k++) dense.push([a[0] + ((b[0] - a[0]) * k) / n, a[1] + ((b[1] - a[1]) * k) / n]);
}
const rnd = seeded(4242);
// 09:40 in the Dolomites in summer is 07:40 UTC.
let t = Date.parse(`${addDays(TOUR_TRIP_DATES.start, -358)}T07:40:00Z`);
const pause = Math.floor(dense.length * 0.55);
const rows = dense.map((p, i) => {
if (i > 0) t += (metres(dense[i - 1], p) / 1000 / (5.5 * (0.9 + rnd() * 0.2))) * 3_600_000;
if (i === pause) t += 20 * 60_000;
const lat = p[0] + (rnd() - 0.5) * 0.00005;
const lng = p[1] + (rnd() - 0.5) * 0.00007;
const ele = 1494 + (rnd() - 0.5) * 2;
return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"><ele>${ele.toFixed(1)}</ele><time>${new Date(Math.round(t / 1000) * 1000).toISOString().replace('.000', '')}</time></trkpt>`;
});
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${PADDLE_GPX.name}</name></metadata>
<trk><name>${PADDLE_GPX.name}</name><type>kayaking</type><trkseg>
${rows.join('\n')}
</trkseg></trk>
</gpx>
`;
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, PADDLE_GPX.file);
writeFileSync(file, gpx);
return file;
}
/** Whether a tour is the recorded track the import guide brings in, whichever name the server gave it. */
export const isPaddleImport = (tour: { name: string }): boolean => /kayak[\s-]round/i.test(tour.name);
// ── On the page ───────────────────────────────────────────────────────────────
/** The Tours tab of a trip by its id, the way `openTourTrip` opens the summer one. */
export async function openToursOf(page: Page, tripId: number): Promise<void> {
await page.goto(`/trips/${tripId}?tab=tour-planner`);
await clearNotices(page);
await dismissReleaseNotice(page);
await expect(page.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('listbox', { name: 'Tours of this trip' })).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/**
* Let a pan or a fit of the map land. Leaflet times its animations from
* `+new Date()`, and the clock is pinned to the picture day, so they start and
* never finish; moving the pinned clock on a few seconds ends them.
*/
export async function finishMapMove(page: Page): Promise<void> {
await page.waitForTimeout(400);
await nudgeClock(page);
await page.waitForTimeout(1200);
await settle(page);
}
/**
* A spot on the drawn tour line that the pointer can really reach, about
* `fraction` of the way along it: the longest line in Leaflet's overlay pane,
* sampled near that point until the map itself, not something over it, takes
* a click there. A box centre would be no use, the lake's middle is water
* nobody paddled.
*/
export async function pointOnLine(page: Page, fraction: number): Promise<{ x: number; y: number }> {
const found = await page.evaluate((wanted: number) => {
const paths = Array.from(document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path'));
let line: SVGPathElement | null = null;
let longest = 0;
for (const path of paths) {
const length = path.getTotalLength();
if (length > longest) {
longest = length;
line = path;
}
}
if (!line || longest < 40) return null;
const ctm = line.getScreenCTM();
if (!ctm) return null;
for (let step = 0; step <= 20; step++) {
for (const sign of [1, -1]) {
const f = wanted + sign * step * 0.005;
if (f <= 0.02 || f >= 0.98) continue;
const at = line.getPointAtLength(longest * f);
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e);
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f);
// The marking tool listens on the map's own click and lays the spot onto
// the track itself, and the drawn line is not interactive while it is on,
// so the pointer lands on whatever map layer lies under the line (the
// vector base map's canvas, a tile). That point is reachable as long as
// the map gets it, not a panel, a card, a control or a marker that keeps
// the click for itself.
const hit = document.elementFromPoint(x, y);
if (
hit &&
hit.closest('.leaflet-container') &&
!hit.closest('.leaflet-control-container, .leaflet-marker-pane, .leaflet-popup-pane, .leaflet-tooltip-pane')
) {
return { x, y };
}
}
}
return null;
}, fraction);
if (!found) throw new Error(`no reachable point on the drawn line near ${Math.round(fraction * 100)} %`);
return found;
}
@@ -0,0 +1,463 @@
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
import { TOUR_NAMES, TOUR_PLACES, TOUR_TRIP } from './tour-fixtures';
import { tripReady } from './trip-shared';
/**
* What the Tours-tab planner pictures need beyond `ensureToursFixtures`: a
* plain day hike to edit, the spots on real paths a new tour is drawn
* through, the way into the Tours tab, and the projection from a spot on the
* earth to a pixel of the map. Not a `.guide.ts`, so importing it registers
* no tests.
*
* Everything is cut from the line of the Puez-Odle hut trek, which the tour
* fixtures routed along real paths: the spare hike is its last stretch, from
* the Rifugio Firenze down to the Col Raiser gondola, and the points a guide
* clicks onto the map lie on the same line, so the router always finds a way
* between them and no picture shows a point it cannot reach.
*/
type Api = APIRequestContext;
type LatLng = [number, number];
type LatLngEle = [number, number, number];
/** The tours this screen makes and takes away again. */
export const PLANNER_TOURS = {
/** A plain four-stop day hike, on no day; the editing guides open it and never keep their changes. */
spare: 'Rifugio Firenze to Col Raiser',
/** A copy of the spare that the delete guide deletes. */
old: 'Rifugio Firenze to Col Raiser (old plan)',
/** The tour the save guide draws and saves. */
drawn: 'Cisles meadow walk',
} as const;
export const SPARE_DESCRIPTION = 'From the hut across the Cisles meadows and down to the Col Raiser gondola.';
/** What the description reads after the "someone else saved it" write of the conflict guide. */
export const REMOTE_DESCRIPTION = 'Mira: we take the gondola down, the last stretch is closed this week.';
/** The query the drawing guide types into the map search; the hut is where its points lie. */
export const SEARCH_QUERY = 'Rifugio Firenze';
async function body<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
return (await res.json()) as T;
}
export async function tripIdOf(api: Api): Promise<number> {
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const trip = trips.find((t) => t.title === TOUR_TRIP);
if (!trip) throw new Error(`no trip "${TOUR_TRIP}": ensureToursFixtures has not run`);
return trip.id;
}
interface PlaceRow {
id: number;
name: string;
lat?: number | null;
lng?: number | null;
route_geometry?: unknown;
}
async function placesOf(api: Api, tripId: number): Promise<PlaceRow[]> {
const res = await body<{ places?: PlaceRow[] } | PlaceRow[]>(await api.get(`/api/trips/${tripId}/places`), 'places');
return Array.isArray(res) ? res : (res.places ?? []);
}
export async function tourIdOf(api: Api, tripId: number, name: string): Promise<number | null> {
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${tripId}/tours`),
'tours'
);
return tours.find((t) => t.name === name)?.place_id ?? null;
}
export async function removeTour(api: Api, tripId: number, name: string): Promise<void> {
for (;;) {
const id = await tourIdOf(api, tripId, name);
if (!id) return;
const res = await api.delete(`/api/trips/${tripId}/places/${id}`);
if (!res.ok()) throw new Error(`delete "${name}": ${res.status()}`);
}
}
// ── The line everything is cut from ───────────────────────────────────────────
function metres(a: readonly number[], b: readonly number[]): number {
const rad = Math.PI / 180;
const x = (b[1] - a[1]) * rad * Math.cos(((a[0] + b[0]) / 2) * rad);
const y = (b[0] - a[0]) * rad;
return Math.hypot(x, y) * 6_371_000;
}
/** The hut trek's line, the index where it passes the Rifugio Firenze, and the distance along it at every point. */
export interface HutLine {
line: LatLngEle[];
hut: number;
along: number[];
}
export async function hutLine(api: Api, tripId: number): Promise<HutLine> {
const places = await placesOf(api, tripId);
const trek = places.find((p) => p.name === TOUR_NAMES.hutToHut);
const hutPlace = places.find((p) => p.name === TOUR_PLACES.firenze);
const raw = trek?.route_geometry;
const line = (typeof raw === 'string' ? JSON.parse(raw) : (raw ?? [])) as LatLngEle[];
if (!trek || line.length < 10)
throw new Error(`"${TOUR_NAMES.hutToHut}" has no route line: did ensureToursFixtures make it?`);
if (hutPlace?.lat == null || hutPlace.lng == null) throw new Error(`no place "${TOUR_PLACES.firenze}" with a spot`);
const spot = [hutPlace.lat, hutPlace.lng];
let hut = 0;
line.forEach((point, i) => {
if (metres(point, spot) < metres(line[hut], spot)) hut = i;
});
const along = [0];
for (let i = 1; i < line.length; i++) along.push(along[i - 1] + metres(line[i - 1], line[i]));
return { line, hut, along };
}
/** The index of the point `offset` metres along the line from the hut (negative: before it), kept on the line. */
function indexAt(geo: HutLine, offset: number): number {
const wanted = geo.along[geo.hut] + offset;
let best = 0;
geo.along.forEach((d, i) => {
if (Math.abs(d - wanted) < Math.abs(geo.along[best] - wanted)) best = i;
});
return best;
}
/** The spot `offset` metres along the hut trek from the Rifugio Firenze, on its path. */
export function spotAt(geo: HutLine, offset: number): LatLng {
const p = geo.line[indexAt(geo, offset)];
return [p[0], p[1]];
}
/** Where the drawing guide clicks: down the path to the hut and on past it. */
export const DRAWN_OFFSETS = [-420, -120, 240, 560];
/** Where the shaping guide pulls its point: back up the approach to the hut. */
export const SHAPING_OFFSET = -320;
/** The spare hike: the hut trek's last stretch, cut at four stops, with its own heights. */
export function spareBody(geo: HutLine, description = SPARE_DESCRIPTION): Record<string, unknown> {
const route = geo.line.slice(geo.hut);
const length = geo.along[geo.along.length - 1] - geo.along[geo.hut];
const cuts = [0, Math.round(route.length / 3), Math.round((2 * route.length) / 3), route.length - 1];
const waypoints = cuts.map((index, i) => ({
lat: route[index][0],
lng: route[index][1],
role: i === 0 ? 'start' : i === cuts.length - 1 ? 'end' : 'via',
sequence: i,
}));
let gain = 0;
let loss = 0;
for (let i = 1; i < route.length; i++) {
const step = route[i][2] - route[i - 1][2];
if (step > 0) gain += step;
else loss -= step;
}
// DIN 33466, as the planner's own estimate reckons a hike.
const flat = length / 1000 / 4;
const vertical = gain / 300 + loss / 500;
const hours = Math.max(flat, vertical) + Math.min(flat, vertical) / 2;
return {
name: PLANNER_TOURS.spare,
tour_type: 'hike',
max_hiking_difficulty: 2,
route_geometry: route,
waypoints,
duration_seconds: Math.round(hours * 3600),
break_additional_minutes: 30,
description,
};
}
/**
* The spare hike, made where it is missing. With `reset` an existing one is
* written back to how it was made, after a guide saved a change into it.
*/
export async function ensureSpare(api: Api, opts: { reset?: boolean } = {}): Promise<number> {
const tripId = await tripIdOf(api);
const geo = await hutLine(api, tripId);
const existing = await tourIdOf(api, tripId, PLANNER_TOURS.spare);
if (existing) {
if (opts.reset)
await body(
await api.put(`/api/trips/${tripId}/tours/${existing}`, { data: spareBody(geo) }),
'reset the spare hike'
);
return existing;
}
const created = await body<{ tour: { place_id: number } }>(
await api.post(`/api/trips/${tripId}/tours`, { data: spareBody(geo) }),
'create the spare hike'
);
return created.tour.place_id;
}
/** Someone else saves the spare: the same tour with another description, a revision further on. */
export async function saveSpareElsewhere(api: Api): Promise<void> {
const tripId = await tripIdOf(api);
const id = await tourIdOf(api, tripId, PLANNER_TOURS.spare);
if (!id) throw new Error('no spare hike to save elsewhere');
const geo = await hutLine(api, tripId);
await body(
await api.put(`/api/trips/${tripId}/tours/${id}`, { data: spareBody(geo, REMOTE_DESCRIPTION) }),
'save the spare elsewhere'
);
}
/** The copy the delete guide deletes, made where it is missing. */
export async function ensureOldPlan(api: Api): Promise<void> {
const tripId = await tripIdOf(api);
if (await tourIdOf(api, tripId, PLANNER_TOURS.old)) return;
const spare = await ensureSpare(api);
await body(
await api.post(`/api/trips/${tripId}/tours/${spare}/copy`, { data: { name: PLANNER_TOURS.old } }),
'copy the old plan'
);
}
/** Take a tour off one day, as the fixtures had it before a guide put it there. */
export async function offDay(api: Api, tripId: number, dayIndex: number, placeId: number): Promise<void> {
const { days } = await body<{ days: { id: number; date?: string | null; day_number?: number }[] }>(
await api.get(`/api/trips/${tripId}/days`),
'days'
);
const sorted = [...days].sort(
(a, b) => (a.date ?? '').localeCompare(b.date ?? '') || (a.day_number ?? 0) - (b.day_number ?? 0) || a.id - b.id
);
const dayId = sorted[dayIndex]?.id;
if (!dayId) return;
const { assignments } = await body<{ assignments: { id: number; place_id: number }[] }>(
await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`),
'assignments'
);
for (const a of assignments.filter((row) => row.place_id === placeId)) {
await api.delete(`/api/trips/${tripId}/days/${dayId}/assignments/${a.id}`);
}
}
/**
* Forget the planner's unsaved draft: on the account, where the planner keeps
* it now, and the copy older builds left in the browser, and the folds of the
* rail cards, so the next guide starts from the defaults.
*/
export async function forgetPlannerState(page: Page): Promise<void> {
const tripId = await tripIdOf(page.request);
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
await page
.evaluate((id) => {
try {
for (const key of Object.keys(window.localStorage)) {
if (
key === `tour-draft-${id}` ||
key.startsWith('trek.tours.fold.') ||
key === 'trek.tours.planner.detailsOpen'
) {
window.localStorage.removeItem(key);
}
}
} catch {
// No storage, nothing kept.
}
}, tripId)
.catch(() => undefined);
}
// ── Where things are ──────────────────────────────────────────────────────────
/** The right rail, "Tours of this trip", and a tour's row in its list (the option is named after the tour). */
export const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
export const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
/** The editor rail; its section is named after the planner while a tour is drawn or edited. */
export const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
export const waypointsCard = (page: Page) => page.getByRole('region', { name: 'Waypoints', exact: true });
export const waypointRows = (page: Page) => page.getByTestId('tour-planner-waypoint-row');
export const summaryCard = (page: Page) => page.getByTestId('tour-planner-overview');
export const layerPill = (page: Page) => page.getByRole('group', { name: 'Map Style' });
export const mapSearch = (page: Page) => page.getByRole('combobox', { name: 'Search place or address' });
/** A numbered pin of the open tour on the map, by its number; a shaping point carries none. */
export const pin = (page: Page, n: number) =>
page.locator('.tour-planner-waypoint-marker').filter({ hasText: new RegExp(`^${n}$`) });
export const shapingPin = (page: Page) => page.locator('.tour-planner-waypoint-marker').filter({ hasNotText: /\d/ });
/** The one context menu that is open; its entries are plain buttons. */
export const openMenu = (page: Page) => page.getByRole('menu');
export const menuItem = (page: Page, name: string) => openMenu(page).getByRole('button', { name, exact: true });
/** The row's own controls show on hover or once it is selected: select it, then open its menu. */
export async function openRowMenu(page: Page, row: Locator): Promise<void> {
await row.hover();
await row.getByRole('button', { name: 'More actions', exact: true }).click();
await expect(openMenu(page)).toBeVisible();
await settle(page);
}
// ── Getting there ─────────────────────────────────────────────────────────────
const rawKey = (page: Page) => page.getByText(/^(common|planner|places|trip|tours|map|undo)\.[A-Za-z0-9.]+$/);
/**
* The Dolomites trip on its Tours tab, as the reader finds it: nothing open in
* the editor, the trip's tours listed on the right. Waits for the Trip
* settings pill, as `tripReady` does (Share moved into it in v5).
*/
export async function openPlanner(page: Page): Promise<number> {
const tripId = await tripIdOf(page.request);
await forgetPlannerState(page);
await page.goto(`/trips/${tripId}?tab=tour-planner`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
await page.waitForTimeout(4000);
await page.reload();
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
await expect(page.getByRole('button', { name: 'Plan new tour', exact: true })).toBeVisible({ timeout: 30_000 });
await expect(tourRow(page, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(1200);
return tripId;
}
/**
* Let the map's camera land. Leaflet times a pan from `+new Date()`, which the
* pictures pin to one instant, so a pan starts and never gets anywhere; moving
* the pinned clock on ends it on the next frame. The clock only moves on, so
* the same instant never comes twice.
*/
export async function landMap(page: Page): Promise<void> {
await page.waitForTimeout(400);
await nudgeClock(page);
await page.waitForTimeout(1200);
await settle(page);
}
/** The route is planned and its heights are in: the summary is up and the status dot has nothing to say. */
export async function waitRouted(page: Page): Promise<void> {
await expect(summaryCard(page)).toBeVisible({ timeout: 120_000 });
await expect(page.getByTestId('tour-planner-status')).toHaveCount(0, { timeout: 120_000 });
await landMap(page);
}
/** The Topo map, whose raster tiles show the paths and let a spot be found on the screen. */
export async function useTopo(page: Page): Promise<void> {
const topo = layerPill(page).getByRole('button', { name: 'Topo', exact: true });
await expect(topo).toBeVisible({ timeout: 15_000 });
if ((await topo.getAttribute('aria-pressed')) !== 'true') await topo.click();
await expect(page.locator('img.leaflet-tile[src*="opentopomap"]').first()).toBeAttached({ timeout: 20_000 });
await settle(page);
}
/** Open a saved tour of the list in the editor and wait for its route, on the Topo map. */
export async function openSaved(page: Page, name: string): Promise<void> {
const row = tourRow(page, name);
await expect(row, `no tour "${name}" in the Tours tab`).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
await waitRouted(page);
await useTopo(page);
}
/** Close the editor, throwing the unsaved changes away, so the next guide finds the rail as the reader does. */
export async function closeEditor(page: Page): Promise<void> {
const back = page.getByRole('button', { name: 'Back to Tours', exact: true });
if (await back.isVisible().catch(() => false)) {
await back.click();
const discard = page.getByRole('button', { name: 'Discard changes', exact: true });
if (await discard.isVisible({ timeout: 2_000 }).catch(() => false)) await discard.click();
}
await forgetPlannerState(page);
}
// ── From the earth to the screen ──────────────────────────────────────────────
/**
* The pixel of a spot on the map. Read off a raster tile: its address names
* its zoom and its column and row, its box says where it sits, and Web
* Mercator does the rest. Exact at any zoom and through any pan, and it
* needs nothing of Leaflet's that the page does not show anyway.
*/
export async function screenOf(page: Page, spot: LatLng): Promise<{ x: number; y: number }> {
const at = await page.evaluate(([lat, lng]) => {
for (const img of Array.from(
document.querySelectorAll('.leaflet-tile-pane img.leaflet-tile')
) as HTMLImageElement[]) {
const m = /\/(\d+)\/(\d+)\/(\d+)(?:@\dx)?\.(?:png|jpe?g|webp)/.exec(img.src);
const box = img.getBoundingClientRect();
if (!m || box.width === 0) continue;
const [z, tx, ty] = [Number(m[1]), Number(m[2]), Number(m[3])];
const n = 2 ** z;
const s = Math.sin((lat * Math.PI) / 180);
const wx = ((lng + 180) / 360) * n;
const wy = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n;
return { x: box.left + (wx - tx) * box.width, y: box.top + (wy - ty) * box.height };
}
return null;
}, spot);
if (!at) throw new Error('no raster tile on the map to place a spot by: is the Topo map on?');
// Between the two rails and under the search box, or a click would land on a panel.
const rails = await page.evaluate(() => {
const left = document.querySelector('[aria-label="Plan a tour"]')?.getBoundingClientRect();
const right = document.querySelector('aside[aria-label="Tours of this trip"]')?.getBoundingClientRect();
return { left: left?.right ?? 0, right: right?.left ?? window.innerWidth };
});
if (at.x < rails.left + 24 || at.x > rails.right - 24 || at.y < 120 || at.y > 1000) {
throw new Error(`spot ${spot.join(', ')} lands at ${Math.round(at.x)}, ${Math.round(at.y)}, outside the open map`);
}
return at;
}
/**
* A pointer movement in even steps with a frame each, for a loop: from where
* the mouse is to `to`, eased like a hand.
*/
export async function glide(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
frame: () => Promise<void>,
steps = 10
): Promise<void> {
for (let i = 1; i <= steps; i++) {
const k = i / steps;
const ease = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
await page.mouse.move(from.x + (to.x - from.x) * ease, from.y + (to.y - from.y) * ease, { steps: 2 });
await frame();
}
}
/** The centre of an element on the screen. */
export async function centreOf(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox();
if (!box) throw new Error('the element has no box');
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/**
* A spot on the drawn route, `share` of the way along its widest hit band:
* the invisible band Leaflet lays along the line to take the click that adds
* a shaping point.
*/
export async function spotOnRoute(page: Page, share = 0.3): Promise<{ x: number; y: number }> {
const at = await page.evaluate((k) => {
const bands = Array.from(document.querySelectorAll('.leaflet-overlay-pane path.cursor-copy')) as SVGPathElement[];
const band = bands.sort((a, b) => b.getTotalLength() - a.getTotalLength())[0];
const ctm = band?.getScreenCTM();
if (!band || !ctm) return null;
const p = band.getPointAtLength(band.getTotalLength() * k);
const s = new DOMPoint(p.x, p.y).matrixTransform(ctm);
return { x: s.x, y: s.y };
}, share);
if (!at) throw new Error('no drawn route to click on');
return at;
}
/** The drawn route's click band, for the ring: the widest one. */
export const routeBand = (page: Page) => page.locator('.leaflet-overlay-pane path.cursor-copy').first();
+226
View File
@@ -0,0 +1,226 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { SKI_TRIP, SKI_TRIP_DATES, TOUR_NAMES } from './tour-fixtures';
/**
* What the Ski & winter help pictures need beyond `ensureToursFixtures`: a
* recorded ski track to import through the UI, and the names of what the
* guides make, so their cleanup can take it away again.
*
* The track follows the line of the "Vallunga winter walk" tour, which
* `ensureToursFixtures` draws along the real Vallunga path (by the piste
* router where it answers, else along the OpenStreetMap way), so the recording
* lies on the valley's cross-country trail and not on a line drawn here. It is
* recorded a year before the ski week, at a classic skier's pace.
*/
/** The track the import guide brings in, by the name in its file. */
export const SKI_TRACK = { name: 'Vallunga cross-country loop', file: 'vallunga-cross-country-loop.gpx' };
/** The tour the planning guide saves, deleted again by its cleanup. */
export const PLANNED_SKI_TOUR = 'Selva to Corvara on skis';
/**
* The points the planner guides click, the first part of the Sella Ronda:
* Selva at the Dantercepies I valley station, the top of the Gardena run at
* Passo Gardena, and Corvara at the Borest and Boè gondolas. Each lies within
* a few metres of the piste data, because the router only takes a point
* within 150 m of a run or lift: the old Corvara point sat 360 m off the
* Sodlisia run and failed as not_on_piste, and the old Selva point snapped to
* the run down into the village, so the first leg went round by Ciampinoi
* instead of up the Dantercepies gondola. On the Places build of 2026-10-20
* the first leg rides Dantercepies I and II and skis the Sellaronda run to
* the pass, and the second skis Gardena, Frara and Sodlisia down to Colfosco
* and takes the Borest gondola over to Corvara. With every lift left out, the
* first leg fails as blocked_by_lift_avoid on Dantercepies I.
*/
export const SKI_POINTS: [number, number][] = [
[46.5556, 11.768],
[46.5512, 11.8077],
[46.5482, 11.871],
];
type Point = [number, number, number];
/** The trip's id by its title. */
export async function skiTripId(api: APIRequestContext): Promise<number> {
const res = await api.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
const trip = trips.find((t) => t.title === SKI_TRIP);
if (!trip) throw new Error(`no trip "${SKI_TRIP}": call ensureToursFixtures first`);
return trip.id;
}
/** The tours of the ski week, name and place id. */
async function skiTours(api: APIRequestContext, tripId: number): Promise<{ place_id: number; name: string }[]> {
const res = await api.get(`/api/trips/${tripId}/tours`);
if (!res.ok()) return [];
return ((await res.json()) as { tours?: { place_id: number; name: string }[] }).tours ?? [];
}
/** The line of a saved tour, from the trip's places or the tour itself. */
async function lineOf(api: APIRequestContext, tripId: number, placeId: number): Promise<Point[]> {
const parse = (raw: unknown): Point[] => {
const value = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
return Array.isArray(value)
? (value as number[][]).filter((p) => p.length >= 2).map((p) => [p[0], p[1], p[2] ?? NaN])
: [];
};
const places = await api.get(`/api/trips/${tripId}/places`);
if (places.ok()) {
const body = (await places.json()) as
{ places?: { id: number; route_geometry?: unknown }[] } | { id: number; route_geometry?: unknown }[];
const row = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.id === placeId);
const line = parse(row?.route_geometry);
if (line.length > 1) return line;
}
const tour = await api.get(`/api/trips/${tripId}/tours/${placeId}`);
if (tour.ok()) {
const body = (await tour.json()) as { route_geometry?: unknown; tour?: { route_geometry?: unknown } };
return parse(body.route_geometry ?? body.tour?.route_geometry);
}
return [];
}
const metres = (a: Point, b: Point): number => {
const r = 6_371_000;
const dLat = ((b[0] - a[0]) * Math.PI) / 180;
const dLng = ((b[1] - a[1]) * Math.PI) / 180;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos((a[0] * Math.PI) / 180) * Math.cos((b[0] * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return 2 * r * Math.asin(Math.sqrt(h));
};
/** A small seeded random, so the recording is the same on every run. */
function random(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* A GPS recording of `line`: a point every 15 m, a little scatter, heights
* where the line has none (the Vallunga climbs from about 1610 m to 1760 m),
* classic cross-country pace, slower uphill, and a stop at the turn.
*/
function recordedSkiGpx(line: Point[], startUtcMs: number): string {
const rnd = random(4219);
const known = line.every((p) => Number.isFinite(p[2]));
const total = line.slice(1).reduce((s, p, i) => s + metres(line[i], p), 0) || 1;
let run = 0;
const withHeights: Point[] = line.map((p, i) => {
if (i > 0) run += metres(line[i - 1], p);
if (known) return p;
// Out and back: up to the turn, then down the same way.
const f = run / total;
return [p[0], p[1], 1610 + 150 * (f <= 0.5 ? f * 2 : (1 - f) * 2)];
});
const pts: Point[] = [withHeights[0]];
for (let i = 1; i < withHeights.length; i++) {
const a = withHeights[i - 1];
const b = withHeights[i];
const n = Math.max(1, Math.round(metres(a, b) / 15));
for (let k = 1; k <= n; k++) {
const s = k / n;
pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, a[2] + (b[2] - a[2]) * s]);
}
}
let t = startUtcMs;
const turn = Math.floor(pts.length / 2);
const rows = pts.map((p, i) => {
if (i > 0) {
const d = metres(pts[i - 1], p);
const slope = d > 0 ? (p[2] - pts[i - 1][2]) / d : 0;
const kmh = slope > 0 ? Math.max(5, 11 - slope * 120) : Math.min(16, 11 - slope * 80);
t += (d / 1000 / kmh) * 3_600_000;
}
if (i === turn) t += 12 * 60_000;
const lat = p[0] + (rnd() - 0.5) * 0.00005;
const lng = p[1] + (rnd() - 0.5) * 0.00007;
const ele = p[2] + (rnd() - 0.5) * 3;
const time = new Date(Math.round(t / 1000) * 1000).toISOString().replace('.000', '');
return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"><ele>${ele.toFixed(1)}</ele><time>${time}</time></trkpt>`;
});
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${SKI_TRACK.name}</name><time>${new Date(startUtcMs).toISOString().replace('.000', '')}</time></metadata>
<trk><name>${SKI_TRACK.name}</name><type>cross_country_skiing</type><trkseg>
${rows.join('\n')}
</trkseg></trk>
</gpx>
`;
}
/** Where the Vallunga winter walk runs when no line can be read: the car park, the chapel and back. */
const VALLUNGA: Point[] = [
[46.5622, 11.7795, 1610],
[46.5652, 11.7858, 1640],
[46.5688, 11.7925, 1680],
[46.5712, 11.7984, 1720],
[46.5735, 11.804, 1760],
[46.5712, 11.7984, 1720],
[46.5688, 11.7925, 1680],
[46.5652, 11.7858, 1640],
[46.5622, 11.7795, 1610],
];
/**
* The path of the recorded ski track in `e2e/.tmp/`, written on every call
* from the winter walk's line, so it follows whatever line that tour has.
*/
export async function skiTrackFixture(api: APIRequestContext): Promise<string> {
const tripId = await skiTripId(api);
const walk = (await skiTours(api, tripId)).find((t) => t.name === TOUR_NAMES.winterWalk);
let line = walk ? await lineOf(api, tripId, walk.place_id) : [];
if (line.length < 2) {
console.warn('[ski fixtures] no line for the Vallunga winter walk; the track follows the built-in points');
line = VALLUNGA;
}
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, SKI_TRACK.file);
// A year before the ski week's fourth day, 10:20 in Val Gardena (UTC+1).
const day = new Date(`${SKI_TRIP_DATES.start}T00:00:00Z`);
day.setUTCDate(day.getUTCDate() + 3 - 364);
const start = day.getTime() + (10 * 60 + 20 - 60) * 60_000;
writeFileSync(file, recordedSkiGpx(line, start));
return file;
}
/** Delete every tour of the ski week whose name is one of `names` (a guide's own, never the fixture's). */
export async function deleteSkiTours(page: Page, ...names: string[]): Promise<void> {
const tripId = await skiTripId(page.request);
const base = SKI_TRACK.file.replace(/\.gpx$/, '');
for (const tour of await skiTours(page.request, tripId)) {
if (!names.includes(tour.name) && !(names.includes(SKI_TRACK.name) && tour.name === base)) continue;
const res = await page.request.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
if (!res.ok()) console.warn(`[ski fixtures] could not delete "${tour.name}": ${res.status()}`);
}
}
/**
* Forget the editor's unsaved draft of the ski week. The planner keeps it on
* the account and opens it again on the next page load, so a draft one guide
* leaves behind would greet the next one in place of Plan new tour. Older
* builds kept it in this browser as `tour-draft-<tripId>`, which goes too.
*/
export async function forgetSkiDraft(page: Page): Promise<void> {
const tripId = await skiTripId(page.request);
const res = await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => null);
if (res && !res.ok() && res.status() !== 404)
console.warn(`[ski fixtures] could not forget the tour draft: ${res.status()}`);
await page
.evaluate((id) => {
try {
localStorage.removeItem(`tour-draft-${id}`);
} catch {
// Storage blocked: nothing was kept there either.
}
}, tripId)
.catch(() => undefined);
}
+176
View File
@@ -0,0 +1,176 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
import { TOUR_NAMES, TOUR_TRIP } from './tour-fixtures';
/**
* What the city walk pictures need beyond `ensureToursFixtures`: opening hours
* on one stop of the Bolzano walk, so the walk's day shows what a stay that
* runs past closing time looks like, and the Google Maps link the import
* guide pastes. Nothing here is run by importing it.
*/
/**
* The stop that gets opening hours, and the hours. The South Tyrol Museum of
* Archaeology is open every day in July from ten to six; the walk starts at
* 15:00 on day 1 and reaches it after the arcades and the market, so its
* hour and a half runs past closing time.
*/
export const WALK_HOURS = { stop: 'South Tyrol Museum of Archaeology', hours: 'Mo-Su 10:00-18:00' } as const;
type Json = Record<string, unknown>;
type Point = [number, number, number | null];
async function body<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
/**
* Give the museum stop of the Bolzano walk its opening hours, once. A walk's
* stops only carry hours where they were found on OpenStreetMap, and the
* fixture draws the walk by hand, so they are written here with a save of
* the tour as it stands: the same line, the same stops, one field more. A
* failure warns and leaves the walk as it was; the pictures then show the
* arrivals without a warning.
*/
export async function ensureWalkHours(api: APIRequestContext): Promise<void> {
try {
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const tripId = trips.find((t) => t.title === TOUR_TRIP)?.id;
if (!tripId) throw new Error(`no trip "${TOUR_TRIP}"`);
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${tripId}/tours`),
'tours'
);
const placeId = tours.find((t) => t.name === TOUR_NAMES.cityWalk)?.place_id;
if (!placeId) throw new Error(`no tour "${TOUR_NAMES.cityWalk}"`);
const detail = await body<{ tour: Json; waypoints: Json[] }>(
await api.get(`/api/trips/${tripId}/tours/${placeId}`),
'the walk'
);
const museum = detail.waypoints.find((w) => w.name === WALK_HOURS.stop);
if (!museum) throw new Error(`the walk has no stop "${WALK_HOURS.stop}"`);
if (museum.opening_hours === WALK_HOURS.hours) return;
const placesBody = await body<{ places?: Json[] } | Json[]>(await api.get(`/api/trips/${tripId}/places`), 'places');
const place = (Array.isArray(placesBody) ? placesBody : (placesBody.places ?? [])).find((p) => p.id === placeId);
const raw = place?.route_geometry;
const line = (typeof raw === 'string' ? JSON.parse(raw) : (raw ?? [])) as Point[];
if (line.length < 2 || line.some((p) => typeof p[2] !== 'number' || !Number.isFinite(p[2]))) {
throw new Error('the walk has no line with heights to save again');
}
const tour = detail.tour;
const minutes = typeof tour.duration === 'number' ? tour.duration : null;
const res = await api.put(`/api/trips/${tripId}/tours/${placeId}`, {
data: {
name: tour.name,
tour_type: 'walk',
route_geometry: line,
waypoints: detail.waypoints.map((w) => (w === museum ? { ...w, opening_hours: WALK_HOURS.hours } : w)),
duration_seconds: minutes === null ? null : minutes * 60,
break_additional_minutes: tour.break_additional_minutes ?? null,
planned_duration_minutes: tour.planned_duration_minutes ?? null,
description: tour.description ?? null,
website: tour.website ?? null,
expected_revision: tour.revision ?? null,
},
});
if (!res.ok()) throw new Error(`save the walk: ${res.status()} ${await res.text()}`);
} catch (err) {
console.warn(
`[walk-fixtures] the museum stop has no opening hours: ${err instanceof Error ? err.message : String(err)}`
);
}
}
// ── The Google Maps link ──────────────────────────────────────────────────────
/**
* A walking route through Ortisei, in Val Gardena where the trip stays, in
* the shape Google Maps shares one: the stops as path segments, then the
* viewport, then the data blob with one block per stop holding its position
* (`!2m2!1d<lng>!2d<lat>`) and `!3e2`, on foot. The server reads it without
* asking Google (`directions-url.helpers.ts`), so the picture needs no
* network for the preview. The place ids in the blocks are opaque to TREK.
*/
const ORTISEI_STOPS = [
{
name: 'Piazza S. Antonio, 39046 Ortisei BZ, Italy',
lat: 46.57478,
lng: 11.67228,
cid: '0x4778f4c0d1a2b3c5:0x2f6a1e0b9c4d7e13',
},
{
name: 'Museum Gherdëina, Strada Rezia 83, 39046 Ortisei BZ, Italy',
lat: 46.57589,
lng: 11.67428,
cid: '0x4778f4c2a9e1d0b7:0x6b3c2d1e0f9a8b75',
},
{
name: 'Chiesa parrocchiale di Sant’Ulrico, 39046 Ortisei BZ, Italy',
lat: 46.57673,
lng: 11.6708,
cid: '0x4778f4bd6c5e4f31:0x1d9e8f7a6b5c4d29',
},
{
name: 'Promenade Luis Trenker, 39046 Ortisei BZ, Italy',
lat: 46.5741,
lng: 11.6686,
cid: '0x4778f4b9e8d7c6a3:0x7c6b5a4f3e2d1c08',
},
] as const;
/** A path segment the way Google writes one: commas kept, spaces as plus signs. */
const segment = (name: string) => encodeURIComponent(name).replace(/%2C/g, ',').replace(/%20/g, '+');
function googleWalkLink(): string {
const blocks = ORTISEI_STOPS.map((s) => `!1m5!1m1!1s${s.cid}!2m2!1d${s.lng}!2d${s.lat}`).join('');
// The inner route message holds six tokens per stop and the mode; the outer one that and itself.
const inner = ORTISEI_STOPS.length * 6 + 1;
return (
`https://www.google.com/maps/dir/${ORTISEI_STOPS.map((s) => segment(s.name)).join('/')}` +
`/@46.5755,11.6715,17z/data=!4m${inner + 1}!4m${inner}${blocks}!3e2`
);
}
export const GOOGLE_WALK = {
link: googleWalkLink(),
/** The first and the last stop as the preview names them. */
first: ORTISEI_STOPS[0].name,
last: ORTISEI_STOPS[ORTISEI_STOPS.length - 1].name,
count: ORTISEI_STOPS.length,
} as const;
// ── Visits ────────────────────────────────────────────────────────────────────
/**
* Take every stop of the Higashiyama walk on Japan day 9 back to planned,
* so the check-off guide finds nothing marked and leaves nothing marked.
*/
export async function resetKyotoVisits(api: APIRequestContext): Promise<void> {
const japan = seededTrip();
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${japan.tripId}/tours`),
'Japan tours'
);
const walkId = tours.find((t) => t.name === TOUR_NAMES.kyotoWalk)?.place_id;
if (!walkId) return;
const { assignments } = await body<{ assignments: { id: number; place_id: number }[] }>(
await api.get(`/api/trips/${japan.tripId}/days/${japan.dayIds[8]}/assignments`),
'Japan day 9'
);
const assignment = assignments.find((a) => a.place_id === walkId);
if (!assignment) return;
const { visits } = await body<{ visits: { assignment_id: number; station_key: string; status: string }[] }>(
await api.get(`/api/trips/${japan.tripId}/tour-visits`),
'visits'
);
for (const visit of visits.filter((v) => v.assignment_id === assignment.id)) {
await api.put(`/api/trips/${japan.tripId}/tour-visits/${assignment.id}/${visit.station_key}`, {
data: { status: 'planned' },
});
}
}
+186
View File
@@ -0,0 +1,186 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, settle } from './guide';
import { TOUR_TRIP } from './tour-fixtures';
import { closeDayDetails, selectDay, tripReady } from './trip-shared';
/**
* What the Plan-tab tour pictures need beyond `ensureToursFixtures`: the way
* into the Dolomites trip with the right column in Tours mode, a timed stop
* after the horse ride for the overrun line, and the writes the guides take
* back in their `cleanup`. Not a `.guide.ts`, so importing it registers no tests.
*/
type Api = APIRequestContext;
interface AssignmentRow {
id: number;
place_id: number;
assignment_time?: string | null;
place_time?: string | null;
}
/** The timed stop after the horse ride on day 2, made by the times guide and removed again. */
export const LUNCH = {
name: 'Lunch at Gostner Schwaige',
lat: 46.5546,
lng: 11.6347,
address: 'Compatsch, Seiser Alm',
time: '12:30',
};
async function body<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
return (await res.json()) as T;
}
export async function tripIdOf(api: Api, title = TOUR_TRIP): Promise<number> {
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const trip = trips.find((t) => t.title === title);
if (!trip) throw new Error(`no trip "${title}": ensureToursFixtures has not run`);
return trip.id;
}
/** A tour's place id by its name, or null when it is not in the trip. */
export async function tourIdOf(api: Api, tripId: number, name: string): Promise<number | null> {
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${tripId}/tours`),
'tours'
);
return tours.find((t) => t.name === name)?.place_id ?? null;
}
export async function assignmentsOf(api: Api, tripId: number, dayId: number): Promise<AssignmentRow[]> {
return (
await body<{ assignments: AssignmentRow[] }>(
await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`),
'assignments'
)
).assignments;
}
/** Take a place off every day it is on. */
export async function offAllDays(api: Api, tripId: number, dayIds: number[], placeId: number): Promise<void> {
for (const dayId of dayIds) {
for (const a of await assignmentsOf(api, tripId, dayId)) {
if (a.place_id === placeId) await api.delete(`/api/trips/${tripId}/days/${dayId}/assignments/${a.id}`);
}
}
}
/** Put a tour back on a day with its start time, as the fixtures had it. */
export async function backOnDay(
api: Api,
tripId: number,
dayId: number,
placeId: number,
time: string | null
): Promise<void> {
let row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
if (!row) {
await body(
await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: placeId } }),
'put back on its day'
);
row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
}
if (row)
await api.put(`/api/trips/${tripId}/assignments/${row.id}/time`, { data: { place_time: time, end_time: null } });
}
/** Give a tour's day entry its start again (and no end), as the fixtures had it. */
export async function resetTime(api: Api, tripId: number, dayId: number, placeId: number, time: string): Promise<void> {
const row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
if (row)
await api.put(`/api/trips/${tripId}/assignments/${row.id}/time`, { data: { place_time: time, end_time: null } });
}
/** The lunch stop on `dayId`, at the end of the day and timed, made once. */
export async function ensureLunch(api: Api, tripId: number, dayId: number): Promise<void> {
const places = await body<{ places?: { id: number; name: string }[] } | { id: number; name: string }[]>(
await api.get(`/api/trips/${tripId}/places`),
'places'
);
const list = Array.isArray(places) ? places : (places.places ?? []);
let id = list.find((p) => p.name === LUNCH.name)?.id;
if (!id) {
const created = await body<{ place: { id: number } }>(
await api.post(`/api/trips/${tripId}/places`, {
data: { name: LUNCH.name, lat: LUNCH.lat, lng: LUNCH.lng, address: LUNCH.address },
}),
'create the lunch stop'
);
id = created.place.id;
}
await backOnDay(api, tripId, dayId, id, LUNCH.time);
}
export async function removeByName(api: Api, tripId: number, name: string): Promise<void> {
const places = await body<{ places?: { id: number; name: string }[] } | { id: number; name: string }[]>(
await api.get(`/api/trips/${tripId}/places`),
'places'
);
const list = Array.isArray(places) ? places : (places.places ?? []);
for (const place of list.filter((p) => p.name === name)) await api.delete(`/api/trips/${tripId}/places/${place.id}`);
}
/** Forget the tours hidden on the map; the switch is kept in this browser only. */
export async function clearHiddenTours(page: Page): Promise<void> {
await page.evaluate(() => {
try {
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith('trek:hidden-tours:')) window.localStorage.removeItem(key);
}
} catch {
// No storage, nothing hidden.
}
});
}
const rawKey = (page: Page) =>
page.getByText(/^(common|dayplan|planner|places|trip|day|tours|packing|inspector)\.[A-Za-z0-9.]+$/);
/** The Places | Tours switch at the top of the right column. */
export const modeSwitch = (page: Page) => page.getByRole('tablist', { name: 'Add view' });
export const modeTab = (page: Page, name: 'Places' | 'Tours') =>
modeSwitch(page).getByRole('tab', { name, exact: true });
/**
* Open the Dolomites trip on the Plan tab the way `openTrip` opens the Japan
* one: notices cleared, waiting for the page and its texts, day 1 selected and
* its details panel closed again. With `tours`, the right column is switched
* to Tours and the list has loaded.
*/
export async function openToursPlan(page: Page, opts: { tours?: boolean } = {}): Promise<number> {
const tripId = await tripIdOf(page.request);
await page.goto(`/trips/${tripId}`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
await page.waitForTimeout(4000);
await page.reload();
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
await selectDay(page, 1);
await closeDayDetails(page);
if (opts.tours) {
await modeTab(page, 'Tours').click();
await expect(modeTab(page, 'Tours')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('listbox', { name: 'Tours', exact: true }).getByRole('option').first()).toBeVisible({
timeout: 30_000,
});
await settle(page);
}
// The map tiles land after the page says it is idle.
await page.waitForTimeout(1200);
return tripId;
}
+257
View File
@@ -0,0 +1,257 @@
import { request as apiRequest, type APIRequestContext, type Page } from '@playwright/test';
import { E2E_BASE_URL } from '../../playwright.config';
/**
* The Vacay state the guides set up through the API: the seeded member mira
* fused into the admin's plan, a fusion request from her, a public-holiday
* calendar to edit. Every helper is idempotent, so a retried or a single
* guide finds what it needs, and each has its way back to the seed.
*/
const VACAY = '/api/addons/vacay';
/**
* The seeded member the fusion and sharing guides play with. Not jonas: the
* share guide shares with jonas, and two rows of the same name would hide
* which one is which.
*/
export const PARTNER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' };
interface PlanData {
plan: { id: number; holiday_calendars: { id: number; type?: string | null; color: string; label: string | null }[] };
users: { id: number; username: string; color: string }[];
pendingInvites: { user_id: number }[];
incomingInvites: { plan_id: number; username: string }[];
isFused: boolean;
}
async function ok(res: { ok(): boolean; status(): number; text(): Promise<string> }, what: string): Promise<void> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
}
/**
* A request context acting as the member. Its own context with a bearer
* token: a login on the page's context would put the member's session cookie
* on the admin's requests.
*/
export async function asMember(member: typeof PARTNER = PARTNER): Promise<APIRequestContext> {
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined });
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } });
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined;
await anon.dispose();
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`);
return apiRequest.newContext({
baseURL: E2E_BASE_URL,
storageState: undefined,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
async function meId(api: APIRequestContext): Promise<number> {
return ((await (await api.get('/api/auth/me')).json()) as { user: { id: number } }).user.id;
}
/** The admin's active plan, as the page loads it. */
export async function adminPlan(page: Page): Promise<PlanData> {
const res = await page.request.get(`${VACAY}/plan`);
await ok(res, 'could not load the vacay plan');
return (await res.json()) as PlanData;
}
/** The admin's own id. */
export const adminId = (page: Page): Promise<number> => meId(page.request);
/**
* Mira fused into the admin's plan, the admin its owner. The invite guide may
* have left an invite pending already; otherwise the admin sends one.
*/
export async function ensureFused(page: Page): Promise<void> {
const data = await adminPlan(page);
if (data.isFused && data.users.some((u) => u.username === PARTNER.username)) return;
const member = await asMember();
try {
const partnerId = await meId(member);
if (!data.pendingInvites.some((i) => i.user_id === partnerId)) {
await ok(
await page.request.post(`${VACAY}/invite`, { data: { user_id: partnerId } }),
`could not invite ${PARTNER.username}`
);
}
await ok(
await member.post(`${VACAY}/invite/accept`, { data: { plan_id: data.plan.id } }),
`${PARTNER.username} could not accept`
);
} finally {
await member.dispose();
}
}
/** Back to separate plans. The admin owns the plan, so everyone's entries go home. */
export async function ensureSeparate(page: Page): Promise<void> {
if (!(await adminPlan(page)).isFused) return;
await ok(await page.request.post(`${VACAY}/dissolve`), 'could not dissolve the fusion');
}
/** Clear what the partner has logged in the admin's plan on these dates. */
export async function clearPartnerDays(page: Page, year: number, dates: string[]): Promise<void> {
const res = await page.request.get(`${VACAY}/entries/${year}`);
await ok(res, 'could not load the entries');
const { entries } = (await res.json()) as {
entries: { user_id: number; date: string; fraction: number; kind: string | null }[];
};
const member = await asMember();
try {
const partnerId = await meId(member);
for (const e of entries.filter((x) => x.user_id === partnerId && dates.includes(x.date))) {
// The same day with the same fraction and kind again is what clears it.
await ok(
await member.post(`${VACAY}/entries/toggle`, {
data: { date: e.date, fraction: e.fraction, kind: e.kind ?? 'vacation' },
}),
`could not clear ${e.date}`
);
}
} finally {
await member.dispose();
}
}
/**
* A fusion request from mira to the admin, which opens the Fusion Request
* dialog on the admin's next visit. Both have to be on their own plans first,
* or her invite would come from the plan the admin already owns.
*/
export async function requestFusionFromPartner(page: Page): Promise<void> {
await ensureSeparate(page);
const target = await adminId(page);
const member = await asMember();
try {
const res = await member.post(`${VACAY}/invite`, { data: { user_id: target } });
if (!res.ok() && !(await res.text()).includes('Invite already pending')) {
throw new Error(`${PARTNER.username} could not invite the admin: ${res.status()}`);
}
} finally {
await member.dispose();
}
}
/** Take back what requestFusionFromPartner did: the request and the notification it sent. */
export async function clearFusionRequest(page: Page): Promise<void> {
for (const inv of (await adminPlan(page)).incomingInvites) {
await ok(
await page.request.post(`${VACAY}/invite/decline`, { data: { plan_id: inv.plan_id } }),
'could not decline the request'
);
}
await deleteNotifications(page, 'notif.vacay_invite.title', PARTNER.username);
}
/** Remove the in-app notifications of one kind that a member sent the admin. */
export async function deleteNotifications(page: Page, titleKey: string, sender: string): Promise<void> {
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
notifications: { id: number; title_key: string; sender_username: string | null }[];
};
for (const n of inbox.notifications.filter((x) => x.title_key === titleKey && x.sender_username === sender)) {
await page.request.delete(`/api/notifications/in-app/${n.id}`);
}
}
/** The member shares her calendar with the admin, as she would from her own Vacay. */
export async function shareWithAdmin(page: Page): Promise<void> {
const target = await adminId(page);
const member = await asMember();
try {
const res = await member.post(`${VACAY}/shares`, { data: { user_id: target } });
// A retried guide finds the share from its first attempt still there.
if (!res.ok() && !(await res.text()).includes('Already shared')) {
throw new Error(`could not share ${PARTNER.username}'s calendar with the admin: ${res.status()}`);
}
} finally {
await member.dispose();
}
}
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
export async function unshareWithAdmin(page: Page): Promise<void> {
const shares = (await (await page.request.get(`${VACAY}/shares`)).json()) as {
incoming: { id: number; username: string }[];
};
for (const s of shares.incoming.filter((x) => x.username === PARTNER.username)) {
await ok(await page.request.delete(`${VACAY}/shares/${s.id}`), `could not remove ${PARTNER.username}'s share`);
}
await deleteNotifications(page, 'notif.vacay_share.title', PARTNER.username);
}
/** The admin's own colour, to put back after the colour guide. */
export async function ownColor(page: Page): Promise<string> {
const me = await adminId(page);
const user = (await adminPlan(page)).users.find((u) => u.id === me);
if (!user) throw new Error('the admin is not on their own plan');
return user.color;
}
export async function setOwnColor(page: Page, color: string): Promise<void> {
await ok(await page.request.put(`${VACAY}/color`, { data: { color } }), 'could not set the colour');
}
/**
* At least one public-holiday calendar, for the guide that edits one. The
* public-holidays guide adds it in a full run; on its own the guide adds the
* same Berlin calendar here.
*/
export async function ensurePublicCalendar(page: Page): Promise<void> {
const { plan } = await adminPlan(page);
await ok(
await page.request.put(`${VACAY}/plan`, { data: { holidays_enabled: true } }),
'could not switch public holidays on'
);
if (plan.holiday_calendars.some((c) => (c.type ?? 'public_holiday') === 'public_holiday')) return;
await ok(
await page.request.post(`${VACAY}/plan/holiday-calendars`, {
data: { region: 'DE-BE', label: 'Berlin', type: 'public_holiday' },
}),
'could not add the Berlin calendar'
);
}
/** The colour of every holiday calendar, by id, to put back after the edit guide. */
export async function calendarColors(page: Page): Promise<Map<number, string>> {
return new Map((await adminPlan(page)).plan.holiday_calendars.map((c) => [c.id, c.color]));
}
export async function restoreCalendarColors(page: Page, colors: Map<number, string>): Promise<void> {
for (const cal of (await adminPlan(page)).plan.holiday_calendars) {
const was = colors.get(cal.id);
if (was && was !== cal.color) {
await ok(
await page.request.put(`${VACAY}/plan/holiday-calendars/${cal.id}`, { data: { color: was } }),
'could not restore the colour'
);
}
}
}
/** The fraction of the company holiday on a date, or null when there is none. */
export async function companyHolidayFraction(page: Page, year: number, date: string): Promise<number | null> {
const res = await page.request.get(`${VACAY}/entries/${year}`);
await ok(res, 'could not load the entries');
const { companyHolidays } = (await res.json()) as { companyHolidays: { date: string; fraction: number }[] };
return companyHolidays.find((c) => c.date === date)?.fraction ?? null;
}
/** Put a company holiday back to a size, or remove it (null). The same size again clears it. */
export async function setCompanyHoliday(
page: Page,
year: number,
date: string,
fraction: number | null
): Promise<void> {
const now = await companyHolidayFraction(page, year, date);
if (now === fraction) return;
// Clearing sends the size it has; converting sends the size it should get.
const send = fraction === null ? now : fraction;
await ok(
await page.request.post(`${VACAY}/entries/company-holiday`, { data: { date, fraction: send } }),
`could not reset ${date}`
);
}
+146 -7
View File
@@ -1,6 +1,6 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { PICTURE_DAY_ISO } from '../dates'
import { mkdirSync } from 'node:fs'
import { mkdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import type { HelpGuide } from '../../src/help/types'
@@ -44,9 +44,36 @@ export const VIEWPORT = { width: 1920, height: 1080 }
*/
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
/** Put the page on the day the pictures are taken on. Call before navigating. */
/** Where each page's pinned clock stands; it only ever moves on. */
const pinnedAt = new WeakMap<Page, number>()
const ticking = new WeakSet<Page>()
/**
* Move the pinned clock on, a few seconds by default, and never back. A
* Leaflet pan timed from `+new Date()` ends on the next frame, and a wait
* measured with `Date.now()` sees its time pass.
*/
export async function nudgeClock(page: Page, ms = 5_000): Promise<void> {
const at = (pinnedAt.get(page) ?? PICTURE_DAY.getTime()) + ms
pinnedAt.set(page, at)
await page.clock.setFixedTime(new Date(at))
}
/**
* Put the page on the day the pictures are taken on. Call before navigating.
* The clock then moves a second on each second: the public Valhalla is asked
* at most once a second, timed with `Date.now()`, and on a clock that stands
* still that second never passes and a tour's route waits for good.
*/
async function pinClock(page: Page): Promise<void> {
await page.clock.setFixedTime(PICTURE_DAY)
if (!pinnedAt.has(page)) pinnedAt.set(page, PICTURE_DAY.getTime())
await page.clock.setFixedTime(new Date(pinnedAt.get(page) as number))
if (ticking.has(page)) return
ticking.add(page)
const tick = setInterval(() => {
nudgeClock(page, 1_000).catch(() => clearInterval(tick))
}, 1_000)
page.once('close', () => clearInterval(tick))
}
export interface StepAction {
@@ -67,6 +94,17 @@ export interface StepAction {
dropTo?: (page: Page) => Locator
/** What the step does. */
act?: (page: Page) => Promise<void>
/**
* A short loop of the step, for a gesture a still cannot show (a drag, a
* route being drawn). Do the gesture and call `frame()` wherever a frame
* belongs; the loop is cut from the same window as the still, with the
* step's number in its corner and a drawn pointer, since a headless
* screenshot has none. When set it replaces `act`: what the loop shows is
* what the step does. It is recorded only while the step is registered
* under `clips`; a browser drag shows no dragged row in a recording, so such
* steps keep the still with its arrow and run the gesture unrecorded.
*/
clip?: (page: Page, frame: () => Promise<void>) => Promise<void>
}
export interface GuideScript {
@@ -146,19 +184,30 @@ export async function captureGuide(page: Page, script: GuideScript): Promise<voi
else await target.hover()
await settle(page)
box = (await target.boundingBox()) ?? box
let shot: Box
if (step.dropTo) {
const dest = step.dropTo(page)
await expect(dest, `guide "${guide.id}" step ${n}: drop target`).toBeVisible()
const to = await dest.boundingBox()
if (!to) throw new Error(`guide "${guide.id}" step ${n}: drop target has no box`)
await ringDrag(page, box, to, n)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
shot = frameFor(union(box, to))
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
} else {
const frame = frameFor(box)
await ring(page, box, n, frame)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frame })
shot = frameFor(box)
await ring(page, box, n, shot)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
}
await unring(page)
if (step.clip) {
// Only a step the guide registers under `clips` is recorded; the others do the gesture unrecorded.
if (guide.media.clips?.includes(n)) {
await recordLoop(page, path.join(dir, `step-${n}-loop`), shot, n, step.clip, `guide "${guide.id}" step ${n}`)
} else {
await step.clip(page, async () => undefined)
}
continue
}
}
await step.act?.(page)
}
@@ -399,6 +448,96 @@ async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void
)
}
// ── Loops ────────────────────────────────────────────────────────────────────
/**
* The usual body of a drag loop: a short rest on the item, the press, a glide
* to the destination in even steps with a frame each, the release, a rest on
* the result. Libraries that wait for a few pixels of movement before a drag
* starts (dnd-kit, react-beautiful-dnd) get them from the first small steps.
*/
export async function dragLoop(page: Page, from: Locator, to: Locator, frame: () => Promise<void>, steps = 24): Promise<void> {
const a = await from.boundingBox()
const b = await to.boundingBox()
if (!a || !b) throw new Error('dragLoop: the item or the destination has no box')
const start = { x: a.x + a.width / 2, y: a.y + a.height / 2 }
const end = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
await page.mouse.move(start.x - 30, start.y + 20)
for (let i = 1; i <= 4; i++) {
await page.mouse.move(start.x - 30 + (30 * i) / 4, start.y + 20 - (20 * i) / 4)
await frame()
}
await page.mouse.down()
for (const nudge of [4, 8, 12]) await page.mouse.move(start.x + nudge, start.y + nudge)
await frame()
for (let i = 1; i <= steps; i++) {
const k = i / steps
const ease = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2
await page.mouse.move(start.x + (end.x - start.x) * ease, start.y + (end.y - start.y) * ease, { steps: 2 })
await frame()
}
await page.mouse.up()
await page.waitForTimeout(400)
for (let i = 0; i < 3; i++) await frame()
}
/**
* Record a step's loop as numbered PNG frames; promote.mjs turns the directory
* into `step-<n>.loop.webp`. The frames carry the step's number in the corner,
* like the still, and a drawn pointer that follows the mouse.
*/
async function recordLoop(
page: Page,
dir: string,
shot: Box,
n: number,
clip: NonNullable<StepAction['clip']>,
label: string,
): Promise<void> {
rmSync(dir, { recursive: true, force: true })
mkdirSync(dir, { recursive: true })
await page.evaluate(
({ shot, n }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const layer = document.createElement('div')
layer.id = 'trek-help-loop'
Object.assign(layer.style, { position: 'fixed', inset: '0', pointerEvents: 'none', zIndex: '2147483647' })
const badge = document.createElement('div')
Object.assign(badge.style, {
position: 'absolute', left: `${shot.x + 10}px`, top: `${shot.y + 10}px`, width: '28px', height: '28px',
borderRadius: '999px', background: accent, color: accentText, textAlign: 'center',
font: '700 13px/28px Poppins, system-ui, sans-serif', boxShadow: '0 0 0 2px rgba(255,255,255,0.92), 0 2px 8px rgba(0,0,0,0.25)',
})
badge.textContent = String(n)
const pointer = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
pointer.setAttribute('width', '24')
pointer.setAttribute('height', '24')
pointer.setAttribute('viewBox', '0 0 24 24')
pointer.innerHTML = '<path d="M4 2 L4 19 L8.5 14.8 L11.6 21.5 L14.4 20.2 L11.3 13.6 L17.5 13.6 Z" fill="#111827" stroke="#ffffff" stroke-width="1.6" stroke-linejoin="round"/>'
Object.assign(pointer.style, { position: 'absolute', left: '-40px', top: '-40px', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))' })
layer.append(badge, pointer)
document.body.appendChild(layer)
document.addEventListener('mousemove', e => {
pointer.style.left = `${e.clientX - 4}px`
pointer.style.top = `${e.clientY - 2}px`
}, { capture: true })
document.addEventListener('pointermove', e => {
pointer.style.left = `${e.clientX - 4}px`
pointer.style.top = `${e.clientY - 2}px`
}, { capture: true })
},
{ shot, n },
)
let count = 0
await clip(page, async () => {
await page.screenshot({ path: path.join(dir, `${String(count).padStart(4, '0')}.png`), clip: shot })
count++
})
await page.evaluate(() => document.getElementById('trek-help-loop')?.remove())
if (count < 2) throw new Error(`${label}: a loop needs at least two frames, got ${count}`)
}
async function unring(page: Page): Promise<void> {
await page.evaluate(() => document.getElementById('trek-help-ring')?.remove())
}
+420 -4
View File
@@ -2,7 +2,8 @@ import { test, expect, type APIResponse, type Locator, type Page } from '@playwr
import { readFileSync } from 'node:fs'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
import { connectJournalDawarich, releaseJournalDawarich, STAY_DAY, writtenEntryIds } from './fixtures-journey'
import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey'
import type { HelpGuide } from '../../src/help/types'
@@ -344,6 +345,109 @@ async function unlinkTrip(page: Page, title: string): Promise<void> {
await answer(res, `unlink "${title}"`)
}
/** The fixtures leave every entry published; null is not a value the column takes, false is. */
async function clearDraft(page: Page, title: string): Promise<void> {
const entry = await journalEntry(page, title)
await answer(await page.request.patch(`/api/journeys/entries/${entry.id}`, { data: { is_draft: false } }), `publish "${title}" again`)
}
async function setJourney(page: Page, data: Record<string, boolean>): Promise<void> {
await answer(await page.request.patch(`/api/journeys/${journeyId}`, { data }), `update the journey with ${JSON.stringify(data)}`)
}
// ── The map and a recorded day ────────────────────────────────────────────────
/** The frosted switches at the top of the map: Plan, Recorded, Entries, Photos and the key. */
const layerBar = (page: Page) => page.getByRole('group', { name: 'Map layers' })
/**
* A hollow plan pin inside the map's frame. The map keeps the pins it drew
* outside the view in the DOM as well, and the plan pins carry no class of
* their own, only their 22 × 28 drawing, so the one in view is marked for the
* step and found by the mark.
*/
const PIN_MARK = 'data-help-plan-pin'
/** A pin of either map engine: Leaflet's, or the vector map's. */
const MARKERS = ':is(.leaflet-marker-icon, .maplibregl-marker, .mapboxgl-marker)'
const planPin = (page: Page) => page.locator(`[${PIN_MARK}]`)
/**
* The plan pins lie below the entry pins on purpose, and in the Japan journey
* most planned places carry an entry on the same spot, so a pin is only taken
* when its own centre is what a click there would hit. The layer is also drawn
* anew on every moveend, which drops a mark set while the first fit still
* moves the map, so the pin must be found at the same place twice in a row.
*/
async function markPlanPin(page: Page): Promise<void> {
// Zoomed out over the whole trip, the plan pins gather into discs and the one
// left alone sits under an entry pin; each zoom step pulls them apart.
for (let zoom = 0; zoom < 2; zoom++) {
if (await findPlanPin(page, 6_000).then(() => true, () => false)) return
// The map's own + in its corner; it carries no name, only the sign.
await mapPanel(page).getByRole('button', { name: '+', exact: true }).click()
await page.waitForTimeout(800)
}
await findPlanPin(page, 20_000)
}
/** Mark a plan pin that lies well inside the frame and that nothing covers, once the map holds still. */
async function findPlanPin(page: Page, timeout: number): Promise<void> {
let last = ''
await expect
.poll(
async () => {
const found = await mapPanel(page).evaluate((aside, { attr, markers }) => {
for (const old of Array.from(document.querySelectorAll(`[${attr}]`))) old.removeAttribute(attr)
const frame = aside.getBoundingClientRect()
const pins = Array.from(aside.querySelectorAll(markers)).filter(el => el.querySelector('svg[viewBox="0 0 22 28"]'))
for (const el of pins) {
const r = el.getBoundingClientRect()
// Clear of the layer bar at the top and the edges, so the card it opens has room.
if (!(r.left > frame.left + 80 && r.right < frame.right - 80 && r.top > frame.top + 140 && r.bottom < frame.bottom - 80)) continue
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
if (!hit || !el.contains(hit)) continue
el.setAttribute(attr, '')
return `${Math.round(r.left)}:${Math.round(r.top)}`
}
return ''
}, { attr: PIN_MARK, markers: MARKERS })
const steady = found !== '' && found === last
last = found
return steady
},
{ timeout, intervals: [400], message: 'no plan pin inside the journey map that an entry pin does not cover' },
)
.toBe(true)
}
/** One day of the timeline, by the date it carries, and its header row. */
const dayBlock = (page: Page, date = STAY_DAY) => page.locator(`[data-day="${date}"]`)
const dayHeader = (page: Page, date = STAY_DAY) => dayBlock(page, date).locator(':scope > div').first()
/** The folded row of the day's stays and the button that opens it. */
const stayRowToggle = (page: Page) => dayBlock(page).getByRole('button', { name: /stays? from Dawarich/ })
/** The dialog a recorded day opens in: the shared DialogShell, titled with the day. */
const dayDialog = (page: Page, button: RegExp | string) =>
page.getByRole('dialog').filter({ has: page.getByRole('button', { name: button }) }).last()
const recordingDialog = (page: Page) => dayDialog(page, 'Keep route')
const SUBMIT = /^Take over \d+ drafts?$/
const importDialog = (page: Page) => dayDialog(page, SUBMIT)
/** The entries a recorded-day guide found, so its cleanup deletes only what it wrote. */
let entriesBefore = new Set<number>()
/** Dawarich connected with the stays waiting, then the journal scrolled to the stay day. */
async function openRecordedDay(page: Page): Promise<void> {
await connectJournalDawarich(page.request, journeyId)
entriesBefore = await writtenEntryIds(page.request, journeyId)
await openJournal(page)
await expect(dayBlock(page)).toBeVisible({ timeout: 20_000 })
await expect(dayBlock(page).getByTestId('journey-day-recording')).toBeVisible({ timeout: 20_000 })
await dayHeader(page).evaluate(el => el.scrollIntoView({ block: 'start' }))
await settle(page)
}
const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request, journeyId, entriesBefore)
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
@@ -560,6 +664,45 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'entry-draft': {
guide: guide('entry-draft'),
start: async p => {
await clearDraft(p, ASAKUSA)
await openJournal(p)
},
cleanup: p => clearDraft(p, ASAKUSA),
steps: [
{
prepare: async p => {
await editEntry(p, ASAKUSA)
await editor(p).getByRole('button', { name: 'Draft', exact: true }).scrollIntoViewIfNeeded()
await settle(p)
},
// The row with the label and its hint, the switch at its end.
target: p => editor(p).getByRole('button', { name: 'Draft', exact: true }).locator('xpath=..'),
hover: p => editor(p).getByRole('button', { name: 'Draft', exact: true }).hover(),
act: async p => {
const toggle = editor(p).getByRole('button', { name: 'Draft', exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
await beat(p, 400)
},
},
{
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
const badge = entryCard(p, ASAKUSA).getByText('Draft', { exact: true })
await expect(badge).toBeVisible({ timeout: 15_000 })
await entryCard(p, ASAKUSA).evaluate(el => el.scrollIntoView({ block: 'center' }))
await blurFocus(p)
await settle(p)
},
},
],
},
suggestions: {
guide: guide('suggestions'),
start: openJournal,
@@ -671,11 +814,32 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// The day count between the two round buttons at the foot of the timeline.
prepare: async p => {
await p.getByPlaceholder('Search this journey').fill('')
await settle(p)
},
target: p => p.getByRole('button', { name: /^(Hide|Show) suggestions$/ }).first(),
target: p => p.getByRole('button', { name: 'Jump to a day' }),
act: async p => {
await p.getByRole('button', { name: 'Jump to a day' }).click()
await expect(p.getByRole('menu')).toBeVisible()
await settle(p)
},
},
{
// The first day opened onto its entries, so the picture shows both levels.
prepare: async p => {
await p.getByRole('menu').getByRole('button', { name: 'Expand' }).first().click()
await settle(p)
},
target: p => p.getByRole('menu'),
hover: p => p.getByRole('menu').getByRole('menuitem').nth(1).hover(),
act: async p => {
await p.getByRole('menu').getByRole('menuitem').nth(1).click()
await expect(p.getByRole('menu')).toHaveCount(0)
await p.waitForTimeout(800) // the smooth scroll
await settle(p)
},
},
],
},
@@ -691,16 +855,268 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
{
// Upload and, with Immich or Synology connected, a button per library, at the right of the toolbar.
target: p => p.getByRole('button', { name: 'Upload', exact: true }).first(),
},
{
prepare: async p => {
await p.getByRole('button', { name: 'Timeline', exact: true }).first().click()
await settle(p)
await p.waitForTimeout(600) // the map tiles
},
target: mapPanel,
},
],
},
'journey-map-layers': {
guide: guide('journey-map-layers'),
start: openJournal,
steps: [
{
target: layerBar,
// Plan off and on again: the switch is remembered in this browser, and the next guide finds it on.
act: async p => {
const plan = p.getByTestId('journey-map-layer-plan')
await plan.click()
await expect(plan).toHaveAttribute('aria-pressed', 'false')
await beat(p, 600)
await plan.click()
await expect(plan).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
prepare: async p => {
await p.getByTestId('journey-map-key-button').click()
await expect(p.getByTestId('journey-map-key')).toBeVisible()
await settle(p)
},
target: p => p.getByTestId('journey-map-key'),
hover: p => p.getByTestId('journey-map-key-button').hover(),
act: async p => {
await p.getByTestId('journey-map-key-button').click()
await expect(p.getByTestId('journey-map-key')).toHaveCount(0)
await settle(p)
},
},
{
prepare: markPlanPin,
target: planPin,
act: async p => {
await planPin(p).click()
await expect(mapPanel(p).getByText('Planned', { exact: true }).first()).toBeVisible({ timeout: 10_000 })
await settle(p)
},
},
],
},
// ── A recorded day (Dawarich) ───────────────────────────────────────────
'journey-keep-route': {
guide: guide('journey-keep-route'),
start: openRecordedDay,
cleanup: releaseRecordedDay,
steps: [
{
target: p => dayBlock(p).getByTestId('journey-day-recording'),
act: async p => {
await dayBlock(p).getByTestId('journey-day-recording').click()
// The dialog reads the day from Dawarich first; the button waits for the preview.
await expect(recordingDialog(p).getByRole('button', { name: 'Keep route' })).toBeEnabled({ timeout: 60_000 })
await settle(p)
await p.waitForTimeout(600) // the sketch's tiles
},
},
{
target: recordingDialog,
},
{
target: p => recordingDialog(p).getByRole('button', { name: 'Keep route' }),
act: async p => {
await recordingDialog(p).getByRole('button', { name: 'Keep route' }).click()
// Saved, the dialog closes; should it stay, its button reads Already kept and the X closes it.
const kept = p.getByRole('button', { name: 'Already kept' })
await expect.poll(async () => (await p.getByRole('button', { name: 'Keep route' }).count()) === 0 || (await kept.count()) > 0, { timeout: 30_000 }).toBe(true)
if (await anyDialog(p).count()) await closeDialog(p)
// A kept day shows its distance in the header instead of the Dawarich mark.
await expect(dayBlock(p).getByTestId('journey-day-recording')).toContainText(/\d/, { timeout: 15_000 })
await dayHeader(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
await blurFocus(p)
await settle(p)
await p.waitForTimeout(600) // the map draws the route
},
},
],
},
'journey-day-stays': {
guide: guide('journey-day-stays'),
start: async p => {
await openRecordedDay(p)
await expect(stayRowToggle(p)).toBeVisible({ timeout: 20_000 })
await stayRowToggle(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
cleanup: releaseRecordedDay,
steps: [
{
target: stayRowToggle,
act: async p => {
await stayRowToggle(p).click()
await expect(stayRowToggle(p)).toHaveAttribute('aria-expanded', 'true')
await expect(dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first()).toBeVisible()
await settle(p)
},
},
{
target: p => dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first(),
act: async p => {
const before = (await writtenEntryIds(p.request, journeyId)).size
await dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first().click()
await expect.poll(async () => (await writtenEntryIds(p.request, journeyId)).size, { timeout: 15_000 }).toBeGreaterThan(before)
await settle(p)
},
},
{
target: p => dayBlock(p).getByRole('button', { name: 'Not a place I visited' }).first(),
act: async p => {
const rows = dayBlock(p).getByRole('button', { name: 'Not a place I visited' })
const before = await rows.count()
await rows.first().click()
await expect.poll(() => rows.count(), { timeout: 15_000 }).toBeLessThan(before)
await blurFocus(p)
await settle(p)
},
},
],
},
// Nothing is written: Take over turns the stays into drafts for good, and a
// stay taken over is never offered again, so a second run would find none.
'journey-take-over-day': {
guide: guide('journey-take-over-day'),
start: async p => {
await openRecordedDay(p)
await expect(stayRowToggle(p)).toBeVisible({ timeout: 20_000 })
await stayRowToggle(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
cleanup: releaseRecordedDay,
steps: [
{
target: p => dayBlock(p).getByRole('button', { name: 'Take over day', exact: true }),
act: async p => {
await dayBlock(p).getByRole('button', { name: 'Take over day', exact: true }).click()
await expect(importDialog(p).locator('[data-stay-id]').first()).toBeVisible({ timeout: 60_000 })
await settle(p)
await p.waitForTimeout(600) // the day's map
},
},
{
prepare: async p => {
await importDialog(p).locator('[data-stay-id]').first().scrollIntoViewIfNeeded()
await markGroup(p, importDialog(p).locator('[data-stay-id]'))
},
target: groupMark,
hover: p => importDialog(p).locator('[data-stay-id] input').first().hover(),
act: async p => {
await unmarkGroup(p)
await typeInto(p, importDialog(p).locator('[data-stay-id] input').first(), 'Morning on the bridge')
await beat(p, 400)
},
},
{
target: p => importDialog(p).getByRole('group', { name: 'Stays' }),
act: async p => {
await importDialog(p).getByRole('group', { name: 'Stays' }).getByRole('button', { name: 'Day report' }).click()
await expect(importDialog(p).getByRole('button', { name: /^Take over 1 draft$/ })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await importDialog(p).getByRole('button', { name: SUBMIT }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => importDialog(p).getByRole('button', { name: SUBMIT }),
act: async p => {
await importDialog(p).getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(importDialog(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'journey-auto-prepare': {
guide: guide('journey-auto-prepare'),
start: async p => {
await connectJournalDawarich(p.request, journeyId)
entriesBefore = await writtenEntryIds(p.request, journeyId)
await openJournal(p)
},
cleanup: releaseRecordedDay,
steps: [
{ target: settingsButton, act: openSettings },
{
prepare: async p => {
await dialog(p).getByText('Recorded days', { exact: true }).scrollIntoViewIfNeeded()
await settle(p)
},
// The label sits in a row of its own, so the section with its switch is two levels up.
target: p => dialog(p).getByText('Recorded days', { exact: true }).locator('xpath=../..'),
hover: p => dialog(p).getByRole('button', { name: 'Prepare recorded days automatically' }).hover(),
act: async p => {
const toggle = dialog(p).getByRole('button', { name: 'Prepare recorded days automatically' })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 })
await settle(p)
},
},
{
// Switching on fetches at once; the line says when, with Fetch now beside it.
prepare: async p => {
await expect(dialog(p).getByTestId('journey-prep-status')).toBeVisible({ timeout: 30_000 })
// Fetch now shows only while a linked trip has days left to prepare; the fetch it starts first has to end.
await expect(dialog(p).getByRole('button', { name: 'Fetching…' })).toHaveCount(0, { timeout: 60_000 })
await settle(p)
},
target: p => dialog(p).getByTestId('journey-prep-status'),
act: closeDialog,
},
],
},
'photo-location': {
guide: guide('photo-location'),
start: async p => {
await setJourney(p, { photo_location: false })
await openJournal(p)
},
cleanup: p => setJourney(p, { photo_location: false }),
steps: [
{ target: settingsButton, act: openSettings },
{
prepare: async p => {
await dialog(p).getByRole('button', { name: 'Place entries from their photos' }).scrollIntoViewIfNeeded()
await settle(p)
},
// The row with the label, its hint and the switch.
target: p => dialog(p).getByRole('button', { name: 'Place entries from their photos' }).locator('xpath=..'),
hover: p => dialog(p).getByRole('button', { name: 'Place entries from their photos' }).hover(),
act: async p => {
const toggle = dialog(p).getByRole('button', { name: 'Place entries from their photos' })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 })
await beat(p, 400)
await closeDialog(p)
},
},
],
},
'entry-fields': {
guide: guide('entry-fields'),
start: openJournal,
@@ -718,8 +1134,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
// The spare trip gets stations on its days first: a trip without places adds
// nothing to the journal, and the result would show no change at all.
'journey-status': {
guide: guide('journey-status'),
start: openJournal,
@@ -744,6 +1158,8 @@ const SCRIPTS: Record<string, GuideScript> = {
}
},
},
// The spare trip gets stations on its days first: a trip without places adds
// nothing to the journal, and the result would show no change at all.
'link-trip': {
guide: guide('link-trip'),
start: async p => {
+245
View File
@@ -0,0 +1,245 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { notificationsContext, notificationsGuides } from '../../src/help/contexts/notifications';
import type { HelpGuide } from '../../src/help/types';
import { clearNotices } from '../screenshots/shot';
import {
BOOKING_TITLE,
ensureNotificationFixtures,
markUnread,
removeNotificationFixtures,
SHARE_TITLE,
unreadIds,
} from './fixtures-notifications';
import { captureGuide, captureHero, dismissReleaseNotice, settle, VIEWPORT, type GuideScript } from './guide';
/**
* Actions for the notification guides, keyed by the ids in
* `src/help/contexts/notifications.ts`. The admin's inbox holds the seed's
* "New Message" notifications from the Japan trip's chat plus the two
* `fixtures-notifications.ts` adds: jonas saved a booking, mira shared her
* Vacay calendar. A guide that reads or deletes one of those gets it back
* unread in its cleanup, and the file ends by taking both away, so the bell
* every later picture shows counts what the seed left.
*
* Delete all is ringed but never clicked: it would also empty the seed's
* notifications, and the bell of every later screen would lose its number.
*/
const guide = (id: string): HelpGuide => {
const g = notificationsGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
/**
* The bell in the navbar. Found by its title: with unread notifications its
* accessible name is the number on its badge, not "Notifications".
*/
const bell = (page: Page): Locator => page.getByTitle('Notifications', { exact: true }).first();
/** The panel the bell opens, found through its footer button. */
const panel = (page: Page): Locator => page.getByRole('button', { name: 'Show all notifications' }).locator('xpath=..');
/** The column of the /notifications page: header, filter pills and the list. */
const pageColumn = (page: Page): Locator => page.locator('.max-w-2xl').first();
/** One notification (InAppNotificationItem's root) by its title, in the panel or the page. */
const item = (scope: Locator, title: string): Locator =>
scope.locator('div.relative.px-4.py-3').filter({ hasText: title }).first();
/** The user menu button at the top right: avatar, name and chevron. */
const userMenu = (page: Page): Locator =>
page
.getByRole('button')
.filter({ has: page.locator('span.max-w-40') })
.first();
/** The Settings sidebar, found by its first tab. */
const settingsSidebar = (page: Page): Locator =>
page
.locator('nav')
.filter({ has: page.getByRole('button', { name: 'General' }) })
.first();
async function openDashboard(page: Page): Promise<void> {
await page.goto('/dashboard');
await clearNotices(page);
await dismissReleaseNotice(page);
await expect(bell(page)).toBeVisible();
await settle(page);
}
async function openNotifications(page: Page): Promise<void> {
await page.goto('/notifications');
await clearNotices(page);
await dismissReleaseNotice(page);
await expect(item(pageColumn(page), SHARE_TITLE)).toBeVisible();
await settle(page);
}
async function openBell(page: Page): Promise<void> {
await bell(page).click();
await expect(item(panel(page), SHARE_TITLE)).toBeVisible();
await settle(page);
}
/** The unread ones the mark-read guide found, to give back in its cleanup. */
let unreadBefore: number[] = [];
const SCRIPTS: Record<string, GuideScript> = {
'read-notifications': {
guide: guide('read-notifications'),
start: openDashboard,
steps: [
{ target: bell, act: openBell },
{
target: panel,
// Over the newest one, so its unread tint is not covered by the pointer.
hover: async (p) => {
await item(panel(p), SHARE_TITLE).hover();
},
},
{
target: (p) => item(panel(p), SHARE_TITLE).getByRole('button', { name: 'View Vacay' }),
act: async (p) => {
await item(panel(p), SHARE_TITLE).getByRole('button', { name: 'View Vacay' }).click();
await expect(p).toHaveURL(/\/vacay/);
await settle(p);
},
},
],
// Following the share marked it read; the next guide wants it unread.
cleanup: async (p) => {
await ensureNotificationFixtures(p.request);
},
},
'filter-notifications': {
guide: guide('filter-notifications'),
start: openDashboard,
steps: [
{ target: bell, act: openBell },
{
target: (p) => p.getByRole('button', { name: 'Show all notifications' }),
act: async (p) => {
await p.getByRole('button', { name: 'Show all notifications' }).click();
await expect(p).toHaveURL(/\/notifications/);
await expect(item(pageColumn(p), SHARE_TITLE)).toBeVisible();
await settle(p);
},
},
{
target: (p) => pageColumn(p).getByRole('button', { name: 'Unread', exact: true }),
act: async (p) => {
await pageColumn(p).getByRole('button', { name: 'Unread', exact: true }).click();
await settle(p);
},
},
],
},
'mark-notifications-read': {
guide: guide('mark-notifications-read'),
start: async (p) => {
unreadBefore = await unreadIds(p.request);
await openNotifications(p);
},
steps: [
{
target: (p) => item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read'),
act: async (p) => {
await item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read').click();
await expect(item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read')).toHaveCount(0);
await settle(p);
},
},
{
target: (p) => pageColumn(p).getByRole('button', { name: 'Mark all read' }),
act: async (p) => {
await pageColumn(p).getByRole('button', { name: 'Mark all read' }).click();
await expect(pageColumn(p).getByRole('button', { name: 'Mark all read' })).toHaveCount(0);
await settle(p);
},
},
],
cleanup: async (p) => {
await markUnread(p.request, unreadBefore);
await ensureNotificationFixtures(p.request);
},
},
'delete-notifications': {
guide: guide('delete-notifications'),
start: openNotifications,
steps: [
{
target: (p) => item(pageColumn(p), BOOKING_TITLE).getByTitle('Delete', { exact: true }),
act: async (p) => {
await item(pageColumn(p), BOOKING_TITLE).getByTitle('Delete', { exact: true }).click();
await expect(pageColumn(p).locator('div.relative.px-4.py-3').filter({ hasText: BOOKING_TITLE })).toHaveCount(
0
);
await settle(p);
},
},
// Ringed only, see the file's comment.
{ target: (p) => pageColumn(p).getByRole('button', { name: 'Delete all' }) },
],
cleanup: async (p) => {
await ensureNotificationFixtures(p.request);
},
},
'notification-settings': {
guide: guide('notification-settings'),
start: openNotifications,
steps: [
{
target: userMenu,
act: async (p) => {
await userMenu(p).click();
await expect(p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' })).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' }),
act: async (p) => {
await p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' }).click();
await expect(settingsSidebar(p)).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => settingsSidebar(p).getByRole('button', { name: 'Notifications', exact: true }),
act: async (p) => {
await settingsSidebar(p).getByRole('button', { name: 'Notifications', exact: true }).click();
await settle(p);
},
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureNotificationFixtures(request);
});
test.afterAll(async ({ request }) => {
await removeNotificationFixtures(request);
});
test('every registered notifications guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(notificationsContext.guides.slice().sort());
});
test('hero: notifications', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, notificationsContext.id, openNotifications);
});
for (const id of notificationsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+29
View File
@@ -43,6 +43,30 @@ async function picture(src, dest, width) {
files++
}
// A step's loop: its frames, at the width of a step picture, as one animated
// WebP that loops forever. The first frame rests a little and the last one
// long enough to read the result before it starts again.
const FRAME_MS = 80
const FIRST_MS = 600
const LAST_MS = 1600
async function animation(srcDir, dest) {
const frames = readdirSync(srcDir).filter(f => f.endsWith('.png')).sort()
if (frames.length < 2) return
for (const f of frames) before += statSync(path.join(srcDir, f)).size
if (dry) {
console.log(` ${path.relative(SRC, srcDir)} → ${path.relative(DEST, dest)} (${frames.length} frames)`)
return
}
const buffers = await Promise.all(
frames.map(f => sharp(path.join(srcDir, f)).resize({ width: STEP_WIDTH, withoutEnlargement: true }).png().toBuffer()),
)
const delay = frames.map((_, i) => (i === 0 ? FIRST_MS : i === frames.length - 1 ? LAST_MS : FRAME_MS))
await sharp(buffers, { join: { animated: true } }).webp({ quality: 78, effort: 5, loop: 0, delay }).toFile(dest)
after += statSync(dest).size
files++
}
mkdirSync(DEST, { recursive: true })
for (const dir of readdirSync(SRC)) {
@@ -51,6 +75,11 @@ for (const dir of readdirSync(SRC)) {
const to = path.join(DEST, dir)
mkdirSync(to, { recursive: true })
for (const file of readdirSync(from)) {
const loop = /^step-(\d+)-loop$/.exec(file)
if (loop) {
await animation(path.join(from, file), path.join(to, `step-${loop[1]}.loop.webp`))
continue
}
if (path.extname(file) !== '.png') continue
const width = file.startsWith('step-') ? STEP_WIDTH : FRAME_WIDTH
await picture(path.join(from, file), path.join(to, file.replace(/\.png$/, '.webp')), width)
+296 -77
View File
@@ -2,15 +2,30 @@ import { test, expect, type Page, type Locator } from '@playwright/test'
import { createHmac } from 'node:crypto'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureAirtrailFlights, ensureDawarichRecording, disconnectAirtrail, disconnectDawarich, toggleAddon } from './external'
import {
mediaEnv, wandererUrl, wandererToken, allowWanderer, ensureWandererConnection, disconnectWanderer,
ensureActivePlugin, removeSideloadedPlugin, parkOfflineChanges, clearParkedChanges,
} from './fixtures-settings'
import { settingsContext, settingsTabContexts, settingsGuides } from '../../src/help/contexts/settings'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the settings screens, keyed by the ids in
* `src/help/contexts/settings.ts`. One route, one tab per screen; most guides
* only frame a section, and the few that change something (theme, an API key,
* two-factor authentication) leave the account in a state the run can live
* only frame a card or a row, and the few that change something (theme, an API
* key, two-factor authentication) leave the account in a state the run can live
* with, because settings is the last file of the run.
*
* The cards come from `components/Settings/settingsKit.tsx`: a <section> with an
* <h2> in its head band, rows inside (`SettingRow`, a div with `px-3.5`), boxes
* of rows (`SettingRows`, a div with `divide-y`). A stacked row puts its wide
* control in a sibling below the label, so a row is found from its label by its
* own div, never by the label's parent.
*
* Connect AirTrail, Dawarich and the two wanderer guides need those services
* (media.env, see README and fixtures-settings.ts) and fail at their start
* without them; every other guide runs on the seed.
*/
const TAB_OF: Record<string, string> = {
@@ -19,8 +34,10 @@ const TAB_OF: Record<string, string> = {
'settings-map': 'Map',
'settings-notifications': 'Notifications',
'settings-integrations': 'Integrations',
'settings-plugins': 'Plugins',
'settings-offline': 'Offline',
'settings-account': 'Account',
'settings-about': 'About',
}
const guide = (id: string): HelpGuide => {
@@ -31,14 +48,31 @@ const guide = (id: string): HelpGuide => {
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'General' }) }).first()
/** A settings card by the heading of its section. */
const section = (page: Page, title: string) =>
page.locator('h2').filter({ hasText: title }).first().locator("xpath=ancestor::section[1]")
/** A field of a section by its label. */
const field = (page: Page, sectionTitle: string, label: string) =>
section(page, sectionTitle).getByText(label, { exact: true }).first().locator('xpath=..')
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** A sub-section headed by an h3 inside a card (the passkeys block under Account). */
const section = (page: Page, title: string | RegExp) =>
page.locator('h2').filter({ hasText: title }).first().locator('xpath=ancestor::section[1]')
/** The label of a field or a row inside a card. */
const label = (page: Page, sectionTitle: string | RegExp, text: string) =>
section(page, sectionTitle).getByText(text, { exact: true }).first()
/** A field of a section by its label: the label's own column, as before. */
const field = (page: Page, sectionTitle: string | RegExp, text: string) => label(page, sectionTitle, text).locator('xpath=..')
/** A whole row (label, control and whatever the row holds below them) by its label. */
const row = (page: Page, sectionTitle: string | RegExp, text: string) =>
label(page, sectionTitle, text).locator('xpath=ancestor::div[contains(@class,"px-3.5")][1]')
/** The box of rows a row sits in. */
const rows = (page: Page, sectionTitle: string | RegExp, text: string) =>
label(page, sectionTitle, text).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')
/** The line of buttons a card ends with, found by its Save. */
const buttonRow = (page: Page, sectionTitle: string | RegExp) =>
section(page, sectionTitle).getByRole('button', { name: 'Save', exact: true }).locator('xpath=..')
/** The two fields side by side (address and key) of a connection card, by the address field's id. */
const fieldPair = (page: Page, inputId: string) => page.locator(`#${inputId}`).locator('xpath=../..')
const dialog = (page: Page) => page.getByRole('dialog').last()
/** A sub-section headed by an h2 or h3 inside a card. */
const subsection = (page: Page, title: string) => page.locator('h2, h3').filter({ hasText: title }).first().locator('xpath=ancestor::section[1]')
const badge = (page: Page, sectionTitle: string, text: string) => section(page, sectionTitle).getByText(text, { exact: true }).first()
/** The background check's switch, not the info dot beside its label, which carries the same name. */
const watchSwitch = (page: Page) =>
section(page, /^wanderer$/).locator('button[aria-pressed][aria-label="Check my trails for changes"]')
async function openSettings(page: Page, tab: string): Promise<void> {
await page.goto('/settings')
@@ -68,24 +102,20 @@ function totp(secretBase32: string): string {
const only = (target: (p: Page) => Locator) => ({ target })
/**
* Save, wait for the card to say Connected, then Test connection and wait for
* its answer: the order the connect guides tell the reader.
*/
async function saveAndTest(page: Page, card: string, connected: string, answer: (p: Page) => Locator): Promise<void> {
await section(page, card).getByRole('button', { name: 'Save', exact: true }).click()
await expect(badge(page, card, connected)).toBeVisible({ timeout: 30_000 })
await section(page, card).getByRole('button', { name: 'Test connection' }).click()
await expect(answer(page)).toBeVisible({ timeout: 30_000 })
await settle(page)
}
const SCRIPTS: Record<string, GuideScript> = {
// ── General ──────────────────────────────────────────────────────────────
'language-region': {
guide: guide('language-region'),
start: opener('settings-display'),
steps: [
only(p => field(p, 'Language & region', 'Language')),
only(p => section(p, 'Language & region')),
],
},
'travel-map-prefs': {
guide: guide('travel-map-prefs'),
start: opener('settings-display'),
steps: [
only(p => field(p, 'Travel & map', 'Always show booking routes').locator('xpath=..')),
only(p => field(p, 'Travel & map', 'Blur Booking Codes').locator('xpath=..')),
],
},
startup: {
guide: guide('startup'),
start: opener('settings-display'),
@@ -99,6 +129,25 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: async p => { await section(p, 'Startup').getByRole('button', { name: 'Dashboard', exact: true }).click(); await settle(p) },
},
'language-region': {
guide: guide('language-region'),
start: opener('settings-display'),
steps: [
// The chips sit below the label in a stacked row, so the row, not the label's column.
only(p => row(p, 'Language & region', 'Language')),
only(p => row(p, 'Language & region', 'Place names')),
only(p => section(p, 'Language & region')),
],
},
'travel-map-prefs': {
guide: guide('travel-map-prefs'),
start: opener('settings-display'),
steps: [
only(p => row(p, 'Travel & map', 'Open places in')),
only(p => row(p, 'Travel & map', 'Always show booking routes')),
only(p => row(p, 'Travel & map', 'Explore places on the map')),
],
},
// ── Appearance ───────────────────────────────────────────────────────────
'theme-scheme': {
@@ -106,20 +155,18 @@ const SCRIPTS: Record<string, GuideScript> = {
start: opener('settings-appearance'),
steps: [
{
target: p => section(p, 'Theme').getByRole('button', { name: 'Dark', exact: true }).locator('xpath=..'),
target: p => section(p, 'Theme').getByRole('group', { name: 'Color Mode' }),
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Light', exact: true }).click(); await beat(p, 400) },
},
{
target: p => section(p, 'Theme').getByText('Color scheme', { exact: true }).locator('xpath=..'),
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click(); await settle(p) },
target: p => row(p, 'Theme', 'Color scheme'),
// Custom brings up the accent row the next step is about.
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
},
{
prepare: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
target: p => section(p, 'Theme').getByText('Custom accent', { exact: true }).locator('xpath=..'),
act: async p => {
await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click()
await settle(p)
},
target: p => row(p, 'Theme', 'Custom accent'),
// One of the preset dots (named by its hex), so the result shows a picked accent.
act: async p => { await section(p, 'Theme').locator('button[aria-label^="#"]').nth(2).click(); await settle(p) },
},
],
cleanup: async p => {
@@ -131,15 +178,16 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('readability'),
start: opener('settings-appearance'),
steps: [
only(p => section(p, 'Readability').getByText('Transparency', { exact: true }).first().locator('xpath=../..')),
only(p => section(p, 'Readability').getByText('Text size', { exact: true }).first().locator('xpath=../..')),
only(p => rows(p, 'Readability', 'Transparency')),
only(p => row(p, 'Readability', 'Text size')),
],
},
'dashboard-widgets': {
guide: guide('dashboard-widgets'),
start: opener('settings-appearance'),
steps: [
only(p => section(p, 'Dashboard widgets')),
only(p => label(p, 'Dashboard widgets', 'Desktop').locator('xpath=ancestor::section[1]')),
only(p => label(p, 'Dashboard widgets', 'Mobile').locator('xpath=ancestor::section[1]')),
only(p => p.getByRole('button', { name: 'Reset to defaults' })),
],
},
@@ -149,10 +197,15 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('map-provider'),
start: opener('settings-map'),
steps: [
only(p => section(p, 'Map').getByText('Map Provider', { exact: true }).locator('xpath=..')),
only(p => section(p, 'Map').getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
only(p => section(p, 'Map').getByRole('button', { name: 'Save Map' }).locator('xpath=..')),
only(p => row(p, /^Map$/, 'Map Provider')),
only(p => section(p, /^Map$/).getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
{
// The Mapbox rows only exist with Mapbox GL picked; nothing is saved, the reload in cleanup drops it.
prepare: async p => { await section(p, /^Map$/).getByRole('button', { name: /Mapbox GL/ }).click(); await settle(p) },
target: p => rows(p, /^Map$/, '3D Buildings & Terrain'),
},
],
cleanup: async p => { await p.reload(); await settle(p) },
},
// ── Notifications ────────────────────────────────────────────────────────
@@ -160,9 +213,18 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('notification-channels'),
start: opener('settings-notifications'),
steps: [
only(p => section(p, 'Ntfy')),
only(p => section(p, 'Webhook URL')),
only(p => page_rows(p)),
only(p => section(p, 'Ntfy')),
// The per-event matrix is the first card, headed Notifications.
only(p => section(p, /^Notifications$/)),
],
},
'web-push-device': {
guide: guide('web-push-device'),
start: opener('settings-notifications'),
steps: [
only(p => section(p, 'Push notifications on this device')),
only(p => section(p, /^Notifications$/)),
],
},
@@ -172,29 +234,143 @@ const SCRIPTS: Record<string, GuideScript> = {
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'Immich')),
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=..')),
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]')),
],
},
'connect-airtrail': {
guide: guide('connect-airtrail'),
// The account holds the flights first, so Test connection has something to count.
start: async p => {
await ensureAirtrailFlights()
await disconnectAirtrail(p.request)
await toggleAddon(p.request, 'airtrail', true)
await openSettings(p, 'Integrations')
await expect(section(p, 'AirTrail')).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: p => fieldPair(p, 'airtrail-url'),
act: async p => {
await typeInto(p, p.locator('#airtrail-url'), mediaEnv('HELP_MEDIA_AIRTRAIL_URL'))
await typeInto(p, p.locator('#airtrail-key'), mediaEnv('HELP_MEDIA_AIRTRAIL_API_KEY'))
await settle(p)
},
},
only(p => rows(p, 'AirTrail', 'Allow self-signed certificates')),
{
target: p => buttonRow(p, 'AirTrail'),
act: p => saveAndTest(p, 'AirTrail', 'Connected', q => q.getByText(/^Connected\. \d+ flights? found$/).first()),
},
],
cleanup: p => disconnectAirtrail(p.request),
},
'connect-dawarich': {
guide: guide('connect-dawarich'),
// The recording first, so Test connection finds the stays of the last 30 days.
start: async p => {
await ensureDawarichRecording()
await disconnectDawarich(p.request)
await toggleAddon(p.request, 'dawarich', true)
await openSettings(p, 'Integrations')
await expect(section(p, 'Dawarich')).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: p => fieldPair(p, 'dawarich-url'),
act: async p => {
await typeInto(p, p.locator('#dawarich-url'), mediaEnv('HELP_MEDIA_DAWARICH_URL'))
await typeInto(p, p.locator('#dawarich-key'), mediaEnv('HELP_MEDIA_DAWARICH_API_KEY'))
await settle(p)
},
},
// Both switches stay off: an automatic check would seed suggestions into every later picture.
only(p => rows(p, 'Dawarich', 'Check for new stays automatically')),
{
target: p => buttonRow(p, 'Dawarich'),
act: p => saveAndTest(p, 'Dawarich', 'Connected', q => section(q, 'Dawarich').getByText(/^Connected\. \d+ stays? found in the last 30 days\.$/).first()),
},
],
cleanup: p => disconnectDawarich(p.request),
},
'tour-connect-wanderer': {
guide: guide('tour-connect-wanderer'),
start: async p => {
await allowWanderer(p.request)
await openSettings(p, 'Integrations')
await expect(section(p, /^wanderer$/)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: p => fieldPair(p, 'wanderer-url'),
act: async p => {
await typeInto(p, p.locator('#wanderer-url'), wandererUrl())
await typeInto(p, p.locator('#wanderer-token'), wandererToken())
await settle(p)
},
},
only(p => row(p, /^wanderer$/, 'Allow a self-signed certificate')),
{
target: p => buttonRow(p, /^wanderer$/),
act: async p => {
const card = section(p, /^wanderer$/)
await card.getByRole('button', { name: 'Save', exact: true }).click()
await expect(card.getByText('Connected', { exact: true }).first()).toBeVisible({ timeout: 30_000 })
await card.getByRole('button', { name: 'Test connection' }).click()
await expect(card.getByRole('status').getByText(/^Connected\./)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: p => disconnectWanderer(p.request),
},
'tour-wanderer-watch': {
guide: guide('tour-wanderer-watch'),
start: async p => {
await ensureWandererConnection(p.request)
await openSettings(p, 'Integrations')
await expect(section(p, /^wanderer$/)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
// Found by its switch: the label also holds the info dot, whose hidden text would spoil a text match.
target: p => watchSwitch(p).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]'),
act: async p => {
await watchSwitch(p).click()
await expect(p.getByTestId('wanderer-watch-status')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => p.getByTestId('wanderer-watch-status')),
],
cleanup: p => disconnectWanderer(p.request),
},
'api-keys': {
guide: guide('api-keys'),
start: opener('settings-integrations'),
steps: [
{
target: p => section(p, 'API Keys').getByRole('button', { name: 'Create key' }),
act: async p => {
prepare: async p => {
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
await expect(modal(p)).toBeVisible()
await typeInto(p, modal(p).getByPlaceholder('e.g. Dawarich'), 'Home Assistant')
await modal(p).getByRole('button', { name: 'Create', exact: true }).click()
await expect(modal(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
await expect(dialog(p).getByText('Create API key')).toBeVisible()
await typeInto(p, dialog(p).getByPlaceholder('e.g. Home dashboard'), 'Home Assistant')
// A key for a dashboard only needs the trips and their days.
for (const area of ['Bookings', 'Accommodation', 'Who is coming', 'Wishlist', 'Totals']) {
await dialog(p).getByRole('checkbox', { name: area }).click()
}
await settle(p)
},
target: p => dialog(p),
act: async p => {
await dialog(p).getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"], > div').first(),
target: p => dialog(p),
act: async p => {
await modal(p).getByRole('button', { name: 'Done' }).click()
await expect(modal(p)).toHaveCount(0)
await dialog(p).getByRole('button', { name: 'Done' }).click()
await expect(p.getByRole('dialog')).toHaveCount(0)
await settle(p)
},
},
@@ -204,15 +380,36 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('mcp-oauth'),
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'MCP Configuration').getByText('MCP Endpoint', { exact: true }).locator('xpath=..')),
only(p => field(p, 'MCP Configuration', 'MCP Endpoint')),
{
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'OAuth 2.1 Clients' }).click(); await settle(p) },
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'New Client' }).locator('xpath=../..'),
},
{
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'API Tokens' }).click(); await settle(p) },
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'Create New Token' }).locator('xpath=../..'),
prepare: async p => {
await section(p, 'MCP Configuration').getByRole('button', { name: 'OAuth 2.1 Clients' }).click()
await section(p, 'MCP Configuration').getByRole('button', { name: 'New Client' }).click()
await expect(dialog(p).getByText('Register OAuth Client')).toBeVisible()
await dialog(p).getByRole('button', { name: 'Claude Desktop', exact: true }).click()
await settle(p)
},
target: p => dialog(p),
// Registered for real, so the next step has a client row with its two buttons.
act: async p => {
await dialog(p).getByRole('button', { name: 'Register Client', exact: true }).click()
await expect(dialog(p).getByText('Client Registered')).toBeVisible({ timeout: 15_000 })
await dialog(p).getByRole('button', { name: 'Done', exact: true }).click()
await expect(p.getByRole('dialog')).toHaveCount(0)
await settle(p)
},
},
only(p => section(p, 'MCP Configuration').getByRole('button', { name: 'Rotate Secret' }).first().locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
],
},
// ── Plugins ──────────────────────────────────────────────────────────────
'plugin-settings': {
guide: guide('plugin-settings'),
start: opener('settings-plugins'),
steps: [
only(p => section(p, 'Plugin settings')),
only(p => section(p, 'Plugin activity')),
],
},
@@ -223,17 +420,25 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => section(p, 'What to store offline')),
only(p => p.getByRole('button', { name: 'Download for offline use' })),
only(p => section(p, 'Offline mode').getByRole('button', { name: 'Force offline mode' }).locator('xpath=..')),
only(p => row(p, 'Offline mode', 'Force offline mode')),
],
},
'offline-conflicts': {
guide: guide('offline-conflicts'),
start: opener('settings-offline'),
// The conflicts card only exists while a change waits; two are parked in this browser's queue first.
start: async p => {
await openSettings(p, 'General')
await parkOfflineChanges(p)
await sidebar(p).getByRole('button', { name: 'Offline', exact: true }).click()
await expect(section(p, 'Sync conflicts')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
// The conflicts section only exists while a conflict is open; the cache card shows their count.
only(p => section(p, 'Offline cache')),
only(p => p.getByRole('button', { name: 'Re-sync now' })),
only(p => section(p, 'Sync conflicts').getByRole('button', { name: 'Keep mine' }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
only(p => row(p, 'Sync conflicts', 'When a conflict happens')),
only(p => section(p, 'Offline cache').getByRole('button', { name: 'Try again' }).locator('xpath=../..')),
],
cleanup: async p => { await clearParkedChanges(p); await p.reload(); await settle(p) },
},
// ── Account ──────────────────────────────────────────────────────────────
@@ -241,7 +446,9 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('profile'),
start: opener('settings-account'),
steps: [
only(p => section(p, 'Account').getByText('Username', { exact: true }).first().locator('xpath=../..')),
// The identity strip: the picture with its camera, the name and the role.
only(p => section(p, 'Account').locator('div[class*="rounded-[14px]"]').first()),
only(p => fieldPair(p, 'account-username')),
only(p => section(p, 'Account').getByRole('button', { name: 'Save Profile' })),
],
},
@@ -249,9 +456,17 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('password'),
start: opener('settings-account'),
steps: [
only(p => section(p, 'Change Password').getByPlaceholder('Current password').locator('xpath=../..')),
{
prepare: async p => {
// A few characters, so the checklist shows rules met and rules still open.
await typeInto(p, p.locator('#account-new-password'), 'Kyoto2026')
await settle(p)
},
target: p => section(p, 'Change Password'),
},
only(p => section(p, 'Change Password').getByRole('button', { name: 'Update password' })),
],
cleanup: async p => { await p.reload(); await settle(p) },
},
mfa: {
guide: guide('mfa'),
@@ -277,7 +492,8 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes').first().locator('xpath=..'),
// The whole panel: the codes and the Copy codes, Download TXT and Print / PDF buttons under them.
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes', { exact: true }).first().locator('xpath=../..'),
},
],
},
@@ -285,7 +501,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('passkeys'),
start: opener('settings-account'),
steps: [
// Passkeys need a secure context; the run is plain http, so this guide has no pictures.
// A passkey needs a device prompt the run cannot answer, so this guide has no pictures.
{},
{},
],
@@ -299,25 +515,28 @@ const SCRIPTS: Record<string, GuideScript> = {
},
}
/** The per-event preference rows under the channels. */
function page_rows(p: Page): Locator {
// The per-event matrix is the whole Notifications card in the redesigned tab.
return section(p, 'Notifications')
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
const ALL_CONTEXTS = [settingsContext, ...settingsTabContexts]
// The photo-provider sections exist only for providers the admin switched on, and the
// ntfy and webhook forms only for channels the admin turned on.
/** Whether this file sideloaded a plugin for the Plugins tab and has to take it away again. */
let sideloadedPlugin = false
// The photo-provider sections exist only for providers the admin switched on, the ntfy,
// webhook and push cards only for channels the admin turned on, and the Plugins tab only
// while a plugin is active.
test.beforeAll(async ({ request }) => {
const res = await request.put('/api/admin/addons/immich', { data: { enabled: true } })
if (!res.ok()) throw new Error(`could not enable the Immich provider: ${res.status()} ${await res.text()}`)
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook' } })
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook,push' } })
if (!channels.ok()) throw new Error(`could not enable the notification channels: ${channels.status()} ${await channels.text()}`)
sideloadedPlugin = await ensureActivePlugin(request)
})
test.afterAll(async ({ request }) => {
if (sideloadedPlugin) await removeSideloadedPlugin(request)
})
test('every registered settings guide has a script, and only those', async () => {
+312
View File
@@ -0,0 +1,312 @@
import type { APIRequestContext } from '@playwright/test';
import { at, day } from '../dates';
import { setTripCover, type CoverPhoto } from '../screenshots/photos';
import { EXTRA_TRIPS, SPARE_TRIP, ensureExtraTrips } from './fixtures';
/**
* The trips around the seeded one, made to look lived in.
*
* "Autumn in Japan" is the trip every guide works on, but the dashboard, the
* journey and the collections pictures show the others too, and a grid of
* grey cards with no plan in them reads as an empty demo. So the other trips
* get a cover photo, a few real places on their days and the bookings a
* traveller makes months ahead: the flight and the hotel for Lisbon, the
* ferry and the car for Norway. Those are also what the dashboard's Upcoming
* reservations widget lists, which stayed empty while the only bookings were
* on a trip that ends today.
*
* Runs once after the seed. Everything is looked up by title or name first,
* so a second run changes nothing.
*/
interface ShowcasePlace {
name: string;
lat: number;
lng: number;
address: string;
description: string;
/** Index of the trip day, or none for an idea not planned yet. */
day?: number;
start?: string;
end?: string;
category: string;
}
interface ShowcaseBooking {
title: string;
type: string;
status: 'confirmed' | 'pending';
/** Days from the picture day and a local time, through `at`. */
starts: [number, string];
ends?: [number, string];
confirmation_number?: string;
location?: string;
metadata?: Record<string, string>;
endpoints?: Array<{
role: 'from' | 'to';
name: string;
code?: string;
lat: number;
lng: number;
timezone: string;
date: number;
time: string;
}>;
}
interface ShowcaseTrip {
title: string;
cover: CoverPhoto;
places: ShowcasePlace[];
bookings: ShowcaseBooking[];
}
const LISBON_START = 25;
const NORWAY_START = 257;
const SHOWCASE: ShowcaseTrip[] = [
{
title: EXTRA_TRIPS[0].title,
cover: 'lisbon',
places: [
{
name: 'Torre de Belém',
lat: 38.6916,
lng: -9.216,
address: 'Av. Brasília, Lisbon',
description: 'Sixteenth-century tower on the Tagus, the start of the voyages of discovery.',
day: 1,
start: '10:00',
end: '11:00',
category: 'Attraction',
},
{
name: 'Pastéis de Belém',
lat: 38.6975,
lng: -9.2032,
address: 'R. de Belém 84, Lisbon',
description: 'The original custard tarts since 1837, eaten warm with cinnamon.',
day: 1,
start: '11:15',
end: '11:45',
category: 'Bar/Cafe',
},
{
name: 'Miradouro da Senhora do Monte',
lat: 38.7193,
lng: -9.1327,
address: 'Largo Monte, Lisbon',
description: 'The highest viewpoint over the old town, best at sunset.',
day: 0,
start: '19:00',
end: '20:00',
category: 'Attraction',
},
{
name: 'Palácio da Pena',
lat: 38.7876,
lng: -9.3906,
address: 'Estrada da Pena, Sintra',
description: 'The painted palace on the hill above Sintra, a day trip by train.',
day: 2,
start: '10:30',
end: '13:00',
category: 'Attraction',
},
],
bookings: [
{
title: 'TP579 FRA → LIS',
type: 'flight',
status: 'confirmed',
starts: [LISBON_START, '09:40'],
ends: [LISBON_START, '11:35'],
confirmation_number: 'K4R7TW',
location: 'Frankfurt Airport',
metadata: {
airline: 'TAP Air Portugal',
flight_number: 'TP579',
departure_airport: 'FRA',
arrival_airport: 'LIS',
},
endpoints: [
{
role: 'from',
name: 'Frankfurt Airport',
code: 'FRA',
lat: 50.0379,
lng: 8.5622,
timezone: 'Europe/Berlin',
date: LISBON_START,
time: '09:40',
},
{
role: 'to',
name: 'Lisbon Humberto Delgado',
code: 'LIS',
lat: 38.7742,
lng: -9.1342,
timezone: 'Europe/Lisbon',
date: LISBON_START,
time: '11:35',
},
],
},
{
title: 'Memmo Alfama Hotel',
type: 'hotel',
status: 'confirmed',
starts: [LISBON_START, '15:00'],
ends: [LISBON_START + 2, '11:00'],
confirmation_number: 'MA-88213',
location: 'Travessa das Merceeiras 27, Lisbon',
},
{
title: 'Train to Sintra',
type: 'train',
status: 'pending',
starts: [LISBON_START + 2, '09:11'],
ends: [LISBON_START + 2, '09:52'],
location: 'Rossio Station',
},
],
},
{
title: EXTRA_TRIPS[1].title,
cover: 'norway',
places: [
{
name: 'Bryggen',
lat: 60.3976,
lng: 5.3247,
address: 'Bryggen, Bergen',
description: 'The Hanseatic wharf with its leaning wooden warehouses.',
day: 0,
start: '16:00',
end: '17:30',
category: 'Attraction',
},
{
name: 'Trollstigen',
lat: 62.4549,
lng: 7.6699,
address: 'Trollstigen, Rauma',
description: 'Eleven hairpins up the mountain road, with a viewing platform at the top.',
day: 4,
start: '11:00',
end: '12:00',
category: 'Nature',
},
{
name: 'Reine',
lat: 67.9324,
lng: 13.0886,
address: 'Reine, Moskenes',
description: 'Red fishing cabins under granite peaks in the Lofoten.',
category: 'Nature',
},
],
bookings: [
{
title: 'Rental car, Bergen airport',
type: 'car',
status: 'confirmed',
starts: [NORWAY_START, '13:00'],
ends: [NORWAY_START + 14, '10:00'],
confirmation_number: 'HZ-5521907',
location: 'Bergen Airport Flesland',
},
{
title: 'Ferry Bodø → Moskenes',
type: 'ferry',
status: 'pending',
starts: [NORWAY_START + 10, '15:00'],
ends: [NORWAY_START + 10, '18:30'],
location: 'Bodø ferry terminal',
},
],
},
{ title: SPARE_TRIP.title, cover: 'alps', places: [], bookings: [] },
];
type Trip = { id: number; title: string; cover_image?: string | null };
async function list<T>(api: APIRequestContext, url: string, key: string): Promise<T[]> {
const res = await api.get(url);
if (!res.ok()) throw new Error(`GET ${url}: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as Record<string, T[]> | T[];
return Array.isArray(body) ? body : (body[key] ?? []);
}
async function post(api: APIRequestContext, url: string, data: unknown): Promise<Record<string, unknown>> {
const res = await api.post(url, { data });
if (!res.ok()) throw new Error(`POST ${url}: ${res.status()} ${await res.text()}`);
return (await res.json()) as Record<string, unknown>;
}
async function fillTrip(
api: APIRequestContext,
trip: Trip,
spec: ShowcaseTrip,
categories: { id: number; name: string }[]
): Promise<void> {
if (!trip.cover_image) await setTripCover(api, trip.id, spec.cover);
const days = await list<{ id: number }>(api, `/api/trips/${trip.id}/days`, 'days');
const places = await list<{ name: string }>(api, `/api/trips/${trip.id}/places`, 'places');
for (const place of spec.places) {
if (places.some((p) => p.name === place.name)) continue;
const { day: dayIndex, start, end, category, ...payload } = place;
const categoryId = categories.find((c) => c.name === category)?.id;
const created = (await post(api, `/api/trips/${trip.id}/places`, {
...payload,
...(categoryId ? { category_id: categoryId } : {}),
})) as { place: { id: number } };
const dayId = dayIndex === undefined ? undefined : days[dayIndex]?.id;
if (!dayId) continue;
const assigned = (await post(api, `/api/trips/${trip.id}/days/${dayId}/assignments`, {
place_id: created.place.id,
})) as { assignment: { id: number } };
if (start) {
const res = await api.put(`/api/trips/${trip.id}/assignments/${assigned.assignment.id}/time`, {
data: { place_time: start, end_time: end ?? null },
});
if (!res.ok()) throw new Error(`time for ${place.name}: ${res.status()} ${await res.text()}`);
}
}
const bookings = await list<{ title: string }>(api, `/api/trips/${trip.id}/reservations`, 'reservations');
for (const booking of spec.bookings) {
if (bookings.some((b) => b.title === booking.title)) continue;
const { starts, ends, endpoints, ...rest } = booking;
await post(api, `/api/trips/${trip.id}/reservations`, {
...rest,
reservation_time: at(starts[0], starts[1]),
...(ends ? { reservation_end_time: at(ends[0], ends[1]) } : {}),
...(endpoints
? {
endpoints: endpoints.map(({ date, time, ...e }, sequence) => ({
...e,
sequence,
local_date: day(date),
local_time: time,
})),
}
: {}),
});
}
}
export async function ensureShowcase(api: APIRequestContext): Promise<void> {
await ensureExtraTrips(api);
let trips = await list<Trip>(api, '/api/trips', 'trips');
if (!trips.some((t) => t.title === SPARE_TRIP.title)) {
await post(api, '/api/trips', SPARE_TRIP);
trips = await list<Trip>(api, '/api/trips', 'trips');
}
const categories = await list<{ id: number; name: string }>(api, '/api/categories', 'categories');
for (const spec of SHOWCASE) {
const trip = trips.find((t) => t.title === spec.title);
if (trip) await fillTrip(api, trip, spec, categories);
}
}
File diff suppressed because it is too large Load Diff
+142 -6
View File
@@ -1,6 +1,6 @@
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 { day } 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'
@@ -54,6 +54,29 @@ const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwit
// ── Timeline and detail ───────────────────────────────────────────────────────
/** The sliders after the view icons: Group by and Sort by, or the timeline's lanes. */
const viewOptionsButton = (page: Page) => toolbar(page).getByRole('button', { name: 'View options', exact: true })
/** The view options panel is a menu like the filter's; only one is open at a time. */
const viewMenu = (page: Page) => page.getByRole('menu')
/** An item of the panel. Type stands under Group by and Sort by alike, so the first is Group by's. */
const viewItem = (page: Page, name: string) => viewMenu(page).getByRole('button', { name, exact: true }).first()
const exportButton = (page: Page) => toolbar(page).getByRole('button', { name: /^Export (as spreadsheet|the \d+ of \d+ shown rows) \(CSV\)$/ })
/** A section's heading in Cards: a button with the label and its count, folding the section. */
const sectionHead = (page: Page, label: string) =>
page.locator('button[aria-expanded]').filter({ hasText: new RegExp(String.raw`^${label}\s*\d+$`) })
async function openViewOptions(page: Page): Promise<void> {
if (!(await viewMenu(page).isVisible().catch(() => false))) await viewOptionsButton(page).click()
await expect(viewMenu(page)).toBeVisible()
await beat(page, 300)
}
/** The bar's buttons toggle their panel; a second click puts it away. */
async function closeViewOptions(page: Page): Promise<void> {
if (await viewMenu(page).isVisible().catch(() => false)) await viewOptionsButton(page).click()
await expect(viewMenu(page)).toHaveCount(0)
}
/** 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. */
@@ -128,12 +151,14 @@ async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: stri
/**
* Set a date without touching the calendar: every date field has a keyboard
* button beside it that swaps in a DD.MM.YYYY box, which is one fill instead of
* a month of grid cells that move with the trip's dates.
* button beside it that swaps in a text box, which is one fill instead of a
* month of grid cells that move with the trip's dates. The box's placeholder
* follows the locale (MM/DD/YYYY in en-US), but it takes an ISO date in every
* locale, so the value is `day(offset)` and the box is found by its name.
*/
async function setDate(page: Page, nth: number, value: string): Promise<void> {
await modal(page).getByRole('button', { name: 'Enter date manually' }).nth(nth).click()
const box = modal(page).getByPlaceholder('DD.MM.YYYY')
const box = modal(page).getByRole('textbox', { name: 'Enter date manually' })
await box.fill(value)
await box.press('Enter')
await expect(box).toHaveCount(0)
@@ -248,6 +273,53 @@ const SCRIPTS: Record<string, GuideScript> = {
await clearPanelState(p)
},
},
'group-sort-bookings': {
guide: guide('group-sort-bookings'),
start: openBookings,
steps: [
{
target: viewOptionsButton,
act: async p => {
await openViewOptions(p)
await expect(viewItem(p, 'No grouping')).toBeVisible()
await settle(p)
},
},
{
prepare: openViewOptions,
target: p => viewItem(p, 'Type'),
act: async p => {
await viewItem(p, 'Type').click()
await expect(sectionHead(p, 'Tour')).toBeVisible()
await settle(p)
},
},
{
prepare: openViewOptions,
target: p => viewItem(p, 'Title'),
act: async p => {
await viewItem(p, 'Title').click()
// The direction row under the four reads the order now in force.
await expect(viewItem(p, 'A to Z')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await closeViewOptions(p)
await settle(p)
},
target: p => sectionHead(p, 'Tour'),
act: async p => {
await sectionHead(p, 'Tour').click()
await expect(sectionHead(p, 'Tour')).toHaveAttribute('aria-expanded', 'false')
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'create-booking': {
guide: guide('create-booking'),
start: openBookings,
@@ -277,9 +349,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
prepare: async p => {
await setDate(p, 0, dotted(-2))
await setDate(p, 0, day(-2))
await timeBox(p).nth(0).fill('16:30')
await setDate(p, 1, dotted(-2))
await setDate(p, 1, day(-2))
await timeBox(p).nth(1).fill('20:00')
await settle(p)
},
@@ -403,6 +475,35 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => restore(p, 'teamLab Planets timed entry'),
},
'booking-on-map': {
guide: guide('booking-on-map'),
start: openBookings,
steps: [
{
target: p => bookingCard(p, VIEWED.title),
act: async p => {
// The title, not the middle of the card, which may be the address link.
await bookingCard(p, VIEWED.title).getByText(VIEWED.title, { exact: true }).click()
await expect(detail(p, VIEWED.title)).toBeVisible()
await settle(p)
},
},
{
target: p => detail(p, VIEWED.title).getByRole('button', { name: 'On map', exact: true }),
act: async p => {
await detail(p, VIEWED.title).getByRole('button', { name: 'On map', exact: true }).click()
await expect(detail(p, VIEWED.title)).toHaveCount(0)
// The tab goes and the plan opens on the tour's day with its place selected.
await expect(p.getByRole('heading', { name: 'Bookings', exact: true, level: 2 })).toHaveCount(0)
await expect(p.getByText('Fushimi Inari Taisha').first()).toBeVisible({ timeout: 20_000 })
// The map flies to the place and the tiles land after it.
await p.waitForTimeout(1200)
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'booking-travelers': {
guide: guide('booking-travelers'),
start: openBookings,
@@ -631,6 +732,41 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: clearPanelState,
},
'export-bookings-csv': {
guide: guide('export-bookings-csv'),
start: openBookings,
steps: [
{
prepare: async p => {
await typeInto(p, searchBox(p), 'Kyoto')
await expect(resultsChip(p)).toBeVisible()
await settle(p)
},
target: p => searchBox(p).locator('xpath=..'),
act: settle,
},
{
prepare: openViewOptions,
target: p => viewItem(p, 'Title'),
act: async p => {
await viewItem(p, 'Title').click()
await expect(viewItem(p, 'A to Z')).toBeVisible()
await closeViewOptions(p)
await settle(p)
},
},
{
target: exportButton,
act: async p => {
const download = p.waitForEvent('download')
await exportButton(p).click()
expect((await download).suggestedFilename()).toMatch(/-bookings\.csv$/)
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'import-booking-file': {
guide: guide('import-booking-file'),
// The extractor, and no addon: with it answering, the dialog sends mode
+189 -1
View File
@@ -4,6 +4,7 @@ import { ordinal, short } from '../dates'
import { SEED_CHAT } from '../screenshots/seed'
import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures'
import { openTrip } from './trip-shared'
import { MANAGE_NOTE, MANAGE_NOTE_EDIT, ensureManageNote, removeNotes, restoreNoteCategories } from './fixtures-trip-collab'
import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab'
import type { HelpGuide } from '../../src/help/types'
@@ -15,7 +16,10 @@ import type { HelpGuide } from '../../src/help/types'
* for the one guide that closes a poll for good, and the two upcoming stops
* What's Next needs. What a guide writes it takes away again in `cleanup`, with
* one deliberate exception: a deleted chat message cannot be undeleted, which is
* why `trip-chat` is the last guide of the screen.
* why `trip-chat` is the last guide of the screen. The note `manage-notes` works
* on is its own (`fixtures-trip-collab.ts`), written at its start and deleted by
* its last step; `note-categories` renames and recolours seeded categories and
* puts them back in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
@@ -105,6 +109,31 @@ const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (pa
* with the title typed into it, the body, and the footer with the buttons.
*/
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
/** The full view of a note (Expand): a dialog without a text box, holding the note's title. */
const noteView = (page: Page, title: string) =>
page.getByRole('dialog').filter({ hasText: title }).filter({ hasNot: page.getByPlaceholder('Write something...') })
const categoryDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('New category...') })
/**
* One row of Manage Categories, from the button that carries its name. Tooltip
* clones its child instead of wrapping it, so the button's parent is the row
* with the dot, the name, the six swatches and the bin.
*/
const categoryRow = (page: Page, name: string) =>
categoryDialog(page).getByRole('button', { name, exact: true }).locator('xpath=..')
/** The box a category's name turns into while it is renamed: the dialog's one field without a placeholder. */
const renameBox = (page: Page) => categoryDialog(page).locator('input:not([placeholder])')
/** The card's menu is a ContextMenu portal; its items are plain buttons. */
const noteMenu = (page: Page) => page.getByRole('menu')
const openNoteMenu = async (page: Page, title: string): Promise<void> => {
await noteCard(page, title).getByRole('button', { name: 'More options', exact: true }).click()
await expect(noteMenu(page)).toBeVisible()
await beat(page, 300)
}
/** The Delete note? question: ConfirmDialog has no dialog role, so its panel is found from its heading. */
const confirmPanel = (page: Page, title: string) =>
page.getByRole('heading', { name: title, exact: true }).locator('xpath=ancestor::div[@role="presentation"][1]')
/** The filter row under the Notes head band: All and one pill per category. */
const categoryPills = (page: Page) => notesPanel(page).getByRole('button', { name: 'All', exact: true }).locator('xpath=..')
const pollDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('What should we do?') })
const linkDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('#collab-link-url') })
/** A field of an editor with the eyebrow label over it: the box's parent. */
@@ -131,6 +160,13 @@ const start = async (page: Page): Promise<void> => {
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
/** A category added in Manage Categories and used by no note is kept in this browser only. */
const NEW_CATEGORY = 'Food'
/** The renamed category and the recoloured one; `cleanup` puts both back. */
const RENAME = { from: 'Ideas', to: 'Bad weather' }
const RECOLOUR = { category: 'Accommodation', swatch: 'Amber' }
const FILTER = 'Transport'
async function listOf<T>(page: Page, path: string, key: string): Promise<T[]> {
const res = await page.request.get(`${collab()}/${path}`)
const body = (await res.json()) as Record<string, T[] | undefined>
@@ -221,6 +257,158 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: page => deleteNote(page, NOTE.title),
},
'manage-notes': {
guide: guide('manage-notes'),
start: async page => {
await ensureManageNote(page.request)
await start(page)
await expect(noteCard(page, MANAGE_NOTE.title)).toBeVisible({ timeout: 15_000 })
},
steps: [
{
target: page => noteCard(page, MANAGE_NOTE.title).getByRole('button', { name: 'More options', exact: true }),
act: async page => {
await noteCard(page, MANAGE_NOTE.title).getByRole('button', { name: 'More options', exact: true }).click()
await expect(noteMenu(page)).toBeVisible()
},
},
{
// The picture is the note opened in full: its text, its Website and Edit at its foot.
prepare: async page => {
await noteMenu(page).getByRole('button', { name: 'Expand', exact: true }).click()
await expect(noteView(page, MANAGE_NOTE.title)).toBeVisible()
await settle(page)
},
target: page => noteView(page, MANAGE_NOTE.title),
act: async page => {
await noteView(page, MANAGE_NOTE.title).getByRole('button', { name: 'Close', exact: true }).click()
await expect(noteView(page, MANAGE_NOTE.title)).toHaveCount(0)
},
},
{
// The whole menu, like the other menus of the library: Pin is in it, between Expand and Edit.
prepare: page => openNoteMenu(page, MANAGE_NOTE.title),
target: noteMenu,
hover: async page => { await noteMenu(page).getByRole('button', { name: 'Pin', exact: true }).hover() },
act: async page => {
await noteMenu(page).getByRole('button', { name: 'Pin', exact: true }).click()
await expect(notesPanel(page).getByRole('article').first()).toHaveAttribute('aria-label', MANAGE_NOTE.title, { timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await openNoteMenu(page, MANAGE_NOTE.title)
await noteMenu(page).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(noteDialog(page)).toBeVisible()
await typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), `${MANAGE_NOTE.content}\n${MANAGE_NOTE_EDIT}`)
await beat(page, 300)
},
target: noteDialog,
hover: async page => { await noteDialog(page).getByRole('button', { name: 'Save', exact: true }).hover() },
act: async page => {
await noteDialog(page).getByRole('button', { name: 'Save', exact: true }).click()
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await openNoteMenu(page, MANAGE_NOTE.title)
await noteMenu(page).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(confirmPanel(page, 'Delete note?')).toBeVisible()
await beat(page, 300)
},
target: page => confirmPanel(page, 'Delete note?'),
hover: async page => { await confirmPanel(page, 'Delete note?').getByRole('button', { name: 'Delete', exact: true }).hover() },
act: async page => {
await confirmPanel(page, 'Delete note?').getByRole('button', { name: 'Delete', exact: true }).click()
await expect(noteCard(page, MANAGE_NOTE.title)).toHaveCount(0, { timeout: 15_000 })
await settle(page)
},
},
],
// The last step already deleted it; this is for a run that stopped short.
cleanup: page => removeNotes(page.request, MANAGE_NOTE.title),
},
'note-categories': {
guide: guide('note-categories'),
start,
steps: [
{
target: page => notesPanel(page).getByRole('button', { name: 'Manage Categories', exact: true }),
act: async page => {
await notesPanel(page).getByRole('button', { name: 'Manage Categories', exact: true }).click()
await expect(categoryDialog(page)).toBeVisible()
await settle(page)
},
},
{
prepare: page => typeInto(page, categoryDialog(page).getByPlaceholder('New category...'), NEW_CATEGORY),
// The box and the plus beside it.
target: page => categoryDialog(page).getByPlaceholder('New category...').locator('xpath=..'),
act: async page => {
await categoryDialog(page).getByRole('button', { name: 'New category...', exact: true }).click()
await expect(categoryRow(page, NEW_CATEGORY)).toBeVisible()
await settle(page)
},
},
{
// The name turns into a box on a click; the picture shows the new name typed into it.
prepare: async page => {
await categoryDialog(page).getByRole('button', { name: RENAME.from, exact: true }).click()
await expect(renameBox(page)).toBeFocused()
await typeInto(page, renameBox(page), RENAME.to)
await beat(page, 300)
},
target: page => renameBox(page).locator('xpath=..'),
// Hovering the row would be harmless, but a click elsewhere would blur the box and save early.
hover: async page => { await renameBox(page).hover() },
act: async page => {
await renameBox(page).press('Enter')
await expect(categoryRow(page, RENAME.to)).toBeVisible()
await settle(page)
},
},
{
// Another category than the renamed one: a rename and a new colour on the
// same category in one Save keep only the name (see the bug list).
prepare: async page => {
await categoryRow(page, RECOLOUR.category).getByRole('button', { name: RECOLOUR.swatch, exact: true }).click()
await expect(categoryRow(page, RECOLOUR.category).getByRole('button', { name: RECOLOUR.swatch, exact: true })).toHaveAttribute('aria-pressed', 'true')
await beat(page, 300)
},
target: page => categoryRow(page, RECOLOUR.category),
hover: async page => { await categoryRow(page, RECOLOUR.category).getByRole('button', { name: RECOLOUR.swatch, exact: true }).hover() },
},
{
target: page => categoryDialog(page).getByRole('button', { name: 'Save', exact: true }),
act: async page => {
await categoryDialog(page).getByRole('button', { name: 'Save', exact: true }).click()
await expect(categoryDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(categoryPills(page).getByRole('button', { name: RENAME.to, exact: true })).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
target: categoryPills,
hover: async page => { await categoryPills(page).getByRole('button', { name: FILTER, exact: true }).hover() },
act: async page => {
await categoryPills(page).getByRole('button', { name: FILTER, exact: true }).click()
await expect(categoryPills(page).getByRole('button', { name: FILTER, exact: true })).toHaveAttribute('aria-pressed', 'true')
await expect(notesPanel(page).getByRole('article')).toHaveCount(1)
await settle(page)
},
},
],
cleanup: async page => {
await restoreNoteCategories(page.request)
// The added category has no note, so it was only ever kept in this browser.
await page.evaluate(id => {
try { localStorage.removeItem(`collab-cats-${id}`) } catch { /* nothing was kept */ }
}, seededTrip().tripId)
},
},
'shared-links': {
guide: guide('shared-links'),
start,
+77 -10
View File
@@ -3,6 +3,7 @@ import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type Guide
import { day, long, short } from '../dates'
import { seededTrip, ensureCostsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { RAMEN, RECEIPT_FILE, receiptFixture, removeReceiptFiles } from './fixtures-trip-costs'
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
import type { HelpGuide } from '../../src/help/types'
@@ -83,15 +84,14 @@ const categoryList = (page: Page) => page.getByRole('group', { name: 'Category',
/** The bar's button; the editor's own save carries the same words, so it is scoped to the modal. */
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
/** Every Settle button of the card; each is named "Settle: <from> → <to>, <amount>". */
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: /^Settle: / })
/**
* One suggested transfer in the Settle up card, by the initial on its first
* avatar ("J" for jonas; the names are only in the tooltip). The card lists the
* flows in whatever order the settlement endpoint returns them, so taking the
* first Settle button would settle whichever transfer happened to come first.
* The Settle button of jonas's transfer. The card lists the flows in whatever
* order the settlement endpoint returns them, so taking the first Settle button
* would settle whichever transfer happened to come first.
*/
const settleRow = (page: Page, initial: string) =>
card(page, 'Settle up').locator(`xpath=.//div[span[1][normalize-space(.)="${initial}"]]/..`)
const settleJonas = (page: Page) => card(page, 'Settle up').getByRole('button', { name: /^Settle: jonas → / })
// ── The bar's filters ────────────────────────────────────────────────────────
const searchBox = (page: Page) => page.getByPlaceholder('Search expenses…')
@@ -342,7 +342,7 @@ const SCRIPTS: Record<string, GuideScript> = {
.locator('xpath=ancestor::div[2]')
.getByRole('checkbox', { name: /jonas/ })
.click()
await expect(amounts(p).first()).toHaveValue('2100.00')
await expect(amounts(p).first()).toHaveValue(/^2100(\.00)?$/)
await settle(p)
},
target: p => block(p, 'Split').getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
@@ -422,6 +422,72 @@ const SCRIPTS: Record<string, GuideScript> = {
cleanup: p => deleteExpenses(p, UPGRADE),
},
'expense-receipts': {
guide: guide('expense-receipts'),
start: async p => {
await createExpense(p, RAMEN)
await openCosts(p)
},
steps: [
{
target: p => rowActions(p, RAMEN.name).getByRole('button', { name: 'Edit' }),
act: async p => {
await rowActions(p, RAMEN.name).getByRole('button', { name: 'Edit' }).click()
await expect(block(p, 'Receipts & Invoices')).toBeVisible()
await settle(p)
},
},
{
// The picker is the system's own window, which no screenshot shows: the
// file goes straight into the hidden input behind Attach, and the
// picture is of the section with the file listed under it.
prepare: async p => {
const section = block(p, 'Receipts & Invoices')
await section.locator('input[type="file"]').setInputFiles(await receiptFixture())
await expect(section.getByText(RECEIPT_FILE)).toBeVisible()
await settle(p)
},
target: p => block(p, 'Receipts & Invoices'),
},
{
target: p => modal(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await dropFocus(p)
await expect(expRow(p, RAMEN.name).getByRole('button', { name: 'View receipt' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => expRow(p, RAMEN.name).getByRole('button', { name: 'View receipt' }),
act: async p => {
await expRow(p, RAMEN.name).getByRole('button', { name: 'View receipt' }).click()
const img = p.getByRole('img', { name: RECEIPT_FILE })
await expect(img).toBeVisible({ timeout: 20_000 })
await expect.poll(() => img.evaluate(el => (el as HTMLImageElement).naturalWidth), { timeout: 20_000 }).toBeGreaterThan(0)
await settle(p)
},
},
{
// The preview's white panel: the file's name, Open in new tab and
// Download in its head, the receipt under them.
target: p => p.getByRole('button', { name: 'Open in new tab' }).locator('xpath=ancestor::div[3]'),
act: async p => {
await p.keyboard.press('Escape')
await expect(p.getByRole('img', { name: RECEIPT_FILE })).toHaveCount(0)
await dropFocus(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await deleteExpenses(p, RAMEN.name)
await removeReceiptFiles(p.request)
},
},
'filter-costs': {
guide: guide('filter-costs'),
start: openCosts,
@@ -502,9 +568,9 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => card(p, 'Settle up')),
{
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
target: settleJonas,
act: async p => {
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
await settleJonas(p).click()
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
@@ -524,6 +590,7 @@ const SCRIPTS: Record<string, GuideScript> = {
await card(p, 'Settle up').getByRole('button', { name: 'Add payment' }).click()
await expect(p.getByRole('dialog', { name: 'Add payment' })).toBeVisible()
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
await typeInto(p, modal(p).getByPlaceholder('Paid in cash, by bank transfer…'), 'Cash, at the ryokan')
await settle(p)
},
target: p => p.getByRole('dialog', { name: 'Add payment' }),
+64 -5
View File
@@ -22,7 +22,10 @@ const guide = (id: string): HelpGuide => {
/** 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. */
/**
* The panel itself. PageSidebar's drawer is `fixed z-50` too, but it is
* `lg:hidden` and never on screen at the pictures' width.
*/
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} `) })
@@ -58,6 +61,13 @@ const bookings = (page: Page) => block(page, 'Reservations')
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 })
/**
* The stay's Reservation box: a button named by the booking's title and its
* status (the code in it is aria-hidden). The pencil and the X beside the
* name are named Edit accommodation and Remove, so the title picks the box.
*/
const stayBooking = (page: Page, name: string) =>
stayCard(page, name).getByRole('button', { name: new RegExp(`^${name}`) })
/**
* Select a day by its title rather than by the middle of its header: a day with
@@ -116,6 +126,17 @@ async function restoreTokyoStay(page: Page): Promise<void> {
})
}
/** The Hotel booking a stay made for itself; the accommodation route created it confirmed. */
async function setStayBookingStatus(page: Page, name: string, status: 'pending' | 'confirmed'): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: { id: number; type: string; title: string }[] }
const booking = (body.reservations ?? []).find(r => r.type === 'hotel' && r.title === name)
if (!booking) throw new Error(`the Hotel booking of "${name}" is missing`)
const put = await page.request.put(`/api/trips/${tripId}/reservations/${booking.id}`, { data: { status } })
if (!put.ok()) throw new Error(`could not set "${name}" to ${status}: ${put.status()} ${await put.text()}`)
}
/**
* 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.
@@ -281,10 +302,10 @@ const SCRIPTS: Record<string, GuideScript> = {
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)'),
// The pencil and the X are RoundActions named by their tooltips.
target: p => stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Edit accommodation', exact: true }),
act: async p => {
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
await stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Edit accommodation', exact: true }).click()
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
await settle(p)
},
@@ -311,11 +332,49 @@ const SCRIPTS: Record<string, GuideScript> = {
// 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)'),
target: p => stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Remove', exact: true }),
},
],
cleanup: restoreTokyoStay,
},
'confirm-stay-booking': {
guide: guide('confirm-stay-booking'),
start: async p => {
// The night was booked confirmed; the guide starts from a pending one.
await setStayBookingStatus(p, TOKYO_HOTEL, 'pending')
await openDay(p, 1)
await expect(stayBooking(p, TOKYO_HOTEL)).toContainText('Pending', { timeout: 15_000 })
},
steps: [
only(p => stayBooking(p, TOKYO_HOTEL)),
{
prepare: async p => {
await stayBooking(p, TOKYO_HOTEL).click()
await expect(bookingDetail(p, TOKYO_HOTEL)).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, TOKYO_HOTEL),
},
{
target: p => bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Set to Confirmed', exact: true }),
act: async p => {
await bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Set to Confirmed', exact: true }).click()
await expect(bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Set to Pending', exact: true })).toBeVisible()
await settle(p)
},
},
{
target: p => bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Close', exact: true }),
act: async p => {
await bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Close', exact: true }).click()
await expect(bookingDetail(p, TOKYO_HOTEL)).toHaveCount(0)
await expect(stayBooking(p, TOKYO_HOTEL)).toContainText('Confirmed')
await settle(p)
},
},
],
cleanup: p => setStayBookingStatus(p, TOKYO_HOTEL, 'confirmed'),
},
'day-bookings': {
guide: guide('day-bookings'),
start: p => openDay(p, 5),
+270 -100
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { captureGuide, captureHero, beat, typeInto, settle, dragLoop, 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'
@@ -21,8 +21,9 @@ const guide = (id: string): HelpGuide => {
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.' }
/** The day-fixtures train of day 5, the booking the move guide drags to day 4. */
const TRAIN = 'Nozomi 21 Tokyo → Kyoto'
/** 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
@@ -33,7 +34,10 @@ const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of t
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 })
/** A day card's head band (`.dp-day-header`, a div with the button role). */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The whole card of a day: the head band and the rows under it. */
const dayCard = (page: Page, n: number) => dayHeader(page, n).locator('xpath=..')
/**
* 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
@@ -44,13 +48,16 @@ const stop = (page: Page, name: string) =>
.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. */
/** A booking row of a day: its title, its time and its phase (Departure / Arrival) make its name. */
const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name })
/** A transport row by its title, anywhere in the plan or inside one card. */
const transportRow = (scope: Page | Locator, title: string) =>
scope.locator('[data-dp="transport-row"]').filter({ hasText: title }).first()
/** 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. */
/** The round + at the end of a places row (named "+ Day"): 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' })
@@ -60,14 +67,8 @@ const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button
* 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.
*/
/** The route bar of the open day: Route, the modes, Optimize and the two hand-offs. */
const routeBar = (page: Page) => page.locator('[data-dp="route-tools"]').first()
/**
* 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
@@ -81,6 +82,31 @@ const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBo
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' })
/** 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()
/** The menu a right-click on a day's head opens. */
const dayMenu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Clear day' }) }).last()
// ── Seed helpers ──────────────────────────────────────────────────────────────
type Assignment = { id: number; place_id: number; route_excluded?: boolean }
async function assignmentsOf(page: Page, dayIndex: number): Promise<Assignment[]> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: Assignment[] }
return body.assignments ?? []
}
async function placeIdOf(page: Page, name: string): Promise<number> {
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 ?? [])
const place = places.find(x => x.name === name)
if (!place) throw new Error(`the seeded place "${name}" is missing`)
return place.id
}
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
@@ -98,10 +124,58 @@ async function deleteByName(page: Page, ...names: string[]): Promise<void> {
}
}
/** teamLab Planets' slot on day 1 as the fixtures give it. */
const TEAMLAB_SLOT = { place_time: '13:00', end_time: '15:00' }
/** Give teamLab Planets on day 1 a time, or none. */
async function setTeamLabTime(page: Page, slot: { place_time: string; end_time: string } | null): Promise<void> {
const { tripId } = seededTrip()
const teamLab = await placeIdOf(page, 'teamLab Planets')
const stop = (await assignmentsOf(page, 0)).find(a => a.place_id === teamLab)
if (!stop) return
const res = await page.request.put(`/api/trips/${tripId}/assignments/${stop.id}/time`, {
data: slot ?? { place_time: null, end_time: null },
})
if (!res.ok()) throw new Error(`could not set the time of teamLab Planets: ${res.status()} ${await res.text()}`)
}
/** Day 1's stops as the seed orders them, read before the reorder guide moves them. */
let day1Order: number[] = []
/** Put teamLab Planets back on day 1 and day 1 back in its seeded order. */
async function restoreDay1(page: Page): Promise<void> {
const { tripId, dayIds } = seededTrip()
const teamLab = await placeIdOf(page, 'teamLab Planets')
if (!(await assignmentsOf(page, 0)).some(a => a.place_id === teamLab)) {
for (let i = 1; i < dayIds.length; i++) {
const moved = (await assignmentsOf(page, i)).find(a => a.place_id === teamLab)
if (!moved) continue
await page.request.put(`/api/trips/${tripId}/assignments/${moved.id}/move`, { data: { new_day_id: dayIds[0], order_index: 0 } })
break
}
}
const day1 = await assignmentsOf(page, 0)
const rank = (a: Assignment) => { const i = day1Order.indexOf(a.place_id); return i === -1 ? day1Order.length : i }
const orderedIds = [...day1].sort((a, b) => rank(a) - rank(b)).map(a => a.id)
await page.request.put(`/api/trips/${tripId}/days/${dayIds[0]}/assignments/reorder`, { data: { orderedIds } })
}
/** The train's days as the fixtures left them, read before the move guide drags it. */
let trainDays: { id: number; day_id: number | null; end_day_id: number | null } | null = null
async function readTrain(page: Page): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: { id: number; title: string; day_id: number | null; end_day_id: number | null }[] }
const train = (body.reservations ?? []).find(r => r.title === TRAIN)
if (!train) throw new Error(`the day fixtures' train "${TRAIN}" is missing`)
trainDays = { id: train.id, day_id: train.day_id, end_day_id: train.end_day_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.
* Open the trip on the routed day 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)
@@ -117,52 +191,36 @@ const closeModal = async (page: Page): Promise<void> => {
}
}
/** Right-click a stop and wait for its menu to offer `entry`. */
async function stopMenu(page: Page, name: string, entry: string): Promise<void> {
await stop(page, name).click({ button: 'right' })
await expect(menu(page).getByRole('button', { name: entry })).toBeVisible()
await beat(page, 300)
}
/** Whether stop `a` sits above stop `b` on screen. */
async function above(page: Page, a: string, b: string): Promise<boolean> {
const boxA = await stop(page, a).boundingBox()
const boxB = await stop(page, b).boundingBox()
return !!boxA && !!boxB && boxA.y < boxB.y
}
/** Hold the loop's last frame on the settled result once the drop has landed. */
async function holdResult(page: Page, frame: () => Promise<void>): Promise<void> {
await settle(page)
await frame()
await frame()
}
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)),
// The hover the capture does brings the row's grip, arrows, ticket and dots out.
only(p => stop(p, 'Arashiyama Bamboo Grove')),
only(p => stop(p, 'Nishiki Market')),
only(connector),
@@ -173,23 +231,23 @@ const SCRIPTS: Record<string, GuideScript> = {
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
// Day 1 stays open: the round + 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))
dropTo: p => dayHeader(p, 2),
clip: async (p, frame) => {
await dragLoop(p, placeRow(p, SPARE.name), dayHeader(p, 2), frame)
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
await holdResult(p, frame)
},
},
{
// 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.
// 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),
},
@@ -200,26 +258,47 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'reorder-stops': {
guide: guide('reorder-stops'),
start: p => openTrip(p),
start: async p => {
day1Order = (await assignmentsOf(p, 0)).map(a => a.place_id)
// A timed stop may not go above an earlier timed one without the Remove time?
// question, so teamLab Planets runs untimed here and gets its slot back after.
await setTeamLabTime(p, null)
await openTrip(p)
},
steps: [
{
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
// teamLab Planets is untimed, so it may go above the timed Senso-ji Temple
// without the Remove time? question; the loop shows it landing there.
target: p => stop(p, 'teamLab Planets').locator('.dp-grip'),
dropTo: p => stop(p, 'Senso-ji Temple'),
clip: async (p, frame) => {
await dragLoop(p, stop(p, 'teamLab Planets').locator('.dp-grip'), stop(p, 'Senso-ji Temple'), frame)
await expect.poll(() => above(p, 'teamLab Planets', 'Senso-ji Temple'), { timeout: 15_000 }).toBe(true)
await holdResult(p, frame)
},
},
{
// The pair of arrows, not one of them: the stop is first now, so Move up is
// greyed out, and the text names both.
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').locator('.reorder-buttons'),
hover: async p => { await stop(p, 'teamLab Planets').hover() },
},
{
target: p => stop(p, 'teamLab Planets'),
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
dropTo: p => dayHeader(p, 2),
clip: async (p, frame) => {
await dragLoop(p, stop(p, 'teamLab Planets'), dayHeader(p, 2), frame)
await expect(dayCard(p, 2).getByText('teamLab Planets').first()).toBeVisible({ timeout: 15_000 })
await holdResult(p, frame)
},
},
{
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 booking = bookingRow(p, /^LH716 FRA → HND .*departure/i)
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 } })
@@ -234,17 +313,17 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
cleanup: async p => {
await restoreDay1(p)
await setTeamLabTime(p, TEAMLAB_SLOT)
},
},
'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)
},
prepare: p => stopMenu(p, 'teamLab Planets', 'Edit'),
target: menu,
act: async p => {
await menu(p).getByRole('button', { name: 'Edit' }).click()
@@ -275,11 +354,10 @@ const SCRIPTS: Record<string, GuideScript> = {
},
],
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') {
const { tripId } = seededTrip()
const teamLab = await placeIdOf(p, 'teamLab Planets')
for (const a of await assignmentsOf(p, 0)) {
if (a.place_id === teamLab) {
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/time`, { data: { place_time: null, end_time: null } })
}
}
@@ -289,7 +367,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('remove-from-day'),
start: async p => {
await createSpare(p)
// The "+" puts the place on the OPEN day, so day 1 stays selected.
// 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()
@@ -298,11 +376,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
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)
},
prepare: p => stopMenu(p, SPARE.name, 'Remove from day'),
target: p => menu(p).getByRole('button', { name: 'Remove from day' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
@@ -314,6 +388,41 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByName(p, SPARE.name),
},
'leave-stop-off-route': {
guide: guide('leave-stop-off-route'),
start: p => openTrip(p),
steps: [
{
prepare: p => stopMenu(p, 'teamLab Planets', 'Leave out of route'),
target: p => menu(p).getByRole('button', { name: 'Leave out of route' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Leave out of route' }).click()
await expect(stop(p, 'teamLab Planets').getByText('Off route', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
// Hovered by the capture, so its tooltip (the route skips it) stands beside it.
only(p => stop(p, 'teamLab Planets').getByText('Off route', { exact: true })),
{
prepare: p => stopMenu(p, 'teamLab Planets', 'Add back to route'),
target: p => menu(p).getByRole('button', { name: 'Add back to route' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Add back to route' }).click()
await expect(stop(p, 'teamLab Planets').getByText('Off route', { exact: true })).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const teamLab = await placeIdOf(p, 'teamLab Planets')
for (const a of await assignmentsOf(p, 0)) {
if (a.place_id === teamLab && a.route_excluded) {
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/route`, { data: { excluded: false } })
}
}
},
},
'lock-stop': {
guide: guide('lock-stop'),
start: p => openTrip(p),
@@ -340,7 +449,7 @@ const SCRIPTS: Record<string, GuideScript> = {
start: p => openTrip(p),
steps: [
{
// The "+" of the day's head band opens the menu; Add Note is its last entry.
// 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()
@@ -372,17 +481,11 @@ const SCRIPTS: Record<string, GuideScript> = {
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,
},
// Hovered by the capture: the grip, the arrows and the three dots come out.
only(noteRow),
],
cleanup: async p => {
await closeMenu(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 }[]
@@ -404,6 +507,7 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
// Tooltip clones its child, so the Driving button's parent is the segmented control.
only(p => routeBar(p).getByRole('button', { name: 'Driving' }).locator('xpath=..')),
{
prepare: async p => {
@@ -414,10 +518,10 @@ const SCRIPTS: Record<string, GuideScript> = {
target: menu,
act: closeMenu,
},
only(p => p.getByRole('button', { name: 'Optimize' })),
only(p => routeBar(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' })),
only(p => routeBar(p).getByRole('button', { name: 'Open in Google Maps' })),
],
},
'manage-days': {
@@ -428,11 +532,11 @@ const SCRIPTS: Record<string, GuideScript> = {
{
prepare: async p => {
await p.getByRole('button', { name: 'Reorder days' }).click()
await expect(p.getByText('Reorder days').first()).toBeVisible()
await expect(reorderPopup(p)).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.
// The whole popup: the rows with their grips, arrows and bins, and the
// two ways of adding a day in its footer.
target: reorderPopup,
},
{
@@ -471,7 +575,7 @@ const SCRIPTS: Record<string, GuideScript> = {
hover: p => renameDay(p).hover(),
act: closeDayDetails,
},
only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })),
only(p => p.getByRole('button', { name: /^(Expand all days|Collapse all days)$/ })),
],
},
'bookings-in-plan': {
@@ -481,8 +585,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
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/)),
// gets when it crosses days, and the train starts and ends on one. The
// time pill sits between the title and the phase.
only(p => bookingRow(p, /^LH716 FRA → HND .*departure/i)),
{
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
target: p => stop(p, 'Nishiki Market'),
@@ -496,13 +601,45 @@ const SCRIPTS: Record<string, GuideScript> = {
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/ })),
only(p => p.getByRole('button', { name: /^(Show|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' }),
},
],
},
'move-booking-day': {
guide: guide('move-booking-day'),
start: async p => {
await readTrain(p)
await openTrip(p)
},
steps: [
{
// The grip shows on hover only; the capture hovers the row through it.
prepare: async p => { await transportRow(p, TRAIN).hover() },
target: p => transportRow(p, TRAIN).locator('.dp-grip'),
hover: async p => { await transportRow(p, TRAIN).hover() },
},
{
target: p => transportRow(p, TRAIN),
dropTo: p => dayHeader(p, 4),
clip: async (p, frame) => {
await dragLoop(p, transportRow(p, TRAIN), dayHeader(p, 4), frame)
await expect(transportRow(dayCard(p, 4), TRAIN)).toBeVisible({ timeout: 15_000 })
await holdResult(p, frame)
},
},
only(p => transportRow(dayCard(p, 4), TRAIN)),
],
cleanup: async p => {
if (!trainDays) return
const { tripId } = seededTrip()
await p.request.put(`/api/trips/${tripId}/reservations/${trainDays.id}`, {
data: { day_id: trainDays.day_id, end_day_id: trainDays.end_day_id },
})
},
},
'export-plan': {
guide: guide('export-plan'),
start: p => openTrip(p),
@@ -521,6 +658,39 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: closeModal,
},
'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)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
+81 -6
View File
@@ -3,6 +3,7 @@ import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type Guide
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
import { ensureNextcloudBinding } from './fixtures-trip-files'
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
import type { HelpGuide } from '../../src/help/types'
@@ -118,6 +119,17 @@ const bindingCard = (page: Page) => dialog(page).locator('article').first()
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
/** One lane of the flow bar, a toggle named by its caption. */
const lane = (page: Page, name: string) => bindingCard(page).getByRole('button', { name, exact: true })
/** The fold under the flow bar; only the owner or an administrator gets it. */
const settingsFold = (page: Page) => bindingCard(page).getByRole('button', { name: 'Settings', exact: true })
/** What the fold opens: the rows of settings on their tinted panel (`space-y-3`, DocSyncBinding.tsx). */
const settingsPanel = (page: Page) => bindingCard(page).locator('div.space-y-3').first()
/** One settings row, the label with its hint on the left and its control on the right. */
const settingsRow = (page: Page, label: string) =>
settingsPanel(page).locator('div.flex.items-center.justify-between').filter({ hasText: label }).first()
/** A CustomSelect's menu is a portal `group`, named after the option it shows. */
const selectMenu = (page: Page, current: string) => page.getByRole('group', { name: current })
interface ApiFile {
id: number
@@ -174,6 +186,15 @@ async function untrashFile(page: Page, name: string): Promise<void> {
const only = (target: (p: Page) => Locator) => ({ target })
/** Unbind the trip, take the pulled documents out and put the folder back to its two. */
async function resetStore(page: Page): Promise<void> {
const { tripId } = seededTrip()
await resetDocSync(page.request, tripId)
await removeFiles(page, NEXTCLOUD_DOCUMENTS)
// The run pushed the trip's own documents into the folder; back to its two.
await ensureNextcloudFolder(drawPdf)
}
const SCRIPTS: Record<string, GuideScript> = {
'files-upload': {
guide: guide('files-upload'),
@@ -570,13 +591,67 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
cleanup: async p => {
cleanup: resetStore,
},
'files-sync-settings': {
guide: guide('files-sync-settings'),
// Bound through the API with the dialog's own defaults, so the guide opens
// on the card; how the binding is made is files-sync's story.
start: async p => {
const { tripId } = seededTrip()
await resetDocSync(p.request, tripId)
await removeFiles(p, NEXTCLOUD_DOCUMENTS)
// The run pushed the trip's own documents into the folder; back to its two.
await ensureNextcloudFolder(drawPdf)
await ensureNextcloudBinding(p.request, tripId)
await openFiles(p)
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: syncButton,
act: async p => {
await syncButton(p).click()
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
// The only binding is selected by itself.
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
await expect(lane(p, 'Out to the store')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
{
target: p => lane(p, 'Out to the store'),
act: async p => {
await lane(p, 'Out to the store').click()
await expect(lane(p, 'Out to the store')).toHaveAttribute('aria-pressed', 'false', { timeout: 20_000 })
await expect(bindingCard(p).getByText(/^Only coming in\./)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: settingsFold,
act: async p => {
await settingsFold(p).click()
await expect(settingsFold(p)).toHaveAttribute('aria-expanded', 'true')
await expect(settingsRow(p, 'When a document is deleted')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// The menu open in the picture, so both choices the text names show.
prepare: async p => {
await settingsRow(p, 'When a document is deleted').getByRole('button').first().click()
await expect(selectMenu(p, 'Keep both copies').getByRole('button', { name: 'Move to recycle bin' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => settingsRow(p, 'When a document is deleted'),
act: async p => {
await selectMenu(p, 'Keep both copies').getByRole('button', { name: 'Move to recycle bin' }).click()
await expect(settingsRow(p, 'When a document is deleted')).toContainText('Move to recycle bin', { timeout: 20_000 })
await dropFocus(p)
await settle(p)
},
},
// The two rows under it; the step only reads them, nothing is changed.
only(settingsPanel),
],
cleanup: resetStore,
},
}
@@ -588,7 +663,7 @@ test.beforeAll(async ({ request }) => {
await ensureFilesFixtures(request)
// A store on offer puts Document sync beside the trash icon for the trip's
// owner, which is how bullet 6 of the screen text describes the bar; on for
// the whole file, so the hero and all seven guides show the same bar.
// the whole file, so the hero and every guide show the same bar.
await toggleAddon(request, 'nextcloud', true)
})
+227 -88
View File
@@ -1,18 +1,24 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { captureGuide, captureHero, beat, dragLoop, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { pickerLabel } from '../dates'
import { seededTrip, ensureListsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { restorePacking, snapshotPacking, untickPacking, type PackingSnapshotRow } from './fixtures-trip-lists'
import { tripListsContext, tripListsGuides } from '../../src/help/contexts/tripLists'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Lists tab of a trip, keyed by the ids in
* `src/help/contexts/tripLists.ts`. They run on the seeded "Autumn in Japan"
* with the fixtures' packing template and two extra tasks in place, so Apply
* template exists and the to-do sidebar has more than one filled filter; what
* a guide creates it takes away again in `cleanup`, so the next one starts the
* same.
* with the fixtures' packing template and extra tasks in place, so Apply
* template exists and the to-do sidebar has more than one filled filter.
*
* The seed's packing list is long and mostly ticked, so the packing guides do
* not undo their changes one by one: the list is snapshotted once before the
* first guide (`fixtures-trip-lists.ts`) and put back from that snapshot when
* each packing guide starts and again when it ends. What lives outside the
* items (a template, a bag, a list's assignees, a task) is removed by the
* guide that made it.
*/
const guide = (id: string): HelpGuide => {
@@ -21,10 +27,10 @@ const guide = (id: string): HelpGuide => {
return g
}
/** What the guides make and unmake. Nothing here is in the seed. */
const NEW_LIST = 'Toiletries'
const FIRST_ITEM = 'Toothbrush'
const RENAMED_ITEM = 'Toothbrush and paste'
/** What the guides make and unmake. None of these names is in the seed. */
const NEW_LIST = 'Snacks'
const FIRST_ITEM = 'Rice crackers'
const RENAMED_ITEM = 'Rice crackers for the train'
const MEMBER = 'mira'
const SHARED_ITEM = 'Camera'
const BAG_ITEM = 'Rain jacket'
@@ -34,36 +40,60 @@ const NEW_TASK = 'Pick up the pocket wifi'
const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.'
const TASK_LIST = 'On arrival'
const OTHER_TASK_LIST = 'Before departure'
const EDITED_TASK = 'Activate JR Pass'
/** An open, unassigned task of the seed; the edit guide puts a name on it. */
const EDITED_TASK = 'Send postcards home'
/** The only open task with a priority (the fixtures' P3), so Priority puts it on top. */
const PRIORITY_TASK = 'Confirm ryokan check-in time'
const ASSIGNEE = 'jonas'
/** The date picker labels a day cell with the whole date, in the run's English. */
const DUE_DAY = pickerLabel(4)
/** The fixtures' template, and the lists it brings with it. */
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag', 'SIM card', 'Plug adapter'] }
const IMPORT_LINES = 'Toiletries, Toothbrush\nToiletries, Sunscreen, 90\nHealth, Plasters, 30'
const IMPORTED = ['Toothbrush', 'Sunscreen', 'Plasters']
/** The seed's packing items, in the order it created them. */
const SEED_ORDER = ['Passport', 'JR Pass voucher', 'Travel insurance', 'Rain jacket', 'Walking shoes', 'Light layers', 'Type-A adapter', 'Power bank', 'Camera']
const DOCUMENTS = ['Passport', 'JR Pass voucher', 'Travel insurance']
/** The fixtures' template, and the list it brings with it. */
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit' }
/** Names the seed does not have, so the imported rows are the only ones that match. */
const IMPORT_LINES = 'Toiletries, Earplugs\nHealth, Motion sickness tablets, 20\nDay bag, Folding fan, 40'
const IMPORTED_LAST = 'Folding fan'
/** Still open in the seed, so a click ticks it rather than unticking it. */
const OPEN_ITEM = 'Slip-on shoes for temples'
/** A list with one open item, so Check All visibly finishes it. */
const CHECK_ALL_LIST = 'Electronics'
const CHECK_ALL_OPEN = 'eSIM activation code'
/** Fully packed in the seed, so the Open filter hides its list. */
const PACKED_ITEM = 'Passport'
/** What the ordering guide moves: the third list onto the first, then the last item of that first list to its top. */
const MOVED_LIST = 'Electronics'
const FIRST_LIST = 'Documents'
const MOVED_ITEM = 'Credit card and some yen'
const TOP_ITEM = 'Passport'
/** Packed in the seed; the counting guide unticks it and counts five pairs in. */
const PIECES_ITEM = 'Merino socks'
const PIECES = 5
/** The count in the label moves with what is ticked, so it is matched, not spelled. */
const CLEAR_CHECKED = /^Remove \d+ checked$/
// ── Locators ─────────────────────────────────────────────────────────────────
/** A category card: the list's name is a span in the card's header row. */
/** A category card: the list's name is a span in the card's head band. */
const cat = (page: Page, name: string): Locator =>
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
/**
* The header row of a card. The ⋯ has to be taken from here: every item row
* The head band of a card. The ⋯ has to be taken from here: every item row
* carries a ⋯ of its own, so the card as a whole holds several of them.
*/
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
const catMenu = (page: Page, name: string): Locator =>
catHeader(page, name).locator('button:has(svg.lucide-more-horizontal)')
catHeader(page, name).locator('button:has(svg.lucide-ellipsis, svg.lucide-more-horizontal)')
/** The grip in a list's head band, before its colour dot. */
const catGrip = (page: Page, name: string): Locator =>
catHeader(page, name).getByRole('button', { name: 'Drag to reorder the category' })
/** The open ⋯ menu is a fixed popover with no class of its own; its last entry names it. */
const catMenuCard = (page: Page): Locator => page.getByRole('button', { name: 'Delete List' }).locator('xpath=..')
const item = (page: Page, name: string): Locator =>
page.locator('.packing-item-row').filter({ hasText: name }).first()
/** A row's own grip is a bare draggable div, the first thing in the row. */
const rowGrip = (page: Page, name: string): Locator => item(page, name).locator('div[draggable="true"]')
/** The two-column grid of list cards; its children are the cards, in order. */
const listGrid = (page: Page): Locator =>
page.locator('div.grid').filter({ has: page.locator('.packing-item-row') }).first()
/** The only item of the list the first guide builds; its name changes mid-guide. */
const onlyRow = (page: Page): Locator => cat(page, NEW_LIST).locator('.packing-item-row').first()
/** An item's own ⋯, which holds Move to List, Sharing, Rename and Delete. */
@@ -71,6 +101,18 @@ const rowMenu = (page: Page, name: string): Locator => item(page, name).locator(
/** The open item menu; Sharing is a whole line in it. */
const rowMenuSharing = (page: Page): Locator =>
page.locator('.trek-menu-enter').getByRole('button', { name: /^Sharing/ })
/**
* The progress card's count and percentage. The total is a span of its own
* that reads "/28"; the category badges hold "n/m" in one span, so they never match.
*/
const progressCount = (page: Page): Locator =>
page.getByText(/^\/\d+$/).first().locator('xpath=ancestor::div[2]')
/** The quantity badge is the first field among a row's inline actions while it has no counter. */
const quantityField = (page: Page, name: string): Locator =>
item(page, name).locator('.packing-row-inline-actions input').first()
/** The packed counter of a multi-piece item, named "3 of 5 packed". */
const piecesCounter = (page: Page, name: string): Locator =>
item(page, name).getByRole('group', { name: / of \d+ packed$/ })
/** The name dialog that Add list and Save as template open. */
const nameDialog = (page: Page, confirm: string): Locator => page.getByRole('button', { name: confirm, exact: true })
/** The sharing dropdown is portalled to <body>, so the hint's parent is the card itself. */
@@ -83,7 +125,7 @@ const importCard = (page: Page): Locator =>
page.getByRole('dialog').filter({ has: page.getByText('Import Packing List', { exact: true }) })
/**
* The bag picker of a row and the member picker of a bag both hang off the
* body now, so they are found by their caption rather than inside the row.
* body, so they are found by their caption rather than inside the row.
*/
const bagPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Bags' }).last()
const bagMemberPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Assign members' }).last()
@@ -93,7 +135,6 @@ const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Da
const exportMenu = (page: Page): Locator => page.getByRole('menu')
/** The printable page inside the preview, a srcdoc frame of its own. */
const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]')
/** The Bags card beside the lists. Total weight is a span in its foot band. */
/** The bag sidebar: the innermost box holding the guide's bag and the weight summary below the cards. */
const bagSidebar = (page: Page): Locator =>
page.locator('div')
@@ -107,6 +148,12 @@ const task = (page: Page, name: string): Locator =>
page.getByRole('button', { name: new RegExp(`^${name}`) }).first()
const sidebarFilter = (page: Page, name: string): Locator =>
page.getByRole('button', { name: new RegExp(`^${name}`) })
/**
* A CustomSelect's options are portalled to <body> as a group named after what
* the field shows. Scoped to it, an option never collides with the sidebar's
* list row of the same name, which reads exactly the same once its count is zero.
*/
const selectList = (page: Page, showing: string): Locator => page.getByRole('group', { name: showing, exact: true }).last()
const only = (target: (p: Page) => Locator) => ({ target })
@@ -132,10 +179,25 @@ async function openTodos(page: Page): Promise<void> {
// ── Putting the seed back ────────────────────────────────────────────────────
/** The packing list as the seed and the fixtures left it, taken in beforeAll. */
let seedPacking: PackingSnapshotRow[] | null = null
function seed(): PackingSnapshotRow[] {
if (!seedPacking) throw new Error('the packing list was not snapshotted before the guides')
return seedPacking
}
const resetPacking = (page: Page): Promise<void> => restorePacking(page.request, seed())
/** Every packing guide starts from the seed's list, even after one that failed half way. */
async function startLists(page: Page): Promise<void> {
await resetPacking(page)
await openLists(page)
}
interface PackingRow {
id: number
name: string
category: string | null
}
async function packingItems(page: Page): Promise<PackingRow[]> {
@@ -145,31 +207,6 @@ async function packingItems(page: Page): Promise<PackingRow[]> {
return body.items ?? []
}
async function deleteItems(page: Page, match: (row: PackingRow) => boolean): Promise<void> {
const { tripId } = seededTrip()
for (const row of (await packingItems(page)).filter(match)) {
await page.request.delete(`/api/trips/${tripId}/packing/${row.id}`)
}
}
/**
* Re-creates the three Documents items the guide really deleted, then writes
* the seed's order back: without it the re-created rows sort last, the
* Documents card slides to the end of the two-column grid, and every later
* guide's picture changes.
*/
async function restoreDocuments(page: Page): Promise<void> {
const { tripId } = seededTrip()
const present = await packingItems(page)
for (const name of DOCUMENTS) {
if (present.some(row => row.name === name)) continue
await page.request.post(`/api/trips/${tripId}/packing`, { data: { name, category: 'Documents', visibility: 'common' } })
}
const after = await packingItems(page)
const orderedIds = SEED_ORDER.map(name => after.find(row => row.name === name)?.id).filter((id): id is number => id !== undefined)
await page.request.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds } })
}
async function deleteTemplate(page: Page, name: string): Promise<void> {
const res = await page.request.get('/api/admin/packing-templates')
const body = (await res.json()) as { templates?: { id: number; name: string }[] }
@@ -178,6 +215,16 @@ async function deleteTemplate(page: Page, name: string): Promise<void> {
}
}
async function deleteBag(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/packing/bags`)
const body = (await res.json()) as { bags?: { id: number; name: string }[] }
for (const bag of (body.bags ?? []).filter(x => x.name === name)) {
// bag_id is ON DELETE SET NULL, so the item is freed with it.
await page.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`)
}
}
async function deleteTodo(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/todo`)
@@ -200,7 +247,7 @@ async function unassignTodo(page: Page, name: string): Promise<void> {
const SCRIPTS: Record<string, GuideScript> = {
'packing-categories': {
guide: guide('packing-categories'),
start: openLists,
start: startLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Add list' }),
@@ -277,36 +324,78 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: async p => {
const { tripId } = seededTrip()
await deleteItems(p, row => row.category === NEW_LIST)
await resetPacking(p)
await p.request.put(`/api/trips/${tripId}/packing/category-assignees/${encodeURIComponent(NEW_LIST)}`, { data: { user_ids: [] } })
},
},
'arrange-packing-list': {
guide: guide('arrange-packing-list'),
start: startLists,
steps: [
{
// The still rings the grip and the card it goes onto; the loop shows the drag.
target: p => catGrip(p, MOVED_LIST),
dropTo: p => catHeader(p, FIRST_LIST),
clip: async (p, frame) => {
await dragLoop(p, catGrip(p, MOVED_LIST), catHeader(p, FIRST_LIST), frame)
await expect(listGrid(p).locator('> div').first()).toContainText(MOVED_LIST, { timeout: 20_000 })
await settle(p)
for (let i = 0; i < 3; i++) await frame()
},
},
{
prepare: async p => { await item(p, MOVED_ITEM).hover() },
target: p => rowGrip(p, MOVED_ITEM),
dropTo: p => item(p, TOP_ITEM),
clip: async (p, frame) => {
await dragLoop(p, rowGrip(p, MOVED_ITEM), item(p, TOP_ITEM), frame)
await expect(cat(p, FIRST_LIST).locator('.packing-item-row').first()).toContainText(MOVED_ITEM, { timeout: 20_000 })
await settle(p)
for (let i = 0; i < 3; i++) await frame()
},
},
{
target: p => p.getByRole('button', { name: 'A to Z' }),
act: async p => {
await p.getByRole('button', { name: 'A to Z' }).click()
await expect(p.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
],
cleanup: async p => {
// A to Z is remembered in this browser; the next guide opens a fresh one,
// but the switch goes back off here all the same.
await p.evaluate(() => { try { localStorage.removeItem('trek:packing-sort') } catch { /* nothing stored */ } })
await resetPacking(p)
},
},
'check-off-packing': {
guide: guide('check-off-packing'),
start: openLists,
start: startLists,
steps: [
{
// The grip is a div, so the tick box is the row's first button.
target: p => item(p, 'Passport').getByRole('button').first(),
target: p => item(p, OPEN_ITEM).getByRole('button').first(),
act: async p => {
await item(p, 'Passport').getByRole('button').first().click()
await expect(item(p, 'Passport').locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await item(p, OPEN_ITEM).getByRole('button').first().click()
await expect(item(p, OPEN_ITEM).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
// The count and the percentage, with the bar beside them: the whole card is
// wider than any frame and would lose both at its edges.
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
only(progressCount),
{
prepare: async p => {
await catMenu(p, 'Documents').click()
await catMenu(p, CHECK_ALL_LIST).click()
await expect(p.getByRole('button', { name: 'Check All', exact: true })).toBeVisible()
await beat(p, 300)
},
target: p => p.getByRole('button', { name: 'Check All', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Check All', exact: true }).click()
await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 })
await expect(item(p, CHECK_ALL_OPEN).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
@@ -314,28 +403,74 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => p.getByRole('button', { name: 'Open', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Open', exact: true }).click()
await expect(p.getByText('Documents', { exact: true })).toHaveCount(0, { timeout: 20_000 })
await expect(item(p, PACKED_ITEM)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: CLEAR_CHECKED }),
act: async p => {
// A native window.confirm, which Playwright dismisses unless something
// accepts it first. The browser's own dialog cannot be pictured, so
// the step shows the button and the state it leaves behind.
p.once('dialog', d => { void d.accept() })
// A native window.confirm, which cannot be pictured. The step shows the
// button and answers the browser with Cancel: with the Open filter on,
// the screen looks the same either way, and the seed's ticked items
// (most of the list) stay where they are for the guides after this one.
p.once('dialog', d => { void d.dismiss() })
await p.getByRole('button', { name: CLEAR_CHECKED }).click()
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 })
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toBeVisible()
await settle(p)
},
},
],
cleanup: restoreDocuments,
cleanup: resetPacking,
},
'count-packed-pieces': {
guide: guide('count-packed-pieces'),
start: async p => {
await resetPacking(p)
await untickPacking(p.request, PIECES_ITEM)
await openLists(p)
},
steps: [
{
prepare: async p => { await item(p, PIECES_ITEM).hover() },
target: p => quantityField(p, PIECES_ITEM),
act: async p => {
const field = quantityField(p, PIECES_ITEM)
await typeInto(p, field, String(PIECES))
await field.press('Enter')
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`0 of ${PIECES} packed`, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => piecesCounter(p, PIECES_ITEM),
act: async p => {
for (let n = 1; n <= 3; n++) {
await item(p, PIECES_ITEM).getByRole('button', { name: 'Pack one more' }).click()
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`${n} of ${PIECES} packed`, { timeout: 20_000 })
}
await settle(p)
},
},
{
// Partly packed: the counter in the accent, the tick box outlined in it.
target: p => item(p, PIECES_ITEM),
act: async p => {
for (let n = 4; n <= PIECES; n++) {
await item(p, PIECES_ITEM).getByRole('button', { name: 'Pack one more' }).click()
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`${n} of ${PIECES} packed`, { timeout: 20_000 })
}
await expect(item(p, PIECES_ITEM).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
only(p => item(p, PIECES_ITEM)),
],
cleanup: resetPacking,
},
'apply-packing-template': {
guide: guide('apply-packing-template'),
start: openLists,
start: startLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Apply template' }),
@@ -367,13 +502,13 @@ const SCRIPTS: Record<string, GuideScript> = {
},
],
cleanup: async p => {
await deleteItems(p, row => TEMPLATE.items.includes(row.name))
await resetPacking(p)
await deleteTemplate(p, SAVED_TEMPLATE)
},
},
'import-packing-list': {
guide: guide('import-packing-list'),
start: openLists,
start: startLists,
steps: [
{
// An icon at the right end of the bar, named Import by its label and tooltip.
@@ -407,16 +542,17 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => {
await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click()
await expect(importCard(p)).toHaveCount(0, { timeout: 30_000 })
await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
await expect(item(p, IMPORTED_LAST)).toBeVisible({ timeout: 30_000 })
await item(p, IMPORTED_LAST).scrollIntoViewIfNeeded()
await settle(p)
},
},
],
cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)),
cleanup: resetPacking,
},
'export-packing-list': {
guide: guide('export-packing-list'),
start: openLists,
start: startLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Export', exact: true }),
@@ -449,7 +585,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'share-packing-item': {
guide: guide('share-packing-item'),
start: openLists,
start: startLists,
steps: [
{
target: p => p.getByRole('button', { name: /^My list/ }),
@@ -518,18 +654,23 @@ const SCRIPTS: Record<string, GuideScript> = {
if (row) {
await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } })
}
await resetPacking(p)
},
},
'packing-bags': {
guide: guide('packing-bags'),
start: openLists,
start: async p => {
// A run that died half way leaves its bag behind, and the Bags card with it.
await deleteBag(p, BAG)
await startLists(p)
},
steps: [
{
// The weight field's placeholder really is a dash: it is the app's own
// string, not prose, so it is matched exactly as rendered.
target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'),
target: p => item(p, BAG_ITEM).getByPlaceholder('—'),
act: async p => {
const field = item(p, BAG_ITEM).getByPlaceholder('\u2014')
const field = item(p, BAG_ITEM).getByPlaceholder('—')
await typeInto(p, field, '620')
await expect(field).toHaveValue('620', { timeout: 20_000 })
await settle(p)
@@ -557,6 +698,8 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
// The jacket is ticked in the seed, so Packed under Total weight already
// carries its 620 g.
only(bagSidebar),
{
target: p => bagSidebar(p).getByRole('button', { name: 'Set limit' }),
@@ -581,20 +724,15 @@ const SCRIPTS: Record<string, GuideScript> = {
// The picker closes on a click anywhere beside it.
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
// A bag with someone on it fills Per person at the foot of the card.
await expect(p.getByText('Per person', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/packing/bags`)
const body = (await res.json()) as { bags?: { id: number; name: string }[] }
for (const bag of (body.bags ?? []).filter(x => x.name === BAG)) {
// bag_id is ON DELETE SET NULL, so the item is freed with it.
await p.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`)
}
const row = (await packingItems(p)).find(x => x.name === BAG_ITEM)
if (row) await p.request.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { weight_grams: null } })
await deleteBag(p, BAG)
await resetPacking(p)
},
},
'create-todo': {
@@ -625,7 +763,7 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => {
await modal(p).getByRole('button', { name: 'No list' }).click()
// The select's list is portalled to <body>, outside the dialog.
await p.getByRole('button', { name: TASK_LIST, exact: true }).click()
await selectList(p, 'No list').getByRole('button', { name: TASK_LIST, exact: true }).click()
await expect(modal(p).getByRole('button', { name: TASK_LIST })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
@@ -657,7 +795,7 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => {
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
@@ -702,7 +840,7 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => p.getByRole('button', { name: 'Priority', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Priority', exact: true }).click()
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(EDITED_TASK, { timeout: 20_000 })
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(PRIORITY_TASK, { timeout: 20_000 })
await settle(p)
},
},
@@ -717,7 +855,7 @@ const SCRIPTS: Record<string, GuideScript> = {
{
prepare: async p => {
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
await settle(p)
},
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
@@ -739,6 +877,7 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureListsFixtures(request)
seedPacking = await snapshotPacking(request)
})
test('every registered lists guide has a script, and only those', async () => {
@@ -747,7 +886,7 @@ test('every registered lists guide has a script, and only those', async () => {
test('hero: trip-lists', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripListsContext.id, openLists)
await captureHero(page, tripListsContext.id, startLists)
})
for (const id of tripListsContext.guides) {
+178 -1
View File
@@ -3,6 +3,8 @@ import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript
import { seededTrip, ensureMapFixtures } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
import { ensureDawarichConnection, disconnectDawarich } from './external'
import { ensureToursFixtures } from './tour-fixtures'
import { ensureTourOnDay, removeTourFromDay, tourNames, type TourOnDay } from './fixtures-trip-map'
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
import type { HelpGuide } from '../../src/help/types'
@@ -10,7 +12,8 @@ import type { HelpGuide } from '../../src/help/types'
* Actions for the map in the middle of a trip's plan, keyed by the ids in
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
* toggle has a second route to draw inside the frame; what a guide changes it
* toggle has a second route to draw inside the frame, and with the two Kyoto
* tours of the Tours fixtures, one of them on a day; what a guide changes it
* puts back in `cleanup`, so the next one starts the same.
*/
@@ -91,6 +94,65 @@ const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
*/
const bookingDetail = (page: Page) => dialog(page)
const bookingDetailClose = (page: Page) => bookingDetail(page).getByRole('button', { name: 'Close', exact: true })
/**
* The part of a tour's line that takes the click: TrackLines lays an invisible
* 14 px line over every track, and nothing else on the map is drawn at that
* width with no opacity, so this is the set of the trip's tours on the map.
*/
const tourLines = (page: Page) => page.locator('#trek-map path[stroke-width="14"][stroke-opacity="0"]')
/**
* The tour's card over the foot of the map. DetailShell carries no test id, so
* it is found by its head band: a `header` holding the Close and a tour's name.
*/
const tourCard = (page: Page) =>
page.locator('header')
.filter({ has: page.getByRole('button', { name: 'Close', exact: true }) })
.filter({ hasText: new RegExp(tourNames().map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')) })
.locator('xpath=..')
/** The right column's switch between the places list and the tours list. */
const toursTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Tours' })
const placesTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Places' })
const tourRow = (page: Page, name: string) =>
page.getByRole('listbox', { name: 'Tours' }).getByRole('option').filter({ hasText: name }).first()
const hideOnMap = (page: Page, name: string) => tourRow(page, name).getByRole('button', { name: 'Hide on the map' })
/**
* A point on a tour's line the pointer can take hold of: in the open part of
* the map, and with the line's own hit path on top there, so a click lands on
* the tour rather than on a pin or a column. Walks each line at twenty points
* and maps them out of the SVG's space into the page's. Zooms out a notch and
* tries again where the frame holds none, at most three times.
*/
async function tourLinePoint(page: Page): Promise<{ index: number; x: number; y: number }> {
for (let attempt = 0; attempt < 4; attempt++) {
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box')
const bounds = { left: box.x + 430, right: box.x + box.width - 430, top: box.y + 140, bottom: box.y + box.height - 220 }
const hit = await tourLines(page).evaluateAll((paths, b) => {
for (let i = 0; i < paths.length; i++) {
const path = paths[i] as SVGPathElement
const ctm = path.getScreenCTM()
const length = path.getTotalLength()
if (!ctm || !length) continue
for (let k = 1; k < 20; k++) {
const p = path.getPointAtLength((length * k) / 20)
const x = ctm.a * p.x + ctm.c * p.y + ctm.e
const y = ctm.b * p.x + ctm.d * p.y + ctm.f
if (x < b.left || x > b.right || y < b.top || y > b.bottom) continue
if (document.elementFromPoint(x, y) === path) return { index: i, x, y }
}
}
return null
}, bounds)
if (hit) return hit
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.wheel(0, 100)
await page.waitForTimeout(600)
await settle(page)
}
throw new Error('no tour line in the open part of the map')
}
/** A row of the places column. */
const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first()
/**
@@ -303,6 +365,10 @@ async function deleteByName(page: Page, ...names: string[]): Promise<void> {
let bubble = 0
let pin = 0
let found = ''
/** The tour on a day (from the fixtures), and the one whose line the tour guide clicked. */
let tourOnDay: TourOnDay | null = null
let line = { index: 0, x: 0, y: 0 }
let clickedTour = ''
const NEW_PLACE = 'Lunch stop'
@@ -708,6 +774,80 @@ const SCRIPTS: Record<string, GuideScript> = {
await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId)
},
},
'tour-map-lines': {
guide: guide('tour-map-lines'),
// On the day that holds a tour: the map frames the day, its route runs to
// the tour and on from it, and both Kyoto tours lie inside the frame.
start: async p => {
if (!tourOnDay) throw new Error('no tour on a day: ensureTourOnDay did not run')
await openTripOnDay(p, tourOnDay.day)
await foldDayDetails(p)
await expect.poll(() => tourLines(p).count(), { timeout: 30_000 }).toBeGreaterThan(0)
await settle(p)
},
steps: [
{
prepare: async p => {
line = await tourLinePoint(p)
},
target: p => tourLines(p).nth(line.index),
// The line's box is mostly empty map; the pointer goes onto the line itself.
hover: p => p.mouse.move(line.x, line.y),
},
{
// The click is the preparation, the card it opens is the picture.
prepare: async p => {
await p.mouse.click(line.x, line.y)
await expect(tourCard(p)).toBeVisible({ timeout: 15_000 })
const text = await tourCard(p).innerText()
clickedTour = tourNames().find(n => text.includes(n)) ?? tourOnDay?.name ?? ''
await settle(p)
},
target: tourCard,
act: async p => {
await tourCard(p).getByRole('button', { name: 'Close', exact: true }).click()
await expect(tourCard(p)).toHaveCount(0)
await settle(p)
},
},
{
// The whole map: the day's route running to the tour and on from it,
// with the pointer parked on an empty spot so no card is in the way.
target: map,
hover: async p => {
const box = await map(p).boundingBox()
if (!box) throw new Error('the map has no box')
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
},
},
{
prepare: async p => {
await toursTab(p).click()
await expect(tourRow(p, clickedTour)).toBeVisible({ timeout: 20_000 })
// The eye only comes out while the pointer is on the row.
await tourRow(p, clickedTour).hover()
await expect(hideOnMap(p, clickedTour)).toBeVisible()
await settle(p)
},
target: p => hideOnMap(p, clickedTour),
act: async p => {
const before = await tourLines(p).count()
await hideOnMap(p, clickedTour).click()
await expect(tourRow(p, clickedTour).getByRole('button', { name: 'Show on the map' })).toBeVisible()
await expect.poll(() => tourLines(p).count(), { timeout: 10_000 }).toBeLessThan(before)
await settle(p)
},
},
],
// Hidden tours are kept in this browser, per user and trip, under
// `trek:hidden-tours:`; the Tours list is page state and goes with the page.
cleanup: async p => {
await p.evaluate(() => {
for (const key of Object.keys(localStorage)) if (key.startsWith('trek:hidden-tours:')) localStorage.removeItem(key)
})
if (await placesTab(p).isVisible().catch(() => false)) await placesTab(p).click()
},
},
'map-compass': {
guide: guide('map-compass'),
@@ -748,6 +888,38 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
target: compassPill,
// The loop: the map turning from north to the still's 35°, the arrow
// leaning with it, and a pointer dragging sideways as a right-button
// turn would. The camera is moved by the map's own API, a notch a
// frame, for the reason the still's preparation gives.
clip: async (p, frame) => {
const box = await glCanvas(p).boundingBox()
if (!box) throw new Error('the GL map has no box')
const x = box.x + EMPTY.x
const y = box.y + EMPTY.y
const turnTo = async (bearing: number) => {
await p.evaluate(b => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
if (!m) throw new Error('no GL map on the page')
m.easeTo({ bearing: b, pitch: 0, duration: 0 })
}, bearing)
await p.waitForFunction(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && !m.isMoving()
})
// Two frames, so the canvas has painted the new bearing before the shot.
await p.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))
}
await turnTo(0)
await p.mouse.move(x, y)
for (let i = 0; i < 3; i++) await frame()
for (let i = 1; i <= 14; i++) {
await p.mouse.move(x + (120 * i) / 14, y)
await turnTo((35 * i) / 14)
await frame()
}
for (let i = 0; i < 4; i++) await frame()
},
},
{
target: compassPill,
@@ -775,6 +947,9 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureMapFixtures(request)
// The Kyoto city walk and the Fushimi Inari hike, and one of them on a day.
await ensureToursFixtures(request)
tourOnDay = await ensureTourOnDay(request)
// The recorded route: the addon on, the signed-in user connected to the
// Dawarich from media.env, the synthetic recording of the trip uploaded.
await ensureDawarichConnection(request)
@@ -790,6 +965,8 @@ test.afterAll(async ({ request }) => {
// run after this one would find a GL canvas instead.
await disconnectDawarich(request)
await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } })
// Only what ensureTourOnDay added itself; a tour the Tours fixtures put on a day stays.
await removeTourFromDay(request, tourOnDay)
})
test('every registered map guide has a script, and only those', async () => {
+128 -5
View File
@@ -1,7 +1,8 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, settle, VIEWPORT, type GuideScript } from './guide'
import { captureGuide, captureHero, beat, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, coverFixture, ensureTrack, ensureDaysFixtures, ensurePlaceFixtures, PLACE_WEBSITE, RATED_PLACE } from './fixtures'
import { openTrip, selectDay, modal, dialog } from './trip-shared'
import { ensurePlacePhoto, removePlacePhoto, PHOTO_FILE, PHOTO_PLACE } from './fixtures-trip-place'
import { long } from '../dates'
import { tripPlaceContext, tripPlaceGuides } from '../../src/help/contexts/tripPlace'
import type { HelpGuide } from '../../src/help/types'
@@ -35,6 +36,13 @@ const LIST = 'Kyoto shortlist'
/** The list's row in the picker: its name, then the status chip once the place is in it. */
const LIST_ROW = new RegExp(`^${LIST}(?: (?:Idea|Planned|Visited))?$`)
const UPLOADED = 'cover-fixture.jpg'
/**
* The place whose hours and contact details the guide types in: the TREK index
* gives it no hours (its OpenStreetMap tag is a shape the parser rejects, see
* OSM_PLACES), so it is the market a traveller corrects by hand.
*/
const HOURS_PLACE = 'Nishiki Market'
const HOURS_CONTACT = { phone: '+81 75 211 3882', email: 'info@kyoto-nishiki.or.jp' }
/** The card itself: the scroll body's parent holds head, body and footer. */
const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..')
@@ -55,7 +63,7 @@ const participants = (page: Page) => section(page, 'Participants')
const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) })
const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true })
/** "Files" while the place has none, "<n> files" once it has. */
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ })
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. */
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: `Open booking: ${BOOKING}` })
/** The booking the strip opens, named by its title. Its form is one Edit further. */
@@ -77,10 +85,20 @@ const weekday = (offset: number) => long(offset).split(',')[0]
const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)')
/** The hours block: the row and, once unfolded, the week in its place. */
const hoursBox = (page: Page) => section(page, 'Opening Hours')
/** The round picture in the head; its name says whether a picture of your own is set. */
const avatar = (page: Page) => card(page).getByRole('button', { name: /^(Upload image|Change image)$/ })
/** The popover the picture opens when the place has photos among its files. */
const imageChooser = (page: Page) => page.getByRole('dialog', { name: 'Choose a picture' })
/** The place form's hours editor, found by its remove button (only there while editing). */
const hoursEditor = (page: Page) =>
modal(page).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::section[1]')
/** The card's own X, the one in its head; the day panel over the map has another. */
/**
* The card's own X, the one in its head; the day panel over the map has another.
* By name: the red X on a custom picture is in the head as well.
*/
async function closeCard(page: Page): Promise<void> {
await head(page).locator('button:has(svg.lucide-x)').click()
await head(page).getByRole('button', { name: 'Close', exact: true }).click()
await expect(card(page)).toHaveCount(0)
}
@@ -291,7 +309,7 @@ const SCRIPTS: Record<string, GuideScript> = {
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
},
{
target: p => card(p).getByRole('button', { name: /^(Upload image|Change image)$/ }),
target: avatar,
act: async p => {
await card(p).locator('input[accept*="image/jpeg"]').setInputFiles(await coverFixture())
await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 })
@@ -303,6 +321,111 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => patchPlace(p, 'Arashiyama Bamboo Grove', { image_url: null }),
},
'place-image-from-file': {
guide: guide('place-image-from-file'),
// Attached before the trip opens, so the files are in the store the card reads.
start: async p => {
await ensurePlacePhoto(p.request)
await openTrip(p, { day: null })
},
steps: [
{
prepare: async p => {
await openPlace(p, PHOTO_PLACE)
await expect(filesToggle(p)).toHaveText(/\d+ files?$/)
},
target: p => section(p, 'Files'),
},
{
target: avatar,
act: async p => {
await avatar(p).click()
await expect(imageChooser(p)).toBeVisible()
// The thumbnails load as blobs one request later.
await expect(imageChooser(p).locator('img').first()).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => imageChooser(p).getByRole('button', { name: PHOTO_FILE, exact: true }),
act: async p => {
await imageChooser(p).getByRole('button', { name: PHOTO_FILE, exact: true }).click()
await expect(imageChooser(p)).toHaveCount(0)
await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(head),
],
cleanup: p => removePlacePhoto(p.request),
},
'place-own-hours': {
guide: guide('place-own-hours'),
start: async p => {
await openTrip(p, { day: null })
await openPlace(p, HOURS_PLACE)
},
steps: [
{
target: p => footer(p).getByRole('button', { name: 'Edit', exact: true }),
act: async p => {
await footer(p).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByLabel('Phone', { exact: true })).toBeVisible()
await modal(p).getByLabel('Phone', { exact: true }).scrollIntoViewIfNeeded()
await settle(p)
},
},
{
target: p => modal(p).getByLabel('E-mail', { exact: true }).locator('xpath=ancestor::div[.//label[normalize-space()="Phone"]][1]'),
act: async p => {
await typeInto(p, modal(p).getByLabel('Phone', { exact: true }), HOURS_CONTACT.phone)
await typeInto(p, modal(p).getByLabel('E-mail', { exact: true }), HOURS_CONTACT.email)
},
},
{
// The week only exists once Add opening hours is clicked, and the picture
// is of the week, with Monday filled in the way the text says.
prepare: async p => {
// Hours the details column looked up open the week by themselves; only
// without them is there an Add opening hours to click.
const add = modal(p).getByRole('button', { name: 'Add opening hours' })
if (await add.isVisible().catch(() => false)) await add.click()
await expect(hoursEditor(p)).toBeVisible()
await modal(p).getByRole('textbox', { name: 'Monday opens' }).fill('10:00')
await modal(p).getByRole('textbox', { name: 'Monday closes' }).fill('18:00')
await p.keyboard.press('Tab')
await hoursEditor(p).scrollIntoViewIfNeeded()
await settle(p)
},
target: hoursEditor,
},
{
target: p => modal(p).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::div[contains(@class, "gap-1")][1]'),
act: async p => {
await modal(p).getByRole('button', { name: "Use Monday's hours for every day" }).click()
await expect(modal(p).getByRole('textbox', { name: 'Sunday opens' })).toHaveValue(/10/)
// A market day off, so the week in the result is not seven equal lines.
await modal(p).getByRole('switch', { name: 'Wednesday: Closed' }).click()
await expect(modal(p).getByRole('switch', { name: 'Wednesday: Closed' })).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
{
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(hoursRow(p)).toBeVisible({ timeout: 20_000 })
await expect(card(p).getByRole('link', { name: HOURS_CONTACT.email })).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await patchPlace(p, HOURS_PLACE, { phone: '', email: '', opening_hours: '' })
},
},
'place-day-assign': {
guide: guide('place-day-assign'),
start: p => openTrip(p, { day: null }),
+164 -25
View File
@@ -1,15 +1,20 @@
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 { captureGuide, captureHero, beat, typeInto, settle, dragLoop, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, gpxFixture } from './fixtures'
import { openTrip, modal, dialog, confirmDialog } from './trip-shared'
import { disconnectDawarich } from './external'
import { ensureDawarichStays, undoDawarichStays, setTours } from './fixtures-trip-places'
import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the places column of a trip, keyed by the ids in
* `src/help/contexts/tripPlaces.ts`. They run on the seeded "Autumn in Japan"
* with one walk imported up front, so the Tracks filter exists; what a guide
* creates it takes away again in `cleanup`, so the next one starts the same.
* with the Tours add-on on, as on every install: the column has the Places and
* Tours switch, no Import file and no Tracks filter. The file import is
* pictured with Tours off, and the Dawarich stays with a connection, each for
* its own guide only; what a guide creates or switches it takes back again in
* `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
@@ -38,15 +43,27 @@ const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\
*/
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. */
/** The list import's entry: List Import, Google List while Google is the only list provider. */
const LIST_IMPORT = /^(Google List|List Import)$/
/** The import button's menu: the list import, and Import file where Tours is off. */
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. */
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: LIST_IMPORT }) }).last()
/** The Show dropdown (All, Unplanned, Planned; Tracks only with Tours off) 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. */
/**
* The icon filters of the row: each a button whose list sits right after it
* (the Tooltip clones the button rather than wrapping it) and opens across the
* whole row.
*/
const listOf = (trigger: Locator) => trigger.locator('xpath=following-sibling::div[1]')
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 categoryList = (page: Page) => listOf(categoryTrigger(page))
const ratingTrigger = (page: Page) => head(page).getByRole('button', { name: 'Filter by rating', exact: true })
const ratingList = (page: Page) => listOf(ratingTrigger(page))
/** Only there once the trip spans two countries or regions; the seed's Tokyo and Kyoto do. */
const localityTrigger = (page: Page) => head(page).getByRole('button', { name: 'Filter by country or region', exact: true })
const localityList = (page: Page) => listOf(localityTrigger(page))
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=..')
@@ -62,10 +79,34 @@ const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[cont
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
// A guide that failed before its cleanup left one behind; two rows of one name would confuse every step.
await deleteByName(page, SPARE.name)
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()}`)
}
/**
* The country and region filter shows once the places lie in two regions or more,
* and the regions come from the Atlas, which looks them up in the background the
* first time its regions are asked for. Ask, then wait until two have come back.
*/
async function resolveRegions(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.request.get('/api/addons/atlas/regions')
await expect
.poll(
async () => {
const body = (await (await page.request.get(`/api/trips/${tripId}/places`)).json()) as
| { places?: { region_name?: string | null }[] }
| { region_name?: string | null }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
return new Set(places.map(x => x.region_name).filter(Boolean)).size
},
{ timeout: 120_000, intervals: [2_000] },
)
.toBeGreaterThan(1)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
@@ -86,7 +127,13 @@ const closeModal = async (page: Page): Promise<void> => {
const only = (target: (p: Page) => Locator) => ({ target })
const sortTrigger = (page: Page) => head(page).getByRole('button', { name: 'Sort by', exact: true })
const sortList = (page: Page) => sortTrigger(page).locator('xpath=following-sibling::div[1]')
const sortList = (page: Page) => listOf(sortTrigger(page))
/** The day headers of the plan; their accessible name starts with the number. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The Dawarich panel on top of the list: its header, and the actions of its rows. */
const dawarichHeader = (page: Page) => page.getByRole('button', { name: /From Dawarich/ })
const stayAction = (page: Page, label: 'Add as a place' | 'Not a place I visited') =>
page.getByRole('button', { name: label, exact: true }).first()
/** A name no index knows, so the search comes back empty and offers Add by hand. */
const HANDMADE = { name: 'Kissaten Hoshizora Ura', phone: '+81 75 000 1234', email: 'hello@hoshizora.example' }
@@ -234,9 +281,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
steps: [
{
target: p => p.getByRole('button', { name: /^1 .*Day 1 / }),
target: p => dayHeader(p, 1),
act: async p => {
await p.getByRole('button', { name: /^1 .*Day 1 / }).click()
await dayHeader(p, 1).click()
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
await settle(p)
},
@@ -270,11 +317,14 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
target: p => row(p, SPARE.name),
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
act: async p => {
await row(p, SPARE.name).dragTo(p.getByRole('button', { name: /^2 .*Day 2 / }))
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
// The gesture is the point of the step, so it also comes as a loop: the
// row leaves the column and lands on the Day 2 card.
clip: async (p, frame) => {
await dragLoop(p, row(p, SPARE.name), dayHeader(p, 2), frame)
await expect(p.locator('.dp-row[role="button"]').filter({ hasText: SPARE.name }).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
for (let i = 0; i < 4; i++) await frame()
},
},
],
@@ -284,6 +334,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('filter-places'),
start: async p => {
await createSpare(p)
await resolveRegions(p)
await openTrip(p)
},
steps: [
@@ -325,9 +376,33 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
prepare: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click(); await beat(p, 300) },
target: p => p.getByRole('button', { name: 'Filter by rating' }),
act: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click() },
// The list of floors, open: 5+ down to 1+ is what the step reads out.
prepare: async p => {
await ratingTrigger(p).click()
await expect(ratingList(p)).toBeVisible()
await beat(p, 300)
},
target: ratingList,
// The lists close on a second click of their trigger.
act: async p => {
await ratingTrigger(p).click()
await expect(ratingList(p)).toHaveCount(0)
await settle(p)
},
},
{
// Countries with their regions under them, each with its count.
prepare: async p => {
await localityTrigger(p).click()
await expect(localityList(p)).toBeVisible()
await beat(p, 300)
},
target: localityList,
act: async p => {
await localityTrigger(p).click()
await expect(localityList(p)).toHaveCount(0)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SPARE.name),
@@ -421,7 +496,11 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'import-places-file': {
guide: guide('import-places-file'),
start: p => openTrip(p),
// Import file is only in the menu of an install with the Tours add-on off.
start: async p => {
await setTours(p.request, false)
await openTrip(p)
},
steps: [
{
prepare: async p => {
@@ -455,7 +534,10 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
cleanup: p => deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa'),
cleanup: async p => {
await deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa')
await setTours(p.request, true)
},
},
'import-places-list': {
guide: guide('import-places-list'),
@@ -467,10 +549,10 @@ const SCRIPTS: Record<string, GuideScript> = {
await expect(importMenu(p)).toBeVisible()
await beat(p, 300)
},
// Google List while Google is the only list provider, List Import once Naver is on too.
target: p => importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }),
// With Tours on, the list import is the menu's only entry.
target: p => importMenu(p).getByRole('button', { name: LIST_IMPORT }),
act: async p => {
await importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }).click()
await importMenu(p).getByRole('button', { name: LIST_IMPORT }).click()
await expect(listImportDialog(p)).toBeVisible()
await settle(p)
},
@@ -492,14 +574,69 @@ const SCRIPTS: Record<string, GuideScript> = {
}
},
},
'dawarich-stays': {
guide: guide('dawarich-stays'),
// Connected for this guide only: while it is, the panel heads the column in
// every picture, and the other guides of this screen do not show it.
start: async p => {
await ensureDawarichStays(p.request, seededTrip().tripId)
await openTrip(p)
await expect(dawarichHeader(p)).toBeVisible({ timeout: 30_000 })
},
steps: [
{
target: dawarichHeader,
act: async p => {
await dawarichHeader(p).click()
await expect(stayAction(p, 'Add as a place')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The first stay's button; the frame shows its row and the day above it.
target: p => stayAction(p, 'Add as a place'),
act: async p => {
await stayAction(p, 'Add as a place').click()
await expect(modal(p).locator('#dawarich-accept-name')).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
const name = await modal(p).locator('#dawarich-accept-name').inputValue()
await modal(p).getByRole('button', { name: 'Add place', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await expect(row(p, name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => stayAction(p, 'Not a place I visited'),
act: async p => {
await stayAction(p, 'Not a place I visited').click()
await expect(p.getByRole('button', { name: /^Show \d+ already dealt with$/ })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
await undoDawarichStays(p.request, seededTrip().tripId)
await disconnectDawarich(p.request)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
test.afterAll(async ({ request }) => {
// Instance-wide, and a guide that fails never reaches its cleanup: Tours back
// on, as on every install, and Dawarich off, so its panel on top of the places
// list does not ride into the pictures of the files after this one.
await setTours(request, true)
await disconnectDawarich(request)
})
test('every registered places guide has a script, and only those', async () => {
@@ -513,6 +650,8 @@ test('hero: trip-places', async ({ page }) => {
for (const id of tripPlacesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
// Dawarich: a recording to upload and a check of the trip's dates first.
if (id === 'dawarich-stays') test.slow()
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
+470 -15
View File
@@ -1,7 +1,11 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
import { captureGuide, captureHero, settle, dragLoop, nudgeClock, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import {
FERRY, FUEL_STOP, GOOGLE_ROUTE, HAZARD_TITLE, assignmentsOn, ensureFerry, ensureFuelStop, hideHazards, overnightFlight, placeIdOf,
removeFerry, removeFuelStop, removeImportedStops, removeNightAt, restoreFlight, stubGooglePreview, stubHazards,
} from './fixtures-trip-roadtrip'
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
import type { HelpGuide } from '../../src/help/types'
@@ -9,7 +13,9 @@ import type { HelpGuide } from '../../src/help/types'
* Actions for road trip mode, keyed by the ids in
* `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in
* Japan" with the Road trip addon switched on, one long drive out of Tokyo on
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). A few
* guides bring their own extra (a fuel stop, a ferry, the answers of Google's
* preview and of the warning feeds), from `fixtures-trip-roadtrip.ts`. What a
* guide writes it takes away again in `cleanup`, and `test.afterAll` switches
* the addon off again: it is an instance-wide setting, and while it is on
* every trip's left column grows a switch the other screens never show.
@@ -24,11 +30,15 @@ const guide = (id: string): HelpGuide => {
// ── Where things are ──────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */
/**
* A day's card by its head band, which is the fold control and carries the day's
* name as its label ("Day 2"): the rail leaves out days with nothing on them. Not
* by the heading inside it, which sits inside that role=button band.
*/
const dayCard = (page: Page, day: number) =>
rail(page)
.locator('> section')
.filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) })
.filter({ has: page.locator(`header[aria-label="Day ${day}"]`) })
.first()
/**
* One stop's row and everything hanging off it. The kind disc sits inside the
@@ -49,8 +59,27 @@ const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^D
/** The list a dropdown opens; only one is open at a time. */
const openList = (page: Page) => corridor(page).locator('div[role="listbox"]')
const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ })
/** Which of day 4's drive bands is its longest leg; worked out by `aimAtLongLeg`. */
let altLeg = 0
/** The drive band of day 4's long leg, the one with more than one road to it. */
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).first()
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).nth(altLeg)
/**
* Pick day 4's longest leg by the distance its band reads. Not simply the first
* band: which leg comes first depends on what else the day holds (a timed stop,
* the hotel the day starts from), and a short hop inside Hakone has one sensible
* road, so the bar would only say that.
*/
async function aimAtLongLeg(page: Page): Promise<void> {
const names = await dayCard(page, 4)
.getByRole('button', { name: /Other ways$/ })
.evaluateAll(els => els.map(el => `${el.getAttribute('aria-label') ?? ''} ${el.textContent ?? ''}`))
const km = names.map(name => {
const m = /([\d.,]+)\s*km/.exec(name)
return m ? Number(m[1].replace(/,/g, '')) : 0
})
altLeg = km.reduce((best, value, i) => (value > km[best] ? i : best), 0)
}
/** The alternatives bar floats over the map, inside neither column. */
const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]')
/** The refuel band belongs to no stop: it runs across the rail between two of them. */
@@ -58,6 +87,31 @@ const dryBand = (page: Page) => page.getByText('Tank runs out here').first().loc
const addFuelStop = (page: Page) => dryBand(page).getByRole('button', { name: /as a fuel stop$/ }).first()
/** A via handle is the one marker Leaflet drags itself; the corridor pins use HTML5 drag. */
const viaHandle = (page: Page) => page.locator('.leaflet-marker-icon.leaflet-marker-draggable').first()
/** The Search button's card: Looking for, the day, Within, Search and Manual. */
const searchCard = (page: Page) => corridor(page).getByRole('button', { name: 'Search', exact: true }).locator('xpath=../..')
/** A hotel hit: its add button is the bed, Add as an overnight stay. */
const nightButton = (page: Page) =>
corridor(page).getByRole('button', { name: 'Add as an overnight stay', exact: true }).first()
const nightRow = (page: Page) =>
corridor(page)
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'Add as an overnight stay', exact: true }) })
.first()
/**
* The battery badge of a refuelling stop. It has no label of its own: its name
* is its figure, "80 %", or the "+" it shows while nothing says how full.
*/
const fillBadge = (page: Page, name: string) => stopItem(page, name).getByRole('button', { name: /^(\d+ %|\+)$/ })
const fillPopover = (page: Page) => page.getByRole('dialog', { name: 'Fills to' })
/** A booked ride is a card button in the chain, named by its title first. */
const rideCard = (page: Page) => rail(page).getByRole('button', { name: /^Lake Ashi cruise/ }).first()
/** The check-in pill of a ride card: the timetable's chips are pills of the same shape. */
const checkInPill = (page: Page) => rideCard(page).locator('span.rounded-full').filter({ hasText: /Check-in|Boarding/ }).last()
/** CustomSelect drops its options into a fixed portal on the body. */
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
/** A warning area drawn by the hazard layer, in the warning colour. */
const hazardArea = (page: Page) => page.locator('.leaflet-overlay-pane path[stroke="var(--warning)"]').first()
const hazardPopup = (page: Page) => page.locator('.leaflet-popup.trek-hazard-popup')
// ── Getting there ─────────────────────────────────────────────────────────────
@@ -100,14 +154,14 @@ async function frameOnStop(page: Page, name: string): Promise<void> {
/**
* Let the map's pan to a picked place land. Leaflet times its pan from
* `+new Date()`, and the clock is pinned to the picture day (`PICTURE_DAY`), so
* `+new Date()`, and the clock is pinned to the picture day (guide.ts), so
* the pan starts and never gets anywhere: the map stays where it was. Moving the
* pinned clock a few seconds on ends it on the next frame, as a second passing
* would; nothing in these pictures reads the time to the second.
*/
async function finishMapPan(page: Page): Promise<void> {
await page.waitForTimeout(400)
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
await nudgeClock(page)
await page.waitForTimeout(1200)
await settle(page)
}
@@ -126,6 +180,13 @@ type RoutePoint = { leg: number; x: number; y: number }
* Points that really lie on a drawn leg, with nothing over them. A bounding-box
* centre is no use here: a route is a curve, its box is mostly empty space, and
* what sits in the middle of it is usually somebody else's pin.
*
* The drawn casing is not what takes the click: it is non-interactive, and an
* invisible band over the same line (`RouteHitBand`, a transparent stroke with
* the class `cursor-copy`) catches it instead. So the point is read off the
* casing, which is the line the reader sees and the one the ring sits on, and
* the hit test asks for the band of that same leg. Both are drawn from the same
* positions, so their `d` is the same string.
*/
async function routePoints(page: Page): Promise<RoutePoint[]> {
return page.evaluate(() => {
@@ -137,13 +198,16 @@ async function routePoints(page: Page): Promise<RoutePoint[]> {
const length = path.getTotalLength()
const ctm = path.getScreenCTM()
if (!ctm || length < 24) return
const d = path.getAttribute('d')
for (let f = 0.08; f <= 0.92; f += 0.02) {
const at = path.getPointAtLength(length * f)
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e)
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f)
// The two panels float over the map; a point under one of them is not clickable.
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
const hit = document.elementFromPoint(x, y)
const ownBand = hit instanceof SVGPathElement && hit.classList.contains('cursor-copy') && hit.getAttribute('d') === d
if (hit === path || ownBand) found.push({ leg, x, y })
}
})
return found
@@ -158,11 +222,16 @@ let viaDayId = 0
/** Names of what a guide created, read back by its cleanup. */
let addedStop = ''
let refuelStop = ''
let nightStop = ''
/** The leg with the most reachable points: the one worth clicking and worth a picture. */
async function aimAtLeg(page: Page): Promise<void> {
const points = await routePoints(page)
if (points.length === 0) throw new Error('no drawn leg is reachable on the map')
if (points.length === 0) {
const legs = await page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').count()
const bands = await page.locator('.leaflet-overlay-pane svg path.cursor-copy').count()
throw new Error(`no drawn leg is reachable on the map (${legs} drawn legs, ${bands} click bands)`)
}
const counts = new Map<number, number>()
for (const point of points) counts.set(point.leg, (counts.get(point.leg) ?? 0) + 1)
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
@@ -178,6 +247,86 @@ const hoverAimedLine = async (page: Page): Promise<void> => {
await page.mouse.move(aimedAt.x, aimedAt.y)
}
/** Where on the warning area a click lands on the area itself, clear of the panels and the route. */
let hazardAt = { x: 0, y: 0 }
async function aimAtHazard(page: Page): Promise<void> {
const aim = await page.evaluate(() => {
const paths = document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane path[stroke="var(--warning)"]')
const path = paths[0]
if (!path) return { point: null, why: 'no warning area is drawn' }
// The stub answers with one storm. More than one area means the live feed
// got through, and its first area is anywhere but over the drive.
if (paths.length > 1) return { point: null, why: `${paths.length} warning areas are drawn, so the feed was not stubbed` }
const box = path.getBoundingClientRect()
const found: { x: number; y: number }[] = []
for (let y = box.top + 8; y < box.bottom - 8; y += 12) {
for (let x = box.left + 8; x < box.right - 8; x += 12) {
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
if (document.elementFromPoint(x, y) === path) found.push({ x: Math.round(x), y: Math.round(y) })
}
}
// Said in the error when nothing is found, so the log tells a map framed
// elsewhere from an area something else is lying on.
const top = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)
const why = `area at ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)}, ` +
`on top at its centre: ${top ? `${top.tagName.toLowerCase()}.${String(top.getAttribute('class') ?? '')}` : 'nothing'}`
return { point: found[Math.floor(found.length / 2)] ?? null, why }
})
if (!aim.point) throw new Error(`no part of the warning area is reachable on the map (${aim.why})`)
hazardAt = aim.point
}
/** Hold the loop's last frame a moment, on the state the gesture left. */
async function holdResult(page: Page, frame: () => Promise<void>): Promise<void> {
await settle(page)
await frame()
await frame()
}
// ── Day 4 as the fixtures left it, for the guide that moves its stops ─────────
let day4Order: number[] = []
async function placeOrder(page: Page, dayIndex: number): Promise<number[]> {
return (await assignmentsOn(page.request, dayIndex)).map(a => a.place_id)
}
/** Whether `first` comes before `second` among the day's stops. */
async function before(page: Page, dayIndex: number, first: string, second: string): Promise<boolean> {
const order = await placeOrder(page, dayIndex)
const a = order.indexOf((await placeIdOf(page.request, first)) ?? -1)
const b = order.indexOf((await placeIdOf(page.request, second)) ?? -1)
return a >= 0 && b >= 0 && a < b
}
/**
* Every stop the guide pushed to another day back on day 4, and day 4 back in
* the order it had. Whichever stop went: the guide moves one, and a run that
* broke halfway must not leave it on the other day for the next guides.
*/
async function restoreDay4(page: Page): Promise<void> {
const { tripId, dayIds } = seededTrip()
const onDay4 = await placeOrder(page, 3)
for (const placeId of day4Order.filter(id => !onDay4.includes(id))) {
for (let i = 0; i < dayIds.length; i++) {
if (i === 3) continue
const moved = (await assignmentsOn(page.request, i)).find(a => a.place_id === placeId)
if (!moved) continue
await page.request.put(`/api/trips/${tripId}/assignments/${moved.id}/move`, {
data: { new_day_id: dayIds[3], order_index: null },
})
break
}
}
const day4 = await assignmentsOn(page.request, 3)
const rank = (placeId: number) => {
const i = day4Order.indexOf(placeId)
return i === -1 ? day4Order.length : i
}
const orderedIds = [...day4].sort((a, b) => rank(a.place_id) - rank(b.place_id)).map(a => a.id)
await page.request.put(`/api/trips/${tripId}/days/${dayIds[3]}/assignments/reorder`, { data: { orderedIds } })
}
// ── What the guides write, and how it is taken back ───────────────────────────
type ViaRow = { id: number; day_id: number; lat: number; lng: number }
@@ -321,9 +470,13 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
// Hakone Shrine, not a seeded stop: every seeded visit has an end time
// from the day plan, and a stop with one opens the read-only "until"
// version of the dialog, which has no slider and no Save. The drive's
// own fixture places carry no times, so the badge is the plus of Add a stay.
target: p => stopItem(p, 'Hakone Shrine').getByRole('button', { name: /^(Time at this stop|Add a stay)/ }),
act: async p => {
await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click()
await stopItem(p, 'Hakone Shrine').getByRole('button', { name: /^(Time at this stop|Add a stay)/ }).click()
await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible()
await settle(p)
},
@@ -335,16 +488,66 @@ const SCRIPTS: Record<string, GuideScript> = {
await expect(modal(p).getByRole('slider', { name: 'Minutes' })).toHaveValue('60')
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
await expect(stopItem(p, 'Hakone Shrine')).toContainText('1 h', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
// Zero, not null: the update folds a null into "leave it alone".
await restorePlace(p, 'Hakone Shrine', { duration_minutes: 0 })
await restorePlace(p, 'Meiji Jingu', { stop_type: null })
},
},
'roadtrip-reorder': {
guide: guide('roadtrip-reorder'),
start: async p => {
day4Order = await placeOrder(p, 3)
await openRoadtrip(p)
},
steps: [
{
// Hakone Shrine onto Shinjuku Gyoen: the shrine moves up beside
// Owakudani, and the day stops driving to Tokyo and back.
target: p => stopItem(p, 'Hakone Shrine'),
dropTo: p => stopItem(p, 'Shinjuku Gyoen'),
clip: async (p, frame) => {
await dragLoop(p, stopItem(p, 'Hakone Shrine'), stopItem(p, 'Shinjuku Gyoen'), frame)
await expect.poll(() => before(p, 3, 'Hakone Shrine', 'Shinjuku Gyoen'), { timeout: 20_000 }).toBe(true)
await holdResult(p, frame)
},
},
{
// Onto the Open-Air Museum on day 3, the Hakone day the card just above:
// the shrine on Lake Ashi joins the lake and the museum. A day close by,
// and not Tokyo's day 2: the picture centres the stop it picks up in the
// rail, whatever a prepare scrolled to before, and with day 3 and the
// head of day 4 between them day 2 then lies above the rail's window, so
// the pointer let go off the rail and nothing moved.
target: p => stopItem(p, 'Hakone Shrine'),
dropTo: p => stopItem(p, 'Hakone Open-Air Museum'),
clip: async (p, frame) => {
await dragLoop(p, stopItem(p, 'Hakone Shrine'), stopItem(p, 'Hakone Open-Air Museum'), frame)
const shrine = await placeIdOf(p.request, 'Hakone Shrine')
await expect.poll(async () => (await placeOrder(p, 2)).includes(shrine ?? -1), { timeout: 20_000 }).toBe(true)
await expect(dayCard(p, 3)).toContainText('Hakone Shrine', { timeout: 30_000 })
await holdResult(p, frame)
},
},
{
// Day 4 is Owakudani and Shinjuku Gyoen now; Alt and up puts the garden
// first, and the day drives out of Tokyo to the valley.
target: p => stopRow(p, 'Shinjuku Gyoen'),
act: async p => {
await stopRow(p, 'Shinjuku Gyoen').focus()
await p.keyboard.press('Alt+ArrowUp')
await expect.poll(() => before(p, 3, 'Shinjuku Gyoen', 'Owakudani'), { timeout: 20_000 }).toBe(true)
await settle(p)
},
},
],
cleanup: restoreDay4,
},
'roadtrip-corridor': {
guide: guide('roadtrip-corridor'),
start: async p => {
@@ -419,6 +622,74 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByName(p, addedStop),
},
'roadtrip-overnight': {
guide: guide('roadtrip-overnight'),
start: async p => {
nightStop = ''
await openRoadtrip(p)
},
steps: [
{
target: searchCard,
act: async p => {
// Accommodation on its own: the panel opens on Fuel for a petrol car,
// so Accommodation is ticked first and Fuel taken off after it (the
// trigger is only named "Looking for: ..." while something is ticked).
await kindPicker(p).click()
await expect(openList(p)).toBeVisible()
await openList(p).getByRole('option', { name: 'Accommodation' }).click()
const fuel = openList(p).getByRole('option', { name: 'Fuel' })
if ((await fuel.getAttribute('aria-selected')) === 'true') await fuel.click()
await kindPicker(p).click()
await expect(openList(p)).toHaveCount(0)
await expect(kindPicker(p)).toHaveAccessibleName('Looking for: Accommodation')
// Day 4 ends in Hakone, where the inns are.
await dayPicker(p).click()
await openList(p).getByRole('option', { name: /\bDay 4$/ }).click()
await expect(dayPicker(p)).toContainText('Day 4')
await corridor(p).getByRole('button', { name: 'Search', exact: true }).click()
await expect(nightButton(p)).toBeVisible({ timeout: 180_000 })
await settle(p)
},
},
{
prepare: async p => {
nightStop = (await nightRow(p).innerText()).split('\n')[0] ?? ''
await nightRow(p).hover()
await settle(p)
},
target: nightButton,
act: async p => {
await nightButton(p).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
// Left on Overnight, which is what a hotel opens on.
target: p => modal(p).getByRole('group', { name: 'Kind of stop' }),
act: async p => {
await expect(modal(p).getByRole('button', { name: 'Overnight', exact: true })).toHaveAttribute('aria-pressed', 'true')
},
},
only(p => dialog(p).locator('aside').filter({ hasText: 'Find accommodation' })),
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === nightStop), { timeout: 30_000 })
.toBe(true)
await expect(rail(p).getByText(nightStop).first()).toBeVisible({ timeout: 30_000 })
await rail(p).getByText(nightStop).first().scrollIntoViewIfNeeded()
await settle(p)
},
},
],
// The stay goes first, with the booking it brought, then the place.
cleanup: p => removeNightAt(p.request, nightStop),
},
'roadtrip-via': {
guide: guide('roadtrip-via'),
start: openRoadtrip,
@@ -494,13 +765,23 @@ const SCRIPTS: Record<string, GuideScript> = {
start: openRoadtrip,
steps: [
{
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
// Shibuya the router knows one sensible way, and the bar says only that.
// Day 4's long drive between Tokyo and Hakone, not a city leg: for three
// kilometres across Shibuya the router knows one sensible way, and the
// bar says only that.
prepare: aimAtLongLeg,
target: altBand,
act: async p => {
await altBand(p).click()
await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 })
await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 })
// Both are answers of the public routers, not of this script; said
// outright, so a run on a day they will not help reads as that.
if (await p.getByText('The router is not answering right now.').isVisible()) {
throw new Error('the alternatives router did not answer (OSRM demo or public Valhalla)')
}
if (await p.getByText('Only one sensible way to drive this one.').isVisible()) {
throw new Error(`the router offered one way only for day 4's leg ${altLeg + 1}`)
}
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
@@ -681,6 +962,74 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByName(p, refuelStop),
},
'roadtrip-stop-fill': {
guide: guide('roadtrip-stop-fill'),
start: async p => {
await ensureFuelStop(p.request)
await openRoadtrip(p)
},
steps: [
{
// Faint "80 %": the stop follows the trip's Fill up to.
target: p => fillBadge(p, FUEL_STOP.name),
act: async p => {
await fillBadge(p, FUEL_STOP.name).click()
await expect(fillPopover(p)).toBeVisible()
await settle(p)
},
},
{
target: fillPopover,
act: async p => {
await fillPopover(p).getByRole('button', { name: '100', exact: true }).click()
await expect(fillPopover(p)).toHaveCount(0)
await expect(stopItem(p, FUEL_STOP.name)).toContainText('100 %', { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await fillBadge(p, FUEL_STOP.name).click()
await expect(fillPopover(p)).toBeVisible()
await settle(p)
},
target: p => fillPopover(p).getByRole('button', { name: /^Use my default/ }),
act: async p => {
await fillPopover(p).getByRole('button', { name: /^Use my default/ }).click()
await expect(fillPopover(p)).toHaveCount(0)
await expect(stopItem(p, FUEL_STOP.name)).toContainText('80 %', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => removeFuelStop(p.request),
},
'roadtrip-bookings': {
guide: guide('roadtrip-bookings'),
start: async p => {
await ensureFerry(p.request)
await openRoadtrip(p)
},
steps: [
only(p => dayCard(p, 3)),
only(rideCard),
only(checkInPill),
{
target: rideCard,
act: async p => {
await rideCard(p).click()
await expect(modal(p)).toContainText(FERRY.title, { timeout: 15_000 })
await settle(p)
await closeModal(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await removeFerry(p.request)
},
},
'roadtrip-track': {
guide: guide('roadtrip-track'),
start: openRoadtrip,
@@ -726,6 +1075,110 @@ const SCRIPTS: Record<string, GuideScript> = {
await clearVias(p)
},
},
'roadtrip-google-import': {
guide: guide('roadtrip-google-import'),
start: async p => {
await stubGooglePreview(p)
await openRoadtrip(p)
},
steps: [
{
target: p => corridor(p).getByRole('button', { name: 'Import Google Maps route', exact: true }),
act: async p => {
await corridor(p).getByRole('button', { name: 'Import Google Maps route', exact: true }).click()
await p.getByRole('menu').getByRole('button', { name: 'Import Google Maps route' }).click()
await expect(modal(p).getByRole('heading', { name: 'Import Google Maps route' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('textbox', { name: 'Google Maps URL' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Google Maps URL' }).fill(GOOGLE_ROUTE.url)
await modal(p).getByRole('button', { name: 'Preview', exact: true }).click()
await expect(modal(p).getByRole('list').first()).toContainText('Lake Kawaguchi', { timeout: 30_000 })
await settle(p)
},
},
only(p => modal(p).getByRole('list').first()),
{
target: dialog,
act: async p => {
// Day 3, the Hakone day: the route goes on round Mount Fuji from there.
await modal(p).getByRole('button', { name: /^Day \d+/, expanded: false }).first().click()
await expect(selectMenu(p)).toBeVisible()
await selectMenu(p).getByRole('button', { name: 'Day 3', exact: true }).click()
await expect(modal(p).getByRole('button', { name: /^Day 3/ }).first()).toBeVisible()
await modal(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 60_000 })
const day3 = async () => {
const ids = await placeOrder(p, 2)
return Promise.all(['Gotemba Premium Outlets', 'Lake Kawaguchi'].map(async n => ids.includes((await placeIdOf(p.request, n)) ?? -1)))
}
await expect.poll(async () => (await day3()).every(Boolean), { timeout: 30_000 }).toBe(true)
await expect(dayCard(p, 3)).toContainText('Lake Kawaguchi', { timeout: 60_000 })
await dayCard(p, 3).scrollIntoViewIfNeeded()
await settle(p)
},
},
],
cleanup: async p => {
await p.unrouteAll({ behavior: 'ignoreErrors' })
await removeImportedStops(p.request)
},
},
'roadtrip-hazards': {
guide: guide('roadtrip-hazards'),
start: async p => {
// The stub sits on the browser context: the service worker fetches the
// feed itself, and a page route would let the live warnings through.
await stubHazards(p)
await openRoadtrip(p)
},
steps: [
{
target: settingsCard,
act: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByText('Current warnings', { exact: true }).locator('xpath=ancestor::section[1]'),
act: async p => {
const toggle = modal(p).getByRole('button', { name: 'Show hazard areas', exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
// The footer's Close, not the cross in the head band.
await modal(p).getByRole('button', { name: 'Close', exact: true }).last().click()
await expect(modal(p)).toHaveCount(0)
await expect(hazardArea(p)).toBeAttached({ timeout: 30_000 })
// The map stands fitted to the whole trip, where the storm over Hakone
// is a small patch near the right column, crossed by the drive and its
// pins, and wherever the fit lands decides whether it is reachable at
// all. Hakone Shrine lies inside it, so framing on
// that stop (the way the via guide brings its leg into view) puts the
// area in the middle of the map at the same zoom, outline and all.
await frameOnStop(p, 'Hakone Shrine')
await aimAtHazard(p)
},
},
{
target: hazardArea,
hover: async p => { await p.mouse.move(hazardAt.x, hazardAt.y) },
act: async p => {
await p.mouse.click(hazardAt.x, hazardAt.y)
await expect(hazardPopup(p)).toContainText(HAZARD_TITLE, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
await p.context().unrouteAll({ behavior: 'ignoreErrors' })
await hideHazards(p.request)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
@@ -734,6 +1187,7 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureRoadtripFixtures(request)
await overnightFlight(request)
})
test.afterAll(async ({ request }) => {
@@ -741,6 +1195,7 @@ test.afterAll(async ({ request }) => {
// Days / Road trip switch and every other screen's pictures change. Here
// rather than in the last guide's cleanup, so a failed run puts it back too.
await request.put('/api/admin/addons/roadtrip', { data: { enabled: false } })
await restoreFlight(request)
})
test('every registered road trip guide has a script, and only those', async () => {
+11 -3
View File
@@ -27,14 +27,22 @@ export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.tre
export const portalDialog = (page: Page, contains: Locator): Locator =>
page.locator('div[role="presentation"]').filter({ has: contains }).last()
/**
* The trip page has loaded: its header carries the Trip settings pill, which
* every tab shows. (Share moved into that pill's dialog in v5, so it is no
* longer a button of its own up there.)
*/
export async function tripReady(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 })
}
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
const { tripId } = seededTrip()
const { tab, day = 1 } = opts
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
await clearNotices(page)
await dismissReleaseNotice(page)
// Exact: the Lists tab carries a Shared button, which the loose name also matches.
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await tripReady(page)
await settle(page)
await untilTranslated(page)
if (!tab && day) {
@@ -62,7 +70,7 @@ async function untilTranslated(page: Page): Promise<void> {
await page.reload()
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await tripReady(page)
await settle(page)
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
+260
View File
@@ -0,0 +1,260 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourBikeContext, tripTourBikeGuides } from '../../src/help/contexts/tripTourBike';
import type { HelpGuide } from '../../src/help/types';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES, TOUR_PLACES, TOUR_TRIP } from './tour-fixtures';
/**
* Actions for bike tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourBike.ts`. They run on "Summer in the Dolomites"
* (`ensureToursFixtures`): the road bike loop over the four passes and the
* MTB loop over Monte Pana, both saved tours that are only opened here and
* left as they were. The one guide that saves a tour of its own deletes it
* again in `cleanup`.
*
* Every route in the editor is planned in the browser against the public
* Valhalla, and the Surface and ways card asks the same server how the route
* runs, so these pictures need the network; the MTB trail grades come from
* the server's trail-grade endpoint (TREK Places, else Overpass).
*/
const guide = (id: string): HelpGuide => {
const g = tripTourBikeGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
/** The tour the profile guide plans and saves, and deletes again afterwards. */
const NEW_TOUR = 'Selva to Bolzano by bike';
// ── Where things are ──────────────────────────────────────────────────────────
/** The left rail: the neutral head with Plan new tour, or the editor. Both are labelled "Plan a tour". */
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour' });
/** The right rail, "Tours of this trip". */
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name });
const profileField = (page: Page) => editorRail(page).getByRole('group', { name: 'Profile' });
const waysCard = (page: Page) => page.getByRole('region', { name: 'Surface and ways' });
const summaryCard = (page: Page) => page.getByTestId('tour-planner-overview');
/** A section of the ways card by its eyebrow ("Surface", "Kind of way", "Trail grade (mtb:scale)"). */
const waysSection = (page: Page, label: string) =>
waysCard(page).getByText(label, { exact: true }).locator('xpath=ancestor::div[1]');
/** The first row whose test id starts with one of the prefixes, in the order given. */
async function firstRow(page: Page, prefixes: string[]): Promise<Locator> {
for (const prefix of prefixes) {
const rows = waysCard(page).locator(`[data-testid^="route-ways-row-${prefix}"]`);
if ((await rows.count()) > 0) return rows.first();
}
throw new Error(`no row in the Surface and ways card for ${prefixes.join(', ')}`);
}
/** An unpaved surface row: gravel or natural ground read best, compacted will do. */
const UNPAVED_ROWS = ['surface-gravel', 'surface-ground', 'surface-compacted', 'surface-rough'];
/** A kind of way that is not the road: the forest track is the one an MTB loop has. */
const WAY_ROWS = ['way-track', 'way-path', 'way-cyclePath', 'way-quiet', 'way-'];
/** A graded trail before an ungraded one. */
const GRADE_ROWS = ['trail-s', 'trail-'];
// ── Getting there ─────────────────────────────────────────────────────────────
async function openToursTab(page: Page): Promise<void> {
await openTourTrip(page, { tab: 'tour-planner' });
await expect(toursRail(page)).toBeVisible({ timeout: 30_000 });
await expect(tourRow(page, TOUR_NAMES.roadBike)).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/**
* Wait until the route in the editor is planned and its heights are in: the
* status badges beside the rail's title are gone once nothing is happening.
*/
async function waitForRoute(page: Page): Promise<void> {
await expect(summaryCard(page)).toBeVisible({ timeout: 120_000 });
await expect(page.getByTestId('tour-planner-status')).toHaveCount(0, { timeout: 120_000 });
await settle(page);
}
/** The Surface and ways card once it has read the ways (and, on an MTB tour, the grades). */
async function waitForWays(page: Page): Promise<void> {
await expect(page.getByTestId('bike-ways-card')).toBeVisible({ timeout: 120_000 });
await expect(page.getByText('Reading the ways along the route…')).toHaveCount(0, { timeout: 120_000 });
await expect(page.getByText('Reading the trail grades…')).toHaveCount(0, { timeout: 120_000 });
await expect(waysSection(page, 'Surface')).toBeVisible({ timeout: 30_000 });
await settle(page);
}
async function openBikeTour(page: Page, name: string): Promise<void> {
await tourRow(page, name).click();
await expect(editorRail(page).getByRole('heading', { name: 'Edit tour' })).toBeVisible({ timeout: 30_000 });
await waitForRoute(page);
await waitForWays(page);
}
async function openToursTabWith(page: Page, name: string): Promise<void> {
await openToursTab(page);
await openBikeTour(page, name);
}
/** Delete the tour the profile guide saved, wherever the run stopped. */
async function deleteNewTour(page: Page): Promise<void> {
const trips =
((await (await page.request.get('/api/trips')).json()) as { trips?: { id: number; title: string }[] }).trips ?? [];
const trip = trips.find((t) => t.title === TOUR_TRIP);
if (!trip) return;
const { tours = [] } = (await (await page.request.get(`/api/trips/${trip.id}/tours`)).json()) as {
tours?: { place_id: number; name: string }[];
};
for (const tour of tours.filter((t) => t.name === NEW_TOUR)) {
await page.request.delete(`/api/trips/${trip.id}/places/${tour.place_id}`);
}
}
// ── The scripts ───────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-bike-profile': {
guide: guide('tour-bike-profile'),
start: async (p) => {
await deleteNewTour(p);
await openToursTab(p);
},
steps: [
{
target: (p) => editorRail(p).getByRole('button', { name: 'Plan new tour', exact: true }),
act: async (p) => {
await editorRail(p).getByRole('button', { name: 'Plan new tour', exact: true }).click();
await expect(editorRail(p).getByRole('heading', { name: 'New tour' })).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-type-select'),
act: async (p) => {
await p.getByTestId('tour-type-select').click();
await p.getByTestId('tour-type-option-bike').click();
await expect(profileField(p)).toBeVisible();
await settle(p);
},
},
{
target: profileField,
act: async (p) => {
await profileField(p).getByRole('button', { name: 'Touring', exact: true }).click();
await expect(p.getByText('Prefers cycle paths and quiet roads, paved where it can.')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-add-place'),
act: async (p) => {
await p.getByTestId('tour-planner-add-place').click();
for (const name of [TOUR_PLACES.selvaHotel, TOUR_PLACES.bolzanoHotel]) {
await p.getByTestId('trip-place-candidate').filter({ hasText: name }).first().click();
await settle(p);
}
await p.getByTestId('tour-planner-add-place').click();
await waitForRoute(p);
},
},
{
target: summaryCard,
hover: async (p) => {
await summaryCard(p).getByRole('button', { name: 'How the riding time is worked out' }).hover();
},
},
{
prepare: async (p) => {
await p.locator('#tour-planner-name').fill(NEW_TOUR);
await settle(p);
},
target: (p) => editorRail(p).getByRole('button', { name: 'Save', exact: true }),
act: async (p) => {
await editorRail(p).getByRole('button', { name: 'Save', exact: true }).click();
await expect(p.getByText('Tour saved')).toBeVisible({ timeout: 60_000 });
await expect(tourRow(p, NEW_TOUR)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: deleteNewTour,
},
'tour-bike-surfaces': {
guide: guide('tour-bike-surfaces'),
start: openToursTab,
steps: [
{
target: (p) => tourRow(p, TOUR_NAMES.mtb),
act: (p) => openBikeTour(p, TOUR_NAMES.mtb),
},
{
target: (p) =>
waysCard(p)
.getByText(/unpaved$/)
.first(),
},
{
target: (p) => waysSection(p, 'Surface'),
act: async (p) => {
await (await firstRow(p, UNPAVED_ROWS)).click();
await settle(p);
},
},
{
target: (p) => waysSection(p, 'Kind of way'),
act: async (p) => {
await (await firstRow(p, WAY_ROWS)).click();
await settle(p);
},
},
],
},
'tour-bike-mtb-grades': {
guide: guide('tour-bike-mtb-grades'),
start: (p) => openToursTabWith(p, TOUR_NAMES.mtb),
steps: [
{
target: profileField,
hover: async (p) => {
await profileField(p).getByRole('button', { name: 'MTB', exact: true }).hover();
},
},
{
target: (p) => waysSection(p, 'Trail grade (mtb:scale)'),
},
{
target: (p) => waysSection(p, 'Trail grade (mtb:scale)').getByRole('list'),
act: async (p) => {
await (await firstRow(p, GRADE_ROWS)).click();
await settle(p);
},
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
});
test('every registered bike tour guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourBikeContext.guides].sort());
});
test('hero: trip-tour-bike', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourBikeContext.id, (p) => openToursTabWith(p, TOUR_NAMES.roadBike));
});
for (const id of tripTourBikeContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
test.setTimeout(8 * 60_000);
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+467
View File
@@ -0,0 +1,467 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourDetailContext, tripTourDetailGuides } from '../../src/help/contexts/tripTourDetail';
import type { HelpGuide } from '../../src/help/types';
import {
deleteToursNamed,
deleteTripFilesNamed,
ensureTourDetailFixtures,
FERRATA_PACKING,
HUT_BOOKING_FILE,
hutBookingFixture,
removeInariRecording,
tourIdOf,
unkeepTour,
unlinkInariRecording,
unlinkPacking,
} from './fixtures-trip-tour-detail';
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
import {
ensureTourRecording,
ensureToursFixtures,
openTourTrip,
TOUR_COLLECTION,
TOUR_NAMES,
type ToursFixtures,
} from './tour-fixtures';
import { openTrip } from './trip-shared';
/**
* Actions for the card a tour opens over the map on the Plan tab, keyed by the
* ids in `src/help/contexts/tripTourDetail.ts`. They run on "Summer in the
* Dolomites" from `tour-fixtures.ts`, and the two about actual tracks on the
* Fushimi Inari hike of the seeded "Autumn in Japan" with its recording
* (`ensureTourRecording`). What a guide changes it changes back in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourDetailGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let fx: ToursFixtures | null = null;
const fixtures = (): ToursFixtures => {
if (!fx) throw new Error('ensureToursFixtures did not run');
return fx;
};
/** The new name the rename guide types. */
const RENAMED = 'Sella Ronda, four passes';
/** The colour it picks: TRACK_COLORS[1], the orange. */
const PICKED_COLOR = '#ea580c';
const COPY_NAME = `${TOUR_NAMES.roadBike} (copy)`;
/**
* The tour card: the box whose head carries the Track color dot. Only the
* tour card has that button in a header, the place card names its own
* "Track color".
*/
const card = (page: Page) => page.locator('header:has(button[aria-label="Track color"])').locator('xpath=..');
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 <section> that opens with it. */
const section = (page: Page, heading: string) =>
card(page).getByText(heading, { exact: true }).first().locator('xpath=ancestor::section[1]');
/** The Places | Tours switch at the top of the right column. */
const toursTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Tours' });
/** A tour's row in the Tours list. */
const row = (page: Page, name: string) => page.getByRole('option', { name, exact: true }).first();
/** The block of distance, times, heights and the profile: the first fact list with the altitudes, and its frame. */
const measures = (page: Page) =>
card(page).locator('dl').filter({ hasText: 'Minimum altitude' }).first().locator('xpath=..');
/** "Files" while the tour has none, "<n> file(s)" once it has. */
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files?)$/ });
const compare = (page: Page) => card(page).getByRole('region', { name: 'Plan and actual' });
const only = (target: (p: Page) => Locator) => ({ target });
/** Switch the right column to Tours and open a tour's card from its row. */
async function openTour(page: Page, name: string): Promise<void> {
if ((await toursTab(page).getAttribute('aria-selected')) !== 'true') await toursTab(page).click();
await expect(row(page, name)).toBeVisible({ timeout: 30_000 });
await row(page, name).click();
await expect(head(page).getByRole('heading', { name, exact: true })).toBeVisible({ timeout: 30_000 });
await settle(page);
}
async function putPlace(page: Page, tripId: number, placeId: number, data: Record<string, unknown>): Promise<void> {
const res = await page.request.put(`/api/trips/${tripId}/places/${placeId}`, { data });
if (!res.ok()) throw new Error(`restore place ${placeId}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-detail-read': {
guide: guide('tour-detail-read'),
// Day 4 open, the first stage of the hut trek, so the card knows the stop it was opened on.
start: async (p) => {
await openTourTrip(p, { day: 4 });
},
steps: [
{
prepare: async (p) => {
await toursTab(p).click();
await expect(row(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
target: (p) => row(p, TOUR_NAMES.hutToHut),
act: async (p) => {
await row(p, TOUR_NAMES.hutToHut).click();
await expect(head(p).getByRole('heading', { name: TOUR_NAMES.hutToHut, exact: true })).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
},
only(head),
// The pill with the clock; hovering it shows the parts of the time.
only((p) =>
head(p)
.locator('span[aria-label]')
.filter({ has: p.locator('svg.lucide-clock') })
.first()
),
only((p) => section(p, 'Trip days')),
only(measures),
only((p) => section(p, 'Origin')),
// No click: the open card is what the guide leaves behind.
only((p) => head(p).getByRole('button', { name: 'Close', exact: true })),
],
},
'tour-rename-colour': {
guide: guide('tour-rename-colour'),
start: async (p) => {
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.roadBike);
},
steps: [
{
target: (p) => head(p).getByRole('button', { name: 'Edit', exact: true }),
act: async (p) => {
await head(p).getByRole('button', { name: 'Edit', exact: true }).click();
await expect(head(p).getByRole('textbox', { name: 'Name' })).toBeVisible();
await typeInto(p, head(p).getByRole('textbox', { name: 'Name' }), RENAMED);
await settle(p);
},
},
{
target: (p) => head(p).getByRole('textbox', { name: 'Name' }).locator('xpath=..'),
act: async (p) => {
await head(p).getByRole('button', { name: 'Save', exact: true }).click();
await expect(head(p).getByRole('heading', { name: RENAMED, exact: true })).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => head(p).getByRole('button', { name: 'Track color' }),
act: async (p) => {
await head(p).getByRole('button', { name: 'Track color' }).click();
await expect(card(p).getByRole('button', { name: PICKED_COLOR, exact: true })).toBeVisible();
await settle(p);
},
},
{
target: (p) => card(p).getByRole('button', { name: 'Automatic color' }).locator('xpath=ancestor::section[1]'),
act: async (p) => {
await card(p).getByRole('button', { name: PICKED_COLOR, exact: true }).click();
await expect(card(p).getByRole('button', { name: PICKED_COLOR, exact: true })).toHaveAttribute(
'aria-pressed',
'true',
{ timeout: 20_000 }
);
await settle(p);
},
},
],
cleanup: async (p) => {
const { tripId, tours } = fixtures();
const id = tours.roadBike ?? (await tourIdOf(p.request, tripId, RENAMED));
await putPlace(p, tripId, id, { name: TOUR_NAMES.roadBike, route_color: null });
},
},
'tour-take-along': {
guide: guide('tour-take-along'),
// The city walk: the one tour with a web page of its own.
start: async (p) => {
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.cityWalk);
},
steps: [
{
target: (p) => footer(p).getByRole('button', { name: 'Navigation' }),
act: async (p) => {
await footer(p).getByRole('button', { name: 'Navigation' }).click();
await expect(p.getByRole('menu')).toBeVisible();
await settle(p);
},
},
{
// Clicking it opens an external tab, so the step only shows it.
prepare: async (p) => {
await p.keyboard.press('Escape');
await expect(p.getByRole('menu')).toHaveCount(0);
// Escape may have closed the card as well; bring it back.
if ((await card(p).count()) === 0) await openTour(p, TOUR_NAMES.cityWalk);
},
target: (p) => footer(p).getByRole('button', { name: /^Website: / }),
},
{
target: (p) => footer(p).getByTestId('tour-gpx-export'),
act: async (p) => {
const download = p.waitForEvent('download', { timeout: 30_000 });
await footer(p).getByTestId('tour-gpx-export').click();
await download;
await settle(p);
},
},
],
},
'tour-copy-keep': {
guide: guide('tour-copy-keep'),
start: async (p) => {
const { tripId, collectionId } = fixtures();
await deleteToursNamed(p.request, tripId, COPY_NAME);
if (collectionId !== null) await unkeepTour(p.request, collectionId, TOUR_NAMES.roadBike);
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.roadBike);
},
steps: [
{
// The visible label is Copy; the button is named by what it does.
target: (p) => footer(p).getByRole('button', { name: 'Save as new tour' }),
act: async (p) => {
await footer(p).getByRole('button', { name: 'Save as new tour' }).click();
await expect(head(p).getByRole('heading', { name: COPY_NAME, exact: true })).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only(head),
{
// Back to the original: that is the one kept for later trips.
prepare: async (p) => {
await openTour(p, TOUR_NAMES.roadBike);
},
target: (p) => footer(p).getByTestId('tour-keep-in-collection'),
act: async (p) => {
await footer(p).getByTestId('tour-keep-in-collection').click();
await expect(p.getByTestId('keep-tour-confirm')).toBeVisible();
await expect(p.getByTestId(`keep-tour-list-${fixtures().collectionId}`)).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => p.getByTestId(`keep-tour-list-${fixtures().collectionId}`),
act: async (p) => {
await p.getByTestId(`keep-tour-list-${fixtures().collectionId}`).click();
await p.getByTestId('keep-tour-confirm').click();
await expect(p.getByTestId('keep-tour-confirm')).toHaveCount(0, { timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
const { tripId, collectionId } = fixtures();
await deleteToursNamed(p.request, tripId, COPY_NAME);
if (collectionId !== null) await unkeepTour(p.request, collectionId, TOUR_NAMES.roadBike);
},
},
'tour-packing-link': {
guide: guide('tour-packing-link'),
start: async (p) => {
const { tripId } = fixtures();
await ensureTourDetailFixtures(p.request, tripId);
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.ferrata);
},
steps: [
{
target: (p) => card(p).getByRole('button', { name: 'Link a packing list' }),
act: async (p) => {
await card(p).getByRole('button', { name: 'Link a packing list' }).click();
await expect(p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`)).toBeVisible({
timeout: 20_000,
});
await settle(p);
},
},
{
target: (p) => p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`),
act: async (p) => {
await p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`).click();
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-packing-confirm'),
act: async (p) => {
await p.getByTestId('tour-packing-confirm').click();
await expect(card(p).getByTestId('tour-packing-card')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only((p) => card(p).getByTestId('tour-packing-card')),
{
target: (p) => card(p).getByRole('button', { name: 'Open packing list' }),
act: async (p) => {
await card(p).getByRole('button', { name: 'Open packing list' }).click();
await expect(p.getByText(FERRATA_PACKING.items[0], { exact: true }).first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
const { tripId, tours } = fixtures();
await unlinkPacking(p.request, tripId, tours.ferrata ?? (await tourIdOf(p.request, tripId, TOUR_NAMES.ferrata)));
},
},
'tour-files': {
guide: guide('tour-files'),
start: async (p) => {
await deleteTripFilesNamed(p.request, fixtures().tripId, HUT_BOOKING_FILE);
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.hutToHut);
},
steps: [
{
target: (p) => section(p, 'Files').getByText('Upload', { exact: true }),
act: async (p) => {
await card(p).locator('input[type="file"][multiple]').setInputFiles(hutBookingFixture());
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
// The list unfolds itself once the upload lands.
only((p) => section(p, 'Files')),
// No click: opening the file hands the browser a download.
only((p) => card(p).getByRole('button', { name: new RegExp(`^${HUT_BOOKING_FILE.replace('.', '\\.')}`) })),
],
cleanup: (p) => deleteTripFilesNamed(p.request, fixtures().tripId, HUT_BOOKING_FILE),
},
'tour-activity-link': {
guide: guide('tour-activity-link'),
start: async (p) => {
await ensureTourRecording(p.request);
await unlinkInariRecording(p.request);
await openTrip(p, { day: 5 });
await openTour(p, TOUR_NAMES.inariHike);
},
steps: [
{
target: (p) => section(p, 'Actual track').getByRole('button', { name: 'Add actual track' }),
act: async (p) => {
await card(p).getByRole('button', { name: 'Add actual track' }).click();
await expect(p.getByTestId('tour-activity-confirm')).toBeVisible({ timeout: 20_000 });
await expect(p.getByTestId('tour-activity-candidate').first()).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
only((p) => p.getByText('Done on', { exact: true }).first().locator('xpath=ancestor::section[1]')),
{
target: (p) => p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) }),
act: async (p) => {
await p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) }).click();
await expect(p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) })).toHaveAttribute(
'aria-checked',
'true'
);
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-activity-confirm'),
act: async (p) => {
await p.getByTestId('tour-activity-confirm').click();
await expect(card(p).getByTestId('tour-activity')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only((p) => card(p).getByTestId('tour-activity')),
],
// The guide ends where ensureTourRecording leaves the hike: the recording linked on day 5.
cleanup: async (p) => {
await ensureTourRecording(p.request);
},
},
'tour-activity-compare': {
guide: guide('tour-activity-compare'),
start: async (p) => {
await ensureTourRecording(p.request);
await openTrip(p, { day: 5 });
await openTour(p, TOUR_NAMES.inariHike);
},
steps: [
only((p) => card(p).getByTestId('tour-activity-compare')),
// The fact list under the table: Farthest from the plan and Off the plan.
only((p) => compare(p).locator('dl').last()),
{
target: (p) => compare(p).getByRole('button', { name: 'Compare on map' }),
act: async (p) => {
await compare(p).getByRole('button', { name: 'Compare on map' }).click();
await expect(compare(p).getByRole('button', { name: 'End comparison' })).toBeVisible();
await settle(p);
},
},
only((p) => card(p).getByTestId('tour-activity').locator('footer')),
],
},
'tour-origin-check': {
guide: guide('tour-origin-check'),
// The imported Seceda walk first, then the hut trek, which was drawn in TREK.
start: async (p) => {
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.recorded);
},
steps: [
only((p) => section(p, 'Origin').getByText('Source', { exact: true }).locator('xpath=..')),
only((p) => section(p, 'Origin')),
{
prepare: async (p) => {
await openTour(p, TOUR_NAMES.hutToHut);
},
target: (p) => section(p, 'Origin'),
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
fx = await ensureToursFixtures(request);
if (fx.collectionId === null)
throw new Error(`no collection "${TOUR_COLLECTION}": ensureToursFixtures could not make it`);
await ensureTourDetailFixtures(request, fx.tripId);
});
// The recording is one more tour in a trip the other screens photograph.
test.afterAll(async ({ request }) => {
await removeInariRecording(request);
});
test('every registered tour card guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourDetailContext.guides].sort());
});
test('hero: trip-tour-detail', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourDetailContext.id, async (p) => {
await openTourTrip(p, { day: 4 });
await openTour(p, TOUR_NAMES.hutToHut);
});
});
for (const id of tripTourDetailContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+513
View File
@@ -0,0 +1,513 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourHikeContext, tripTourHikeGuides } from '../../src/help/contexts/tripTourHike';
import type { HelpGuide } from '../../src/help/types';
import {
addTripPlaces,
deleteToursNamed,
editorRail,
forgetDraft,
openDetails,
openTour,
openToursTab,
pickTourType,
routeSettled,
SAVED_BY_GUIDES,
startNewTour,
toursRail,
} from './fixtures-trip-tour-hike';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, TOUR_NAMES, TOUR_PLACES } from './tour-fixtures';
/**
* Actions for hikes and runs in the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourHike.ts`. They run on "Summer in the Dolomites"
* from `tour-fixtures.ts` (the hut trek, the via ferrata hike and the railway
* run) and, where a dense web of paths matters, on the Fushimi Inari hike of
* the seeded Japan trip. The two plan guides save a tour through the UI and
* delete it again by name in `cleanup`; every other guide changes the open
* tour in the editor only and never saves, so a fresh page starts from the
* saved tour again.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourHikeGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
// ── Where things are ──────────────────────────────────────────────────────────
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
/** The Add point and Trip places pair under the waypoints, one quiet bar. */
const addRow = (page: Page) =>
page.getByTestId('tour-planner-add-point').locator('xpath=ancestor::div[contains(@class,"bg-surface-tertiary")][1]');
const waysCard = (page: Page) => page.getByTestId('hike-ways-card');
const waysSwitch = (page: Page) => waysCard(page).getByRole('button', { name: 'Show along the route', exact: true });
const facetButton = (page: Page, name: 'SAC' | 'Surface' | 'Way type') =>
page.getByRole('group', { name: 'What to show', exact: true }).getByRole('button', { name, exact: true });
/** The first class row of the known ways, the most of the route first. */
const firstKnownRow = (page: Page) => waysCard(page).locator('ul').first().getByRole('button').first();
const waypointRow = (page: Page, n: number) => page.getByTestId('tour-planner-waypoint-row').nth(n);
const openMenu = (page: Page) => page.getByRole('menu');
const ferrataIcons = (page: Page) => page.locator('.trek-ferrata-icon');
const ferrataCard = (page: Page) => page.getByTestId('ferrata-detail-card');
// ── Getting there ─────────────────────────────────────────────────────────────
/** Switch Trail details on and wait until the ways along the line have been read. */
async function showWays(page: Page): Promise<void> {
if ((await waysSwitch(page).getAttribute('aria-pressed')) !== 'true') await waysSwitch(page).click();
await expect(page.getByTestId('sac-known').or(page.getByTestId('surface-known'))).toBeVisible({ timeout: 120_000 });
await settle(page);
}
/** Click Save and wait for the toast that says so. */
async function saveTour(page: Page): Promise<void> {
await saveButton(page).click();
await expect(page.getByText('Tour saved', { exact: true }).first()).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/**
* The index of the via ferrata Brigata Tridentina's sign among the ones drawn,
* picked by its card: the Sella group has several ferratas in one view. Only a
* sign between the two columns counts, as a click on one under a column lands
* on the column. Falls back to the first sign that is clear of both.
*/
let ferrataIndex = 0;
async function findTridentina(page: Page): Promise<void> {
const left = await editorRail(page).boundingBox();
const right = await toursRail(page).boundingBox();
const minX = (left ? left.x + left.width : 0) + 24;
const maxX = (right ? right.x : VIEWPORT.width) - 24;
const count = await ferrataIcons(page).count();
let fallback = -1;
for (let i = 0; i < count; i++) {
const box = await ferrataIcons(page).nth(i).boundingBox();
if (!box || box.x < minX || box.x + box.width > maxX || box.y < 80 || box.y > VIEWPORT.height - 120) continue;
if (fallback < 0) fallback = i;
await ferrataIcons(page).nth(i).click();
await expect(ferrataCard(page)).toBeVisible();
const name = (await ferrataCard(page).locator('h3').textContent()) ?? '';
await ferrataCard(page).getByRole('button', { name: 'Close', exact: true }).click();
await expect(ferrataCard(page)).toHaveCount(0);
await page.waitForTimeout(300);
if (/tridentina/i.test(name)) {
ferrataIndex = i;
return;
}
}
if (fallback < 0)
throw new Error('no via ferrata sign between the columns: the map is not framed on the Sella group');
ferrataIndex = fallback;
}
// ── The scripts ───────────────────────────────────────────────────────────────
let summerTripId = 0;
let japanTripId = 0;
async function openSummer(page: Page): Promise<void> {
summerTripId = await openToursTab(page, 'summer');
}
async function openJapan(page: Page): Promise<void> {
japanTripId = await openToursTab(page, 'japan');
}
/** The stop the compare-ways guide asks from, found by `legWithOtherWays`. */
let waysFrom = 1;
/**
* The first stop, from the Yotsutsuji crossing on, whose next leg the router
* offers another way for. What the public router knows changes with its data,
* so a fixed leg can come back with "no other way" on one day and two on the next.
*/
async function legWithOtherWays(page: Page): Promise<number> {
const card = page.getByTestId('leg-variants-card');
const rows = await page.getByTestId('tour-planner-waypoint-row').count();
const order = [1, ...Array.from({ length: Math.max(0, rows - 1) }, (_, i) => i).filter((i) => i !== 1)];
for (const i of order) {
await waypointRow(page, i).hover();
await waypointRow(page, i).getByRole('button', { name: 'More actions', exact: true }).click();
const compare = openMenu(page).getByRole('button', { name: 'Compare ways for the next leg', exact: true });
if (!(await compare.isVisible().catch(() => false))) {
await page.keyboard.press('Escape');
continue;
}
await compare.click();
const way = page.getByRole('radio', { name: 'Way A', exact: true });
const none = card.getByText(/knows no other way|did not answer/);
await expect(way.or(none)).toBeVisible({ timeout: 120_000 });
const found = await way.isVisible();
await card.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(card).toHaveCount(0);
if (found) return i;
}
throw new Error('the router knows no other way for any leg of the Inari hike');
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-hike-plan': {
guide: guide('tour-hike-plan'),
start: openSummer,
steps: [
{
target: (p) => editorRail(p).getByRole('button', { name: 'Plan new tour' }),
act: startNewTour,
},
{
prepare: async (p) => {
await p.getByTestId('tour-type-select').click();
await settle(p);
},
target: (p) => p.getByTestId('tour-type-option-hike'),
act: (p) => pickTourType(p, 'hike'),
},
{
target: (p) => p.locator('#tour-planner-name'),
act: async (p) => {
await p.locator('#tour-planner-name').fill(SAVED_BY_GUIDES.hike);
},
},
{
target: addRow,
act: async (p) => {
// Two trip places stand in for two clicks on the map: they land on real spots every run.
await addTripPlaces(p, TOUR_PLACES.selvaHotel, TOUR_PLACES.firenze);
await routeSettled(p);
},
},
{
prepare: openDetails,
target: (p) => p.locator('#tour-planner-difficulty'),
act: async (p) => {
await p.locator('#tour-planner-difficulty').click();
await p.getByRole('button', { name: /^T3\b/ }).last().click();
await routeSettled(p);
},
},
{
target: saveButton,
act: saveTour,
},
],
cleanup: async (p) => {
await deleteToursNamed(p, summerTripId, SAVED_BY_GUIDES.hike);
await forgetDraft(p, summerTripId);
},
},
'tour-hike-trail-details': {
guide: guide('tour-hike-trail-details'),
start: async (p) => {
await openSummer(p);
await openTour(p, TOUR_NAMES.hutToHut);
},
steps: [
{
target: waysSwitch,
act: showWays,
},
{
target: (p) => p.getByRole('group', { name: 'What to show', exact: true }),
act: async (p) => {
await facetButton(p, 'SAC').click();
await settle(p);
},
},
{
target: (p) => p.getByTestId('sac-known').locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]'),
},
{
target: firstKnownRow,
act: async (p) => {
await firstKnownRow(p).click();
await settle(p);
},
},
{
prepare: async (p) => {
// Back to the whole route before the next facet, as the step before says.
await firstKnownRow(p).click();
await settle(p);
},
target: (p) => facetButton(p, 'Surface'),
act: async (p) => {
await facetButton(p, 'Surface').click();
await expect(p.getByTestId('surface-known')).toBeVisible({ timeout: 120_000 });
await settle(p);
},
},
],
cleanup: (p) => forgetDraft(p, summerTripId),
},
'tour-hike-round': {
guide: guide('tour-hike-round'),
start: async (p) => {
await openJapan(p);
await openTour(p, TOUR_NAMES.inariHike);
},
steps: [
{
target: (p) => p.getByTestId('tour-planner-round-hike'),
act: async (p) => {
await p.getByTestId('tour-planner-round-hike').click();
await expect(p.getByTestId('round-hike-card')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.locator('#round-hike-distance').locator('xpath=ancestor::div[2]'),
act: async (p) => {
await p.locator('#round-hike-distance').fill('6');
await expect(p.getByTestId('round-hike-window')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('round-hike-find'),
act: async (p) => {
await p.getByTestId('round-hike-find').click();
// Many questions to the router, one after the other; the bar goes when the last has come back.
await expect(p.getByRole('radio', { name: 'Round A', exact: true })).toBeVisible({ timeout: 240_000 });
await expect(p.getByTestId('round-hike-card').getByRole('progressbar')).toHaveCount(0, { timeout: 240_000 });
await settle(p);
},
},
{
target: (p) => p.getByRole('radio', { name: 'Round A', exact: true }),
act: async (p) => {
await p.getByRole('radio', { name: 'Round A', exact: true }).click();
await expect(p.getByTestId('round-hike-apply')).toBeEnabled();
await settle(p);
},
},
{
target: (p) => p.getByTestId('round-hike-apply'),
act: async (p) => {
await p.getByTestId('round-hike-apply').click();
await p.getByRole('button', { name: 'Replace', exact: true }).click();
await expect(p.getByText('Round taken', { exact: true })).toBeVisible({ timeout: 30_000 });
await routeSettled(p);
},
},
],
cleanup: (p) => forgetDraft(p, japanTripId),
},
'tour-hike-compare-ways': {
guide: guide('tour-hike-compare-ways'),
start: async (p) => {
await openJapan(p);
await openTour(p, TOUR_NAMES.inariHike);
waysFrom = await legWithOtherWays(p);
},
steps: [
{
// A stop whose next leg the router knows another way for; the Yotsutsuji crossing usually.
prepare: async (p) => {
await waypointRow(p, waysFrom).hover();
await waypointRow(p, waysFrom).getByRole('button', { name: 'More actions', exact: true }).click();
await expect(openMenu(p)).toBeVisible();
},
target: (p) => openMenu(p).getByRole('button', { name: 'Compare ways for the next leg', exact: true }),
act: async (p) => {
await openMenu(p).getByRole('button', { name: 'Compare ways for the next leg', exact: true }).click();
await expect(p.getByTestId('leg-variants-card')).toBeVisible();
await expect(p.getByRole('radio', { name: 'Way A', exact: true })).toBeVisible({ timeout: 120_000 });
await expect(p.getByTestId('leg-variants-card').getByText('Looking for other ways')).toHaveCount(0, {
timeout: 120_000,
});
await settle(p);
},
},
{
target: (p) => p.getByTestId('leg-variants-card'),
},
{
target: (p) => p.getByRole('radio', { name: 'Way A', exact: true }),
act: async (p) => {
await p.getByRole('radio', { name: 'Way A', exact: true }).click();
await expect(p.getByTestId('leg-variants-apply')).toBeEnabled();
await settle(p);
},
},
{
target: (p) => p.getByTestId('leg-variants-apply'),
act: async (p) => {
await p.getByTestId('leg-variants-apply').click();
await expect(p.getByText('Way taken', { exact: true })).toBeVisible({ timeout: 60_000 });
await routeSettled(p);
},
},
],
cleanup: (p) => forgetDraft(p, japanTripId),
},
'tour-ferrata-add': {
guide: guide('tour-ferrata-add'),
start: async (p) => {
await openSummer(p);
// Opening the ferrata hike frames the map on the Sella group, and a new tour without
// points keeps that view. Not through Back to Tours: the neutral rail frames the whole
// trip again, too far out for the via ferrata signs. Add tour on the right goes straight
// from the open tour to a new one.
await openTour(p, TOUR_NAMES.ferrata);
await toursRail(p).getByRole('button', { name: 'Add tour', exact: true }).click();
await p.getByRole('button', { name: 'Plan new tour', exact: true }).last().click();
await expect(editorRail(p).getByRole('heading', { name: 'New tour', exact: true })).toBeVisible({
timeout: 15_000,
});
await pickTourType(p, 'hike');
await settle(p);
},
steps: [
{
target: (p) => p.getByRole('button', { name: 'Via ferratas', exact: true }),
act: async (p) => {
await p.getByRole('button', { name: 'Via ferratas', exact: true }).click();
await expect(p.getByTestId('ferrata-count-badge')).toBeVisible({ timeout: 60_000 });
await expect(p.getByTestId('ferrata-count-spinner')).toHaveCount(0, { timeout: 90_000 });
await expect(ferrataIcons(p).first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
prepare: findTridentina,
target: (p) => ferrataIcons(p).nth(ferrataIndex),
act: async (p) => {
await ferrataIcons(p).nth(ferrataIndex).click();
await expect(ferrataCard(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('ferrata-adopt'),
act: async (p) => {
await p.getByTestId('ferrata-adopt').click();
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(2, { timeout: 15_000 });
await routeSettled(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-ferrata-leg').first(),
},
{
target: (p) => p.getByTestId('tour-ferrata-crossings'),
},
],
cleanup: (p) => forgetDraft(p, summerTripId),
},
'tour-run-plan': {
guide: guide('tour-run-plan'),
start: openSummer,
steps: [
{
prepare: async (p) => {
await startNewTour(p);
await p.getByTestId('tour-type-select').click();
await settle(p);
},
target: (p) => p.getByTestId('tour-type-option-run'),
act: (p) => pickTourType(p, 'run'),
},
{
target: (p) => p.getByTestId('tour-run-profile'),
act: async (p) => {
await p.getByTestId('tour-run-profile').getByRole('button', { name: 'Trail running', exact: true }).click();
await settle(p);
},
},
{
target: (p) => p.locator('#tour-planner-run-pace'),
act: async (p) => {
await p.locator('#tour-planner-run-pace').fill('7:30');
await p.locator('#tour-planner-run-pace').press('Enter');
},
},
{
prepare: async (p) => {
await addTripPlaces(p, TOUR_PLACES.selvaHotel, TOUR_PLACES.firenze);
await routeSettled(p);
await expect(p.getByTestId('tour-run-time-sum')).toBeVisible({ timeout: 30_000 });
},
target: (p) => p.getByTestId('tour-run-time-sum'),
},
{
prepare: async (p) => {
await p.locator('#tour-planner-name').fill(SAVED_BY_GUIDES.run);
},
target: saveButton,
act: saveTour,
},
],
cleanup: async (p) => {
await deleteToursNamed(p, summerTripId, SAVED_BY_GUIDES.run);
await forgetDraft(p, summerTripId);
},
},
'tour-run-ways': {
guide: guide('tour-run-ways'),
start: async (p) => {
await openSummer(p);
await openTour(p, TOUR_NAMES.run);
},
steps: [
{
target: waysSwitch,
act: showWays,
},
{
// The card's head: the unpaved share and the hardest graded path, read once the ways are in.
target: (p) => p.getByRole('region', { name: 'Trail details', exact: true }).locator('> div').first(),
},
{
prepare: async (p) => {
await facetButton(p, 'Surface').click();
await expect(p.getByTestId('surface-known')).toBeVisible({ timeout: 120_000 });
await settle(p);
},
target: firstKnownRow,
act: async (p) => {
await firstKnownRow(p).click();
await settle(p);
},
},
],
cleanup: (p) => forgetDraft(p, summerTripId),
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }, testInfo) => {
// A cold run fetches every route of the Tours demo trips once (minutes), later runs read the cache.
testInfo.setTimeout(600_000);
await ensureToursFixtures(request);
});
test('every registered hike and run guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourHikeContext.guides].sort());
});
test('hero: trip-tour-hike', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourHikeContext.id, async (p) => {
await openSummer(p);
await openTour(p, TOUR_NAMES.ferrata);
});
});
for (const id of tripTourHikeContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
test.setTimeout(600_000);
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+321
View File
@@ -0,0 +1,321 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourHorseContext, tripTourHorseGuides } from '../../src/help/contexts/tripTourHorse';
import type { HelpGuide } from '../../src/help/types';
import {
deleteToursNamed,
finishMapPan,
forgetTourDraft,
horsePlan,
NEW_RIDE,
openSavedRide,
openToursTab,
RECORDED_RIDE,
recordedRideFile,
routeTitle,
serveHorseRoutes,
tourRow,
type HorsePlan,
} from './fixtures-trip-tour-horse';
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures } from './tour-fixtures';
/**
* Actions for horse rides on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourHorse.ts`. They run on "Summer in the Dolomites"
* (`ensureToursFixtures`), with the Seiser Alm in view: the saved "Seiser Alm
* horse ride" for the overview, a new ride built from the horse routes the
* card is given (`horsePlan`, the same answer for every picture), and a ride
* recorded on a GPS device imported from its file. The new ride and the
* import are deleted again in `cleanup`; the gap guide saves nothing, but its
* draft is kept on the account, so its cleanup drops it before the next guide
* of the trip opens the Tours tab.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourHorseGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let plan: HorsePlan;
let rideFile = '';
// ── Where things are ──────────────────────────────────────────────────────────
/** The editor's left column: a section named after what it is for. */
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour' });
const planNewTour = (page: Page) => page.getByRole('button', { name: 'Plan new tour', exact: true });
const typeSelect = (page: Page) => page.getByTestId('tour-type-select');
const horseOption = (page: Page) => page.getByTestId('tour-type-option-horse');
const horseCard = (page: Page) => page.getByRole('region', { name: 'Horse routes' });
const findButton = (page: Page) =>
page.getByTestId('horse-routes-card').getByRole('button', { name: 'Find in map view' });
const routeRow = (page: Page, id: string) => page.getByTestId(`horse-route-${id}`);
/**
* A dialog's accessible name is its eyebrow ("Horse route", "Import preview"),
* not its title, so the two dialogs are found by the title heading they hold.
*/
const dialogTitled = (page: Page, title: string) =>
page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
const routeDialog = (page: Page, id: string) => dialogTitled(page, routeTitle(plan, id));
const sectionChip = (page: Page, n: number) => page.getByTestId(`horse-section-${n}`);
const takeSection = (page: Page) => page.getByTestId('horse-section-adopt');
const joinGapSwitch = (page: Page) => page.getByRole('button', { name: 'Join with a manual section', exact: true });
const horseLeg = (page: Page) => page.getByTestId('tour-planner-horse-leg').first();
const manualLeg = (page: Page) => page.getByTestId('tour-planner-free-leg').first();
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
const previewDialog = (page: Page) => dialogTitled(page, RECORDED_RIDE.file);
const recordedCard = (page: Page) => page.getByRole('region', { name: 'Recorded ride' });
// ── Getting there ─────────────────────────────────────────────────────────────
/** Plan new tour, and the type set to Horse ride. */
async function startRide(page: Page): Promise<void> {
await planNewTour(page).click();
await expect(typeSelect(page)).toBeVisible({ timeout: 15_000 });
await typeSelect(page).click();
await horseOption(page).click();
await expect(horseCard(page)).toBeVisible();
await settle(page);
}
/**
* Find in map view, and the list. A view the card finds too large says so
* instead of searching; then the map is zoomed in once over its middle and the
* button pressed again, which is what the notice asks of the reader too.
*/
async function findRoutes(page: Page): Promise<void> {
const zoomNotice = page.getByText('Zoom in further to look for horse routes.');
// The planner opens around zoom 11, near a degree wide; a search may span half of one.
for (let attempt = 0; attempt < 5; attempt++) {
await findButton(page).click();
await expect(page.getByTestId('horse-routes-list').or(zoomNotice)).toBeVisible({ timeout: 45_000 });
if (await page.getByTestId('horse-routes-list').isVisible()) break;
const box = await page.getByTestId('tour-map-surface').boundingBox();
if (!box) throw new Error('the planner map has no box');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -480);
await finishMapPan(page);
}
await expect(page.getByTestId('horse-routes-list')).toBeVisible();
await settle(page);
}
async function openRoute(page: Page, id: string): Promise<void> {
await routeRow(page, id).click();
await expect(routeDialog(page, id)).toBeVisible({ timeout: 15_000 });
await settle(page);
}
async function pickSection(page: Page, n: number): Promise<void> {
await sectionChip(page, n).click();
await expect(sectionChip(page, n)).toHaveAttribute('aria-pressed', 'true');
await settle(page);
}
/** Take the open dialog's section and wait for its badge in the Waypoints card. */
async function adopt(page: Page, id: string): Promise<void> {
await takeSection(page).click();
await expect(routeDialog(page, id)).toHaveCount(0);
await expect(horseLeg(page)).toBeVisible({ timeout: 15_000 });
await settle(page);
}
/** The gap guide's start: a ride holding its first section, and the route with the gap open. */
async function rideWithFirstSection(page: Page): Promise<void> {
await serveHorseRoutes(page, plan);
await openToursTab(page);
await startRide(page);
await findRoutes(page);
await openRoute(page, plan.first.routeId);
await pickSection(page, plan.first.section);
await adopt(page, plan.first.routeId);
await openRoute(page, plan.gap.routeId);
}
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-horse-plan': {
guide: guide('tour-horse-plan'),
start: async (p) => {
await deleteToursNamed(p.request, NEW_RIDE);
await serveHorseRoutes(p, plan);
await openToursTab(p);
},
steps: [
{
target: planNewTour,
act: async (p) => {
await planNewTour(p).click();
await expect(typeSelect(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await typeSelect(p).click();
await expect(horseOption(p)).toBeVisible();
await settle(p);
},
target: horseOption,
act: async (p) => {
await horseOption(p).click();
await expect(horseCard(p)).toBeVisible();
await settle(p);
},
},
{ target: findButton, act: findRoutes },
{
target: (p) => routeRow(p, plan.first.routeId),
act: (p) => openRoute(p, plan.first.routeId),
},
{
prepare: (p) => pickSection(p, plan.first.section),
target: (p) => routeDialog(p, plan.first.routeId),
// The pointer on the section chosen, so its tooltip names what the tags say along it.
hover: async (p) => {
await sectionChip(p, plan.first.section).hover();
},
},
{
target: takeSection,
act: (p) => adopt(p, plan.first.routeId),
},
{
prepare: async (p) => {
await typeInto(p, p.locator('#tour-planner-name'), NEW_RIDE);
await p.locator('#tour-planner-name').press('Tab');
// Heights are asked for the line before a ride can be saved.
await expect(saveButton(p)).toBeEnabled({ timeout: 60_000 });
await settle(p);
},
target: saveButton,
act: async (p) => {
await saveButton(p).click();
await expect(p.getByText('Tour saved')).toBeVisible({ timeout: 60_000 });
await expect(tourRow(p, NEW_RIDE)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: (p) => deleteToursNamed(p.request, NEW_RIDE),
},
'tour-horse-gap': {
guide: guide('tour-horse-gap'),
start: rideWithFirstSection,
steps: [
{
prepare: (p) => pickSection(p, plan.gap.section),
target: (p) => p.getByTestId('horse-section-gap'),
},
{
target: joinGapSwitch,
act: async (p) => {
await joinGapSwitch(p).click();
await expect(joinGapSwitch(p)).toHaveAttribute('aria-pressed', 'true');
await expect(takeSection(p)).toBeEnabled();
await settle(p);
},
},
{
target: takeSection,
act: async (p) => {
await takeSection(p).click();
await expect(routeDialog(p, plan.gap.routeId)).toHaveCount(0);
await expect(manualLeg(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{ target: manualLeg },
],
cleanup: (p) => forgetTourDraft(p.request),
},
'tour-horse-recorded': {
guide: guide('tour-horse-recorded'),
start: async (p) => {
await deleteToursNamed(p.request, RECORDED_RIDE.name);
await openToursTab(p);
},
steps: [
{
target: (p) => p.getByTestId('tour-rail-add'),
act: async (p) => {
await p.getByTestId('tour-rail-add').click();
const entry = p.getByRole('button', { name: 'Import GPX or KML', exact: true });
await expect(entry).toBeVisible();
// The file picker of the operating system cannot be photographed; its chooser is answered instead.
const chooser = p.waitForEvent('filechooser');
await entry.click();
await (await chooser).setFiles(rideFile);
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
await expect(previewDialog(p).getByTestId('gpx-preview-track').first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await previewDialog(p).getByTestId('tour-type-select').click();
await horseOption(p).click();
await expect(previewDialog(p).getByTestId('gpx-preview-horse')).toBeVisible();
await settle(p);
},
target: (p) => previewDialog(p).getByTestId('gpx-preview-horse'),
},
{
target: (p) => previewDialog(p).getByTestId('gpx-preview-import'),
act: async (p) => {
await previewDialog(p).getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
await expect(recordedCard(p)).toBeVisible({ timeout: 60_000 });
await finishMapPan(p);
},
},
{
target: recordedCard,
// On the badge with the most to say, so the picture carries its tooltip.
hover: async (p) => {
await recordedCard(p).getByText('Access not checked', { exact: true }).hover();
},
},
],
cleanup: (p) => deleteToursNamed(p.request, RECORDED_RIDE.name),
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
test.setTimeout(600_000);
await ensureToursFixtures(request);
plan = await horsePlan(request);
console.log(`[trip-tour-horse] horse routes: ${plan.source}`);
rideFile = await recordedRideFile();
});
test.afterAll(async ({ request }) => {
// Here as well as in the cleanups, so a failed run leaves no ride behind.
await deleteToursNamed(request, NEW_RIDE, RECORDED_RIDE.name);
await forgetTourDraft(request);
});
test('every registered horse ride guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourHorseContext.guides].sort());
});
test('hero: trip-tour-horse', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourHorseContext.id, async (p) => {
await serveHorseRoutes(p, plan);
await openSavedRide(p);
});
});
for (const id of tripTourHorseContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+415
View File
@@ -0,0 +1,415 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourImportContext, tripTourImportGuides } from '../../src/help/contexts/tripTourImport';
import type { HelpGuide } from '../../src/help/types';
import {
deleteToursNamed,
disconnectWanderer,
ensureWandererConnection,
forgetTourDraft,
IMPORTED_NAME,
importFile,
RECORDED_NAME,
restoreRecordedTrack,
tourNamed,
tripIdByTitle,
WANDERER_TRAIL,
} from './fixtures-trip-tour-import';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript, type StepAction } from './guide';
import { ensureToursFixtures, openTourTrip, SKI_TRIP, TOUR_NAMES, TOUR_TRIP } from './tour-fixtures';
import { confirmDialog } from './trip-shared';
/**
* Actions for getting tours into a trip, keyed by the ids in
* `src/help/contexts/tripTourImport.ts`. They run on the Tours tab of
* "Summer in the Dolomites" (`ensureToursFixtures`): a GPX file of the
* Rasciesa balcony walk goes in through the Import preview, the recorded
* Seceda ridge walk that the fixtures imported is read, planned over and put
* back, a run of that trip is taken over into the ski week, and the Rasciesa
* walk comes in once more from a wanderer instance. What a guide adds it
* deletes again in `cleanup`, and the Seceda walk is always given its
* recorded track back, because the fixtures never rebuild a tour.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourImportGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
// ── Where things are ──────────────────────────────────────────────────────────
/** The right column of the Tours tab. */
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
const addTour = (page: Page) => page.getByTestId('tour-rail-add');
/** The menu the + opens; a portal on the body, labelled like its button. */
const addMenuItem = (page: Page, label: string) =>
page.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: label, exact: true });
/** The left column while an import is shown: a section named after the tour. */
const importRail = (page: Page, name: string) => page.getByRole('region', { name, exact: true });
const railCard = (page: Page, rail: string, card: string) =>
importRail(page, rail).getByRole('region', { name: card, exact: true });
/** The left column while a tour is edited (and while nothing is open). */
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
const previewDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Import preview' });
const previewSection = (page: Page, has: 'tracks' | 'as') =>
has === 'tracks'
? previewDialog(page)
.locator('section')
.filter({ has: page.getByTestId('gpx-preview-track') })
: previewDialog(page)
.locator('section')
.filter({ has: page.getByTestId('tour-type-select') });
/**
* The take-over dialog. Its header labels it by the eyebrow ("Take over a
* tour"), not by the title, so it is found by the list section the title
* names, which stays right whichever header line ends up as the label.
*/
const library = (page: Page) =>
page.getByRole('dialog').filter({ has: page.getByRole('region', { name: 'From another trip', exact: true }) });
const wandererRail = (page: Page) => page.getByRole('region', { name: 'wanderer', exact: true });
// ── Getting there ─────────────────────────────────────────────────────────────
/** The Tours tab of the Dolomites trip (or the ski week), with its list of tours in. */
async function openTours(page: Page, trip: 'summer' | 'ski' = 'summer'): Promise<void> {
await openTourTrip(page, { trip, tab: 'tour-planner' });
await expect(toursRail(page)).toBeVisible({ timeout: 30_000 });
await expect(toursRail(page).getByRole('option').first()).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/** The + and its menu, as the first two steps of every way in show them. */
function addMenuSteps(entry: string, act: (page: Page) => Promise<void>): StepAction[] {
return [
{
target: addTour,
act: async (p) => {
await addTour(p).click();
await expect(addMenuItem(p, entry)).toBeVisible();
await settle(p);
},
},
{ target: (p) => addMenuItem(p, entry), act },
];
}
/** Picks the Rasciesa GPX in the file chooser the menu entry opens, and waits for the preview. */
async function chooseImportFile(page: Page): Promise<void> {
const file = await importFile();
const chooser = page.waitForEvent('filechooser');
await addMenuItem(page, 'Import GPX or KML').click();
await (await chooser).setFiles(file);
await expect(previewDialog(page)).toBeVisible({ timeout: 30_000 });
await expect(previewDialog(page).getByTestId('gpx-preview-track').first()).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/** Opens the recorded Seceda walk from the list: an import is shown as it came in. */
async function viewRecorded(page: Page): Promise<void> {
await tourRow(page, RECORDED_NAME).click();
await expect(railCard(page, RECORDED_NAME, 'Tour summary')).toBeVisible({ timeout: 20_000 });
await settle(page);
}
/** Asks for the editable version and waits until the router has answered and the card compares. */
async function analyse(page: Page): Promise<void> {
await railCard(page, RECORDED_NAME, 'Editable version')
.getByRole('button', { name: 'Plan an editable version' })
.click();
await expect(page.getByRole('button', { name: 'Replace the import' })).toBeVisible({ timeout: 120_000 });
await settle(page);
}
/** Takes the suggestion over the import in place; the editor opens and has to route before it can save. */
async function replaceImport(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Replace the import' }).click();
await expect(editorRail(page).getByRole('button', { name: 'Save', exact: true })).toBeEnabled({ timeout: 120_000 });
await settle(page);
}
async function saveEditor(page: Page): Promise<void> {
await editorRail(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Tour saved').first()).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** Puts the Dolomites trip back as the fixtures left it: no Rasciesa tour, the Seceda walk as recorded, no draft. */
async function resetSummer(page: Page): Promise<void> {
const tripId = await tripIdByTitle(page.request, TOUR_TRIP);
await deleteToursNamed(page.request, tripId, IMPORTED_NAME, WANDERER_TRAIL);
await restoreRecordedTrack(page.request, tripId, RECORDED_NAME);
await forgetTourDraft(page.request, tripId);
}
// ── Scripts ───────────────────────────────────────────────────────────────────
const TAKEN_OVER_NAME = 'Morning run on the railway trail';
const SCRIPTS: Record<string, GuideScript> = {
'tour-import-gpx': {
guide: guide('tour-import-gpx'),
start: async (p) => {
await resetSummer(p);
await openTours(p);
},
steps: [
...addMenuSteps('Import GPX or KML', chooseImportFile),
{ target: (p) => previewSection(p, 'tracks') },
// Hike is preset, and the Rasciesa walk is one: the step shows the field and leaves it.
{ target: (p) => previewSection(p, 'as') },
{
target: (p) => p.getByTestId('gpx-preview-import'),
act: async (p) => {
await p.getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
// One track: it opens on the map at once, as a GPX tour.
await expect(railCard(p, IMPORTED_NAME, 'Tour summary')).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, IMPORTED_NAME)).toBeVisible();
await settle(p);
},
},
],
cleanup: resetSummer,
},
'tour-view-import': {
guide: guide('tour-view-import'),
start: async (p) => {
await resetSummer(p);
await openTours(p);
},
steps: [
{ target: (p) => tourRow(p, RECORDED_NAME), act: viewRecorded },
{ target: (p) => railCard(p, RECORDED_NAME, 'Tour summary') },
{
target: (p) => importRail(p, RECORDED_NAME).getByRole('button', { name: 'Back to Tours' }),
act: async (p) => {
await importRail(p, RECORDED_NAME).getByRole('button', { name: 'Back to Tours' }).click();
await expect(importRail(p, RECORDED_NAME)).toHaveCount(0);
await settle(p);
},
},
],
},
'tour-plan-from-import': {
guide: guide('tour-plan-from-import'),
start: async (p) => {
await resetSummer(p);
await openTours(p);
await viewRecorded(p);
},
steps: [
{
target: (p) =>
railCard(p, RECORDED_NAME, 'Editable version').getByRole('button', { name: 'Plan an editable version' }),
act: analyse,
},
{ target: (p) => railCard(p, RECORDED_NAME, 'Editable version') },
{ target: (p) => p.getByRole('button', { name: 'Replace the import' }), act: replaceImport },
{ target: (p) => editorRail(p).getByRole('button', { name: 'Save', exact: true }), act: saveEditor },
],
cleanup: resetSummer,
},
'tour-restore-recorded': {
guide: guide('tour-restore-recorded'),
// The walk is planned over first, the way the guide before this one does
// it, then the editor is closed so step 1 opens it from the list.
start: async (p) => {
await resetSummer(p);
await openTours(p);
await viewRecorded(p);
await analyse(p);
await replaceImport(p);
await saveEditor(p);
await editorRail(p).getByRole('button', { name: 'Back to Tours' }).click();
await expect(editorRail(p).getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 20_000 });
await settle(p);
},
steps: [
{
prepare: async (p) => {
await tourRow(p, RECORDED_NAME).click();
await expect(editorRail(p).getByRole('region', { name: 'Recorded track', exact: true })).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
target: (p) => editorRail(p).getByRole('region', { name: 'Recorded track', exact: true }),
},
{
target: (p) => editorRail(p).getByRole('button', { name: 'Restore recorded track' }),
act: async (p) => {
await editorRail(p).getByRole('button', { name: 'Restore recorded track' }).click();
await expect(confirmDialog(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => confirmDialog(p).getByRole('button', { name: 'Restore recorded track' }),
act: async (p) => {
await confirmDialog(p).getByRole('button', { name: 'Restore recorded track' }).click();
await expect(p.getByText('Recorded track restored').first()).toBeVisible({ timeout: 30_000 });
// It comes back as a GPX tour, shown as it was recorded.
await expect(railCard(p, RECORDED_NAME, 'Tour summary')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: resetSummer,
},
'tour-take-over': {
guide: guide('tour-take-over'),
start: async (p) => {
const skiId = await tripIdByTitle(p.request, SKI_TRIP);
await deleteToursNamed(p.request, skiId, TAKEN_OVER_NAME, TOUR_NAMES.run);
await forgetTourDraft(p.request, skiId);
await openTours(p, 'ski');
},
steps: [
...addMenuSteps('Take over a tour from another trip', async (p) => {
await addMenuItem(p, 'Take over a tour from another trip').click();
await expect(library(p)).toBeVisible();
await expect(library(p).getByTestId('tour-library-list')).toBeVisible({ timeout: 30_000 });
await settle(p);
}),
{
target: (p) => library(p).getByRole('region', { name: 'From another trip' }),
act: async (p) => {
await library(p).getByPlaceholder('Search by name').fill('railway');
await expect(
library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run })
).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run }),
act: async (p) => {
await library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run }).click();
await expect(library(p).getByTestId('tour-library-preview')).toBeVisible({ timeout: 20_000 });
await expect(library(p).getByTestId('tour-library-confirm')).toBeEnabled({ timeout: 20_000 });
await p.waitForTimeout(800); // the preview's line
await settle(p);
},
},
{
target: (p) => library(p).getByLabel('Name in this trip'),
act: async (p) => {
await library(p).getByLabel('Name in this trip').fill(TAKEN_OVER_NAME);
await settle(p);
},
},
{
target: (p) => library(p).getByTestId('tour-library-confirm'),
act: async (p) => {
await library(p).getByTestId('tour-library-confirm').click();
await expect(library(p)).toHaveCount(0, { timeout: 30_000 });
await expect(tourRow(p, TAKEN_OVER_NAME)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
const skiId = await tripIdByTitle(p.request, SKI_TRIP);
await deleteToursNamed(p.request, skiId, TAKEN_OVER_NAME);
await forgetTourDraft(p.request, skiId);
},
},
'tour-import-wanderer': {
guide: guide('tour-import-wanderer'),
// Connected here and taken away in cleanup: with a connection the neutral
// rail grows the wanderer button, which no other Tours picture shows.
start: async (p) => {
await resetSummer(p);
await ensureWandererConnection(p.request);
await openTours(p);
},
steps: [
{
target: (p) => p.getByTestId('tour-rail-open-wanderer'),
act: async (p) => {
await p.getByTestId('tour-rail-open-wanderer').click();
await expect(wandererRail(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => wandererRail(p).getByRole('textbox', { name: 'Search trails' }),
act: async (p) => {
await wandererRail(p).getByRole('textbox', { name: 'Search trails' }).fill('Rasciesa');
await expect(wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL })).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
},
{
target: (p) => wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL }),
act: async (p) => {
await wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL }).click();
await expect(wandererRail(p).getByRole('button', { name: 'Import into trip' })).toBeEnabled({
timeout: 30_000,
});
await settle(p);
},
},
{
target: (p) => wandererRail(p).getByRole('button', { name: 'Import into trip' }),
act: async (p) => {
await wandererRail(p).getByRole('button', { name: 'Import into trip' }).click();
await expect(wandererRail(p)).toHaveCount(0, { timeout: 60_000 });
await expect(railCard(p, WANDERER_TRAIL, 'Tour summary')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
await resetSummer(p);
await disconnectWanderer(p.request);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
const tripId = await tripIdByTitle(request, TOUR_TRIP);
if (!(await tourNamed(request, tripId, RECORDED_NAME)))
throw new Error(`"${RECORDED_NAME}" is missing: the fixtures could not import it`);
});
test.afterAll(async ({ request }) => {
// Instance-wide: left on, every Tours tab would offer wanderer. Here as well
// as in the guide's cleanup, so a failed run puts it back too.
await disconnectWanderer(request);
});
test('every registered tour import guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourImportContext.guides].sort());
});
// The screen as a reader meets it: the Import preview of a recorded walk over the Tours tab.
test('hero: trip-tour-import', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourImportContext.id, async (p) => {
await resetSummer(p);
await openTours(p);
await addTour(p).click();
await chooseImportFile(p);
});
});
for (const id of tripTourImportContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+385
View File
@@ -0,0 +1,385 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourMapContext, tripTourMapGuides } from '../../src/help/contexts/tripTourMap';
import type { HelpGuide } from '../../src/help/types';
import {
ALONG_TOUR,
alongCard,
alongCategories,
alongCategoryList,
alongCorridor,
alongList,
alongSearch,
baseSegment,
candidateRow,
closeOverlay,
enterTerrain,
gradeBands,
gradeMix,
gradeSwitch,
MAP_TOUR,
openSkiTour,
openTourInPlanner,
overviewButton,
PADDLE_TOUR,
pauseButton,
pickAddablePlace,
pisteKeyButton,
pisteKeyPanel,
pistesToggle,
playButton,
poiAdd,
poiCard,
poiOutcome,
poiSlot,
poiUndo,
reliefSlider,
restartButton,
seamarksToggle,
searchAlong,
speedGroup,
terrainToggle,
tourRow,
waitForFlyover,
} from './fixtures-trip-tour-map';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip } from './tour-fixtures';
/**
* Actions for the map of the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourMap.ts`. They run on the Dolomites trip of the
* Tours fixtures (the Pisciadù hike for the maps and 3D, the Puez-Odle hut
* trek for the places along it, the boat tour on Lago di Braies for the
* seamarks) and on the ski week for the pistes. No guide saves anything: the
* base map, the overlays and 3D are the planner's own view, and the stop taken
* in along the tour stays an unsaved change of the draft. Drafts live on the
* account now, one per trip, so that guide discards its draft again before it
* ends, or the next guide on the Dolomites would open the hut trek restored.
*
* Every picture here draws live tiles (OpenTopoMap, OpenHikingMap, Esri,
* OpenSnowMap, OpenSeaMap, the Mapzen terrain), the places along the tour come
* from Overpass and TREK's places index, and a new stop is routed by Valhalla.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourMapGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
/** The place the add-stop guide takes in, found in its `start`: an index among the places not in the tour yet. */
let addable = 0;
/** The speed the flyover guide picks; slow enough that Pause is still there when its picture is taken. */
const SPEED = '2×';
/**
* The 3D map's own control row. The flat map's vector base map draws on a
* MapLibre canvas of its own, so a canvas alone cannot tell 3D from flat.
*/
const terrainDock = (page: Page) => page.getByTestId('terrain-control-row');
/**
* Search along the tour with Summits ticked beside the three kinds a hike
* starts with, 1 km either side. Viewpoints, huts and water are asked of
* Overpass alone, whose public mirrors are often down; summits come from
* TREK's places index as well, so the list has places even then.
*/
async function searchAlongWithSummits(page: Page): Promise<void> {
await alongCard(page).scrollIntoViewIfNeeded();
await alongCategories(page).click();
await expect(alongCategoryList(page)).toBeVisible();
await alongCategoryList(page).getByRole('button', { name: 'Summits', exact: true }).click();
await closeOverlay(page);
await expect(alongCategoryList(page)).toBeHidden();
// 250 m, 500 m, 1 km, 2 km: the third.
await alongCorridor(page).getByRole('button').nth(2).click();
await searchAlong(page);
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-map-layers': {
guide: guide('tour-map-layers'),
start: (p) => openTourTrip(p, { tab: 'tour-planner' }).then(() => undefined),
steps: [
{
target: (p) => tourRow(p, MAP_TOUR),
act: async (p) => {
await tourRow(p, MAP_TOUR).click();
await expect(tourRow(p, MAP_TOUR)).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
await expect(p.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
},
{
target: (p) => baseSegment(p, 'Topo'),
act: async (p) => {
await baseSegment(p, 'Topo').click();
await expect(baseSegment(p, 'Topo')).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
{
// Only there while the admin allows OpenHikingMap: the Tours fixtures switch tours-hiking-map on.
target: (p) => baseSegment(p, 'Hiking'),
act: async (p) => {
await baseSegment(p, 'Hiking').click();
await expect(baseSegment(p, 'Hiking')).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
],
},
'tour-along-search': {
guide: guide('tour-along-search'),
start: (p) => openTourInPlanner(p, ALONG_TOUR),
steps: [
{ target: (p) => alongCard(p) },
{
prepare: async (p) => {
await alongCategories(p).click();
await expect(alongCategoryList(p)).toBeVisible();
},
target: (p) => alongCategoryList(p),
act: async (p) => {
// A hike starts with Viewpoints, Huts and shelters and Drinking water; Summits joins them.
await alongCategoryList(p).getByRole('button', { name: 'Summits', exact: true }).click();
await closeOverlay(p);
await expect(alongCategoryList(p)).toBeHidden();
},
},
{
target: (p) => alongCorridor(p),
// 250 m, 500 m, 1 km, 2 km: the third.
act: (p) => alongCorridor(p).getByRole('button').nth(2).click(),
},
{
target: (p) => alongSearch(p),
act: async (p) => {
await alongSearch(p).click();
await expect(alongSearch(p)).toHaveText(/Search again/, { timeout: 90_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await expect(
alongList(p),
'the search along the tour found nothing (Overpass or the places index)'
).toBeVisible();
},
target: (p) => alongList(p),
},
],
},
'tour-along-add-stop': {
guide: guide('tour-along-add-stop'),
start: async (p) => {
await openTourInPlanner(p, ALONG_TOUR);
await searchAlongWithSummits(p);
addable = await pickAddablePlace(p);
},
steps: [
{
target: (p) => candidateRow(p, addable),
act: async (p) => {
await candidateRow(p, addable).click();
await expect(poiCard(p)).toBeVisible();
await settle(p);
},
},
{ target: (p) => poiSlot(p) },
{
target: (p) => poiAdd(p),
act: async (p) => {
await poiAdd(p).click();
// "is a stop now. The tour changes by:" once Valhalla planned it, or the line that it could not be told.
await expect(poiOutcome(p)).toHaveText(/changes by|could not be worked out/, { timeout: 90_000 });
await settle(p);
},
},
{ target: (p) => poiOutcome(p) },
],
// Nothing was saved, but the draft with the new stop sits on the account: leaving the planner and
// discarding it deletes it there, so the next guide on this trip opens the saved tours.
cleanup: async (p) => {
if (await poiUndo(p).isVisible()) await poiUndo(p).click();
const back = p.getByRole('button', { name: 'Back to Tours', exact: true });
if (!(await back.isVisible())) return;
const deleted = p
.waitForResponse((r) => r.url().includes('/tour-draft') && r.request().method() === 'DELETE', {
timeout: 15_000,
})
.catch(() => null);
await back.click();
const discard = p.getByRole('button', { name: 'Discard changes', exact: true });
// The dialog only asks while the planner holds unsaved changes; without them Back leaves at once.
await discard.waitFor({ state: 'visible', timeout: 5_000 }).then(
() => discard.click(),
() => undefined
);
await deleted;
},
},
'tour-3d-terrain': {
guide: guide('tour-3d-terrain'),
start: (p) => openTourInPlanner(p, MAP_TOUR),
steps: [
{ target: (p) => terrainToggle(p), act: (p) => enterTerrain(p) },
{
target: (p) => gradeSwitch(p),
// Into the tour's colour and back to the grades, as the step tells.
act: async (p) => {
await gradeSwitch(p).click();
await expect(gradeSwitch(p)).toHaveAttribute('aria-pressed', 'false');
await settle(p);
await gradeSwitch(p).click();
await expect(gradeSwitch(p)).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
{
target: (p) => gradeMix(p),
act: async (p) => {
await gradeMix(p).click();
await expect(gradeBands(p)).toBeVisible();
},
},
{
target: (p) => reliefSlider(p),
// From 1.0× to 2.0×, ten steps of 0.1: a range input is moved by its keys, not filled.
act: async (p) => {
await reliefSlider(p).focus();
for (let i = 0; i < 10; i++) await p.keyboard.press('ArrowRight');
await settle(p);
},
},
{
target: (p) => terrainToggle(p),
act: async (p) => {
await terrainToggle(p).click();
await expect(terrainDock(p)).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
],
},
'tour-3d-flyover': {
guide: guide('tour-3d-flyover'),
start: async (p) => {
await openTourInPlanner(p, MAP_TOUR);
await enterTerrain(p);
await waitForFlyover(p);
await settle(p);
},
steps: [
{
target: (p) => playButton(p),
// The loop: the pointer on Play flyover, the click, then about five seconds of
// the camera following the dot. Only the browser's Date is pinned, so the
// flyover's animation frames run on their own clock.
clip: async (p, frame) => {
await playButton(p).hover();
await frame();
await frame();
await playButton(p).click();
await expect(pauseButton(p)).toBeVisible();
for (let i = 0; i < 24; i++) {
await p.waitForTimeout(200);
await frame();
}
},
},
{
target: (p) => speedGroup(p),
act: (p) => speedGroup(p).getByRole('button', { name: SPEED, exact: true }).click(),
},
{
// Back to the start and in flight again, so Pause is on the dock whatever the last step left.
prepare: async (p) => {
await restartButton(p).click();
await expect(pauseButton(p)).toBeVisible();
},
target: (p) => pauseButton(p),
act: async (p) => {
await pauseButton(p).click();
await expect(playButton(p)).toBeVisible();
},
},
{
target: (p) => overviewButton(p),
act: async (p) => {
await overviewButton(p).click();
await settle(p);
},
},
],
},
'tour-map-overlays': {
guide: guide('tour-map-overlays'),
start: async (p) => {
await openSkiTour(p);
},
steps: [
{
prepare: async (p) => {
// On by default for a ski tour; "on" rather than "down", or the key next to it is missing.
await expect(pistesToggle(p)).toHaveAttribute('data-status', 'on', { timeout: 30_000 });
},
target: (p) => pistesToggle(p),
},
{
target: (p) => pisteKeyButton(p),
act: async (p) => {
await pisteKeyButton(p).click();
await expect(pisteKeyPanel(p)).toBeVisible();
},
},
{
target: (p) => pisteKeyPanel(p),
act: async (p) => {
await closeOverlay(p);
await expect(pisteKeyPanel(p)).toBeHidden();
},
},
{
prepare: (p: Page) => openTourInPlanner(p, PADDLE_TOUR),
target: (p) => seamarksToggle(p),
act: async (p) => {
await seamarksToggle(p).click();
await expect(seamarksToggle(p)).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
// The Dolomites and the ski week with their tours, the hiking map allowed.
await ensureToursFixtures(request);
});
test('every registered tour map guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourMapContext.guides].sort());
});
test('hero: trip-tour-map', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourMapContext.id, (p) => openTourInPlanner(p, MAP_TOUR));
});
for (const id of tripTourMapContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+648
View File
@@ -0,0 +1,648 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourPaddleContext, tripTourPaddleGuides } from '../../src/help/contexts/tripTourPaddle';
import type { HelpGuide } from '../../src/help/types';
import {
CARRY_COPY,
deleteToursWhere,
deleteTripsNamed,
ensureCarryCopy,
ensureWindTrip,
finishMapMove,
forgetPaddleDraft,
forgetPaddleDraftBeforeLoad,
holdPaddleDownloads,
isPaddleImport,
openToursOf,
PADDLE_TOUR,
paddleGpxFixture,
pointOnLine,
restorePaddleDownloads,
summerTripId,
WIND_TRIP,
} from './fixtures-trip-tour-paddle';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip } from './tour-fixtures';
/**
* Actions for paddle tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourPaddle.ts`. They run on "Rowing round Lago di
* Braies", the boat tour of "Summer in the Dolomites" (`ensureToursFixtures`):
* four legs drawn by hand along the shore, Boat Other, Water Lake, 3 km/h.
*
* The editing guides never save: what they change is a draft, which the
* planner keeps on the account and restores on the next page load, so
* `cleanup` discards it and every guide drops it again before it opens its
* page and after it ends, failed or not (`forgetPaddleDraft`). The checklist guide saves on a
* copy (`ensureCarryCopy`), the wind guide works in a short trip from
* tomorrow (`ensureWindTrip`), and the import guide brings in a recorded
* track; each takes its own away again, and `afterAll` does so once more for
* a run that failed half way. No water data downloads while this file runs
* (`holdPaddleDownloads`), so a leg TREK cannot plan stays unplanned and says so.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourPaddleGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let gpxFile = '';
// ── Where things are ──────────────────────────────────────────────────────────
/** The editor's left column, a section named after what it is for. */
const editorRail = (p: Page) => p.getByRole('region', { name: 'Plan a tour' });
const planNewTour = (p: Page) => p.getByRole('button', { name: 'Plan new tour', exact: true });
const typeSelect = (p: Page) => editorRail(p).getByTestId('tour-type-select');
const kayakOption = (p: Page) => p.getByTestId('tour-type-option-kayak');
const paddleFields = (p: Page) => editorRail(p).getByTestId('tour-planner-paddle-fields');
const lakeStyle = (p: Page) => editorRail(p).getByTestId('tour-planner-lake-style');
const speedInput = (p: Page) => editorRail(p).locator('#tour-planner-paddle-speed');
const tourList = (p: Page) => p.getByRole('listbox', { name: 'Tours of this trip' });
const tourRow = (p: Page, name: string | RegExp) =>
tourList(p).getByRole('option', typeof name === 'string' ? { name, exact: true } : { name });
/** A waypoint row by the name the fixture gave the point. */
const pointRow = (p: Page, name: string) =>
editorRail(p)
.getByTestId('tour-planner-waypoint-row')
.filter({ has: p.getByTestId('tour-planner-waypoint-name').getByText(name, { exact: true }) })
.first();
const moreActions = (p: Page, name: string) =>
pointRow(p, name).getByRole('button', { name: 'More actions', exact: true });
/** The point's name, inside the part of the row whose hover brings its controls out. */
const pointName = (p: Page, name: string) =>
pointRow(p, name).getByTestId('tour-planner-waypoint-name').getByText(name, { exact: true });
const menu = (p: Page) => p.getByRole('menu');
const menuItem = (p: Page, label: string) => menu(p).getByRole('button', { name: label, exact: true });
const freeLeg = (p: Page) => editorRail(p).getByTestId('tour-planner-free-leg').first();
const legFailure = (p: Page) => editorRail(p).getByTestId('tour-planner-leg-failure').first();
const routeOnWater = (p: Page) => editorRail(p).getByTestId('tour-planner-route-on-water');
const portageCard = (scope: Page | Locator) => scope.getByTestId('tour-planner-portage-leg').first();
const lockCard = (scope: Page | Locator) => scope.getByTestId('tour-planner-lock').first();
const timeCard = (p: Page) => editorRail(p).getByRole('region', { name: 'Time estimate' });
const windCard = (p: Page) => editorRail(p).getByRole('region', { name: 'Wind' });
const checkCard = (p: Page) => editorRail(p).getByRole('region', { name: 'Checklist' });
const safetyRow = (p: Page) => editorRail(p).getByRole('button', { name: /^Paddling has risks/ });
const safetyDialog = (p: Page) => p.getByRole('dialog', { name: 'Safety on the water' });
const addMenu = (p: Page) => p.getByRole('group', { name: 'Add tour', exact: true });
const previewDialog = (p: Page) => p.getByRole('dialog').filter({ has: p.getByTestId('gpx-preview-import') });
const marksCard = (p: Page) => p.getByTestId('track-marks-card');
// ── Getting there ─────────────────────────────────────────────────────────────
/** Open a paddle tour of the list in the editor and wait for its points. */
async function openPaddle(p: Page, name: string = PADDLE_TOUR): Promise<void> {
await expect(tourRow(p, name)).toBeVisible({ timeout: 30_000 });
await tourRow(p, name).click();
await expect(editorRail(p).getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 20_000 });
await expect(pointRow(p, 'Under the Seekofel')).toBeVisible({ timeout: 20_000 });
await finishMapMove(p);
}
async function openPaddleScreen(p: Page): Promise<void> {
await openTourTrip(p, { tab: 'tour-planner' });
await openPaddle(p);
}
/** The waypoint row's menu: its controls come out on hover, as a plan row's do. */
async function openMenu(p: Page, point: string): Promise<void> {
// Centred first, so the click on More actions needs no scroll that slides the row from under the pointer.
await pointRow(p, point).evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await pointName(p, point).hover();
await moreActions(p, point).click();
await expect(menu(p)).toBeVisible();
await settle(p);
}
/** Carrying on land asks the public walking router for the path; it answers or gives up within a minute. */
async function waitForPortageWay(p: Page): Promise<void> {
await expect(editorRail(p).getByText('Looking for a path', { exact: true })).toHaveCount(0, { timeout: 60_000 });
await settle(p);
}
async function carryFrom(p: Page, point: string): Promise<void> {
await openMenu(p, point);
await menuItem(p, 'Carry the boat from here').click();
await expect(portageCard(editorRail(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
await waitForPortageWay(p);
}
/** A minutes field commits when it is left. */
async function typeMinutes(field: Locator, value: string): Promise<void> {
await field.fill(value);
await field.press('Enter');
}
async function openStop(card: Locator): Promise<void> {
const head = card.getByRole('button').first();
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click();
await expect(head).toHaveAttribute('aria-expanded', 'true');
}
async function lockAt(p: Page, point: string, times?: { wait: string; passage: string }): Promise<void> {
await openMenu(p, point);
await menuItem(p, 'Lock at this point').click();
await expect(lockCard(editorRail(p))).toBeVisible({ timeout: 15_000 });
if (times) {
await openStop(lockCard(editorRail(p)));
await typeMinutes(p.getByTestId('tour-planner-lock-wait'), times.wait);
await typeMinutes(p.getByTestId('tour-planner-lock-transit'), times.passage);
}
await settle(p);
}
/** Drop what a guide changed in the editor; nothing of it was saved. */
async function discardEdits(p: Page): Promise<void> {
await p.keyboard.press('Escape').catch(() => undefined);
const discard = editorRail(p).getByRole('button', { name: 'Discard', exact: true });
if (!(await discard.isVisible().catch(() => false)) || !(await discard.isEnabled().catch(() => false))) return;
await discard.click();
const confirm = p.getByRole('button', { name: 'Discard changes', exact: true });
if (await confirm.isVisible({ timeout: 3_000 }).catch(() => false)) await confirm.click();
await expect(planNewTour(p)).toBeVisible({ timeout: 15_000 });
}
async function removeImports(p: Page): Promise<void> {
await deleteToursWhere(p.request, await summerTripId(p.request), isPaddleImport);
}
const only = (target: (p: Page) => Locator) => ({ target });
/**
* The step that rings a row's More actions button. Its controls are only in
* the layout while the pointer is on the row, and the picture centres the
* button by scrolling the rail, which slides the row out from under a pointer
* put there before. So the row is centred first, and after the scroll the
* pointer goes back onto its name and then onto the button the text names.
*/
const pointAtMoreActions = (point: string) => ({
prepare: async (p: Page) => {
await pointRow(p, point).evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await pointName(p, point).hover();
},
target: (p: Page) => moreActions(p, point),
hover: async (p: Page) => {
await pointName(p, point).hover();
await moreActions(p, point).hover();
},
act: async (p: Page) => {
await moreActions(p, point).click();
await expect(menu(p)).toBeVisible();
await settle(p);
},
});
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-paddle-setup': {
guide: guide('tour-paddle-setup'),
start: async (p) => {
await openTourTrip(p, { tab: 'tour-planner' });
},
steps: [
{
target: planNewTour,
act: async (p) => {
await planNewTour(p).click();
await expect(typeSelect(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await typeSelect(p).click();
await expect(kayakOption(p)).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('group', { name: 'Tour type', exact: true }),
hover: async (p) => {
await kayakOption(p).hover();
},
act: async (p) => {
await kayakOption(p).click();
await expect(paddleFields(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => paddleFields(p).getByRole('group', { name: 'Boat', exact: true }),
act: async (p) => {
const kayak = paddleFields(p)
.getByRole('group', { name: 'Boat', exact: true })
.getByRole('button', { name: 'Kayak', exact: true });
await kayak.click();
await expect(kayak).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
{
target: (p) => paddleFields(p).getByRole('group', { name: 'Water', exact: true }),
act: async (p) => {
await paddleFields(p)
.getByRole('group', { name: 'Water', exact: true })
.getByRole('button', { name: 'Lake', exact: true })
.click();
await expect(lakeStyle(p)).toBeVisible();
await settle(p);
},
},
{
target: lakeStyle,
act: async (p) => {
await lakeStyle(p).getByRole('button', { name: 'Near the shore', exact: true }).click();
await expect(lakeStyle(p)).toContainText('Stays near the bank');
await settle(p);
},
},
{
// The label row, the field and the line under it: the block above the input's own div.
target: (p) => speedInput(p).locator('xpath=ancestor::div[2]'),
act: async (p) => {
await speedInput(p).fill('5');
await speedInput(p).press('Enter');
await expect(paddleFields(p).getByText('Own speed', { exact: true })).toBeVisible();
await settle(p);
},
},
only((p) => editorRail(p).getByTestId('tour-planner-add-point')),
],
cleanup: discardEdits,
},
'tour-paddle-water-route': {
guide: guide('tour-paddle-water-route'),
start: openPaddleScreen,
steps: [
only(freeLeg),
pointAtMoreActions('Under the Seekofel'),
{
target: (p) => menuItem(p, 'Plan the next leg on the water'),
act: async (p) => {
await menuItem(p, 'Plan the next leg on the water').click();
// The water router answers at once; the region lookup behind it may take a few seconds.
await expect(legFailure(p)).toBeVisible({ timeout: 90_000 });
await settle(p);
},
},
{
target: legFailure,
act: async (p) => {
await legFailure(p).getByRole('button', { name: 'Draw straight', exact: true }).click();
await expect(editorRail(p).getByTestId('tour-planner-leg-failure')).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
{
target: routeOnWater,
act: async (p) => {
await routeOnWater(p).click();
await expect(legFailure(p)).toBeVisible({ timeout: 90_000 });
await settle(p);
},
},
],
cleanup: discardEdits,
},
'tour-paddle-portage': {
guide: guide('tour-paddle-portage'),
start: openPaddleScreen,
steps: [
pointAtMoreActions('South shore'),
{
target: (p) => menuItem(p, 'Carry the boat from here'),
act: async (p) => {
await menuItem(p, 'Carry the boat from here').click();
await expect(portageCard(editorRail(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => portageCard(editorRail(p)),
act: async (p) => {
await openStop(portageCard(editorRail(p)));
await expect(p.getByTestId('tour-planner-portage-way')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-portage-way'),
act: async (p) => {
await waitForPortageWay(p);
const follow = p
.getByTestId('tour-planner-portage-way')
.getByRole('button', { name: 'Follow the path', exact: true });
if ((await follow.getAttribute('aria-pressed')) !== 'true') await follow.click();
await waitForPortageWay(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-portage-extra'),
act: async (p) => {
await typeMinutes(p.getByTestId('tour-planner-portage-extra'), '15');
await expect(portageCard(editorRail(p))).toContainText('+15 min');
await settle(p);
},
},
],
cleanup: discardEdits,
},
'tour-paddle-lock': {
guide: guide('tour-paddle-lock'),
start: openPaddleScreen,
steps: [
pointAtMoreActions('Chapel'),
{
target: (p) => menuItem(p, 'Lock at this point'),
act: async (p) => {
await menuItem(p, 'Lock at this point').click();
await expect(lockCard(editorRail(p))).toContainText('Time missing', { timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => lockCard(editorRail(p)),
act: async (p) => {
await openStop(lockCard(editorRail(p)));
await expect(p.getByTestId('tour-planner-lock-wait')).toBeVisible();
await settle(p);
},
},
{
target: (p) => lockCard(editorRail(p)),
act: async (p) => {
await typeMinutes(p.getByTestId('tour-planner-lock-wait'), '10');
await typeMinutes(p.getByTestId('tour-planner-lock-transit'), '5');
await expect(lockCard(editorRail(p))).toContainText('15 min');
await settle(p);
},
},
],
cleanup: discardEdits,
},
'tour-paddle-time': {
guide: guide('tour-paddle-time'),
start: async (p) => {
await openPaddleScreen(p);
// One carry and one lock without times: the card then has every row it can show, and a gap to name.
// The lock goes on a point the carry does not touch: a lock at either end of the portage is carried
// round, and its times then drop out of the estimate, so the card would name no gap.
await carryFrom(p, 'South shore');
await lockAt(p, 'Under the Seekofel');
await expect(timeCard(p)).toBeVisible();
},
steps: [
only(timeCard),
only((p) => timeCard(p).getByTestId('paddle-route-strip')),
only((p) => timeCard(p).getByTestId('paddle-time-water')),
{
target: (p) => timeCard(p).getByTestId('paddle-time-carry'),
act: async (p) => {
const chip = timeCard(p).getByTestId('paddle-time-carry-speed');
await chip.fill('2.5');
await chip.press('Enter');
await expect(chip).toHaveValue('2.5');
await settle(p);
},
},
only((p) => timeCard(p).getByText(/lock has no times yet/)),
],
cleanup: discardEdits,
},
'tour-paddle-wind': {
guide: guide('tour-paddle-wind'),
start: async (p) => {
const wind = await ensureWindTrip(p.request);
await openToursOf(p, wind.tripId);
await openPaddle(p, wind.name);
// The forecast is asked of the server, which asks Open-Meteo.
await expect(windCard(p).getByTestId('paddle-wind-hours')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
steps: [
only(windCard),
only((p) => windCard(p).getByTestId('paddle-wind-mean').locator('xpath=ancestor::div[1]')),
{
target: (p) => windCard(p).getByTestId('paddle-wind-hours'),
// A bar around midday, so its tooltip shows in the picture.
hover: async (p) => {
await windCard(p).getByTestId('paddle-wind-hours').locator(':scope > span').nth(5).hover();
},
},
{
target: (p) => windCard(p).getByRole('button', { name: 'What wind means for paddling' }),
act: async (p) => {
const fold = windCard(p).getByRole('button', { name: 'What wind means for paddling' });
await fold.click();
await expect(fold).toHaveAttribute('aria-expanded', 'true');
await settle(p);
},
},
],
cleanup: async (p) => {
await deleteTripsNamed(p.request, WIND_TRIP);
},
},
'tour-paddle-checklist': {
guide: guide('tour-paddle-checklist'),
start: async (p) => {
await ensureCarryCopy(p.request);
await openTourTrip(p, { tab: 'tour-planner' });
await openPaddle(p, CARRY_COPY);
await carryFrom(p, 'South shore');
// Away from the portage, so the lock is passed through and not carried round.
await lockAt(p, 'Under the Seekofel', { wait: '10', passage: '5' });
await editorRail(p).getByRole('button', { name: 'Save', exact: true }).click();
// The checklist is read for the saved route: the carry and the lock come back as rows of their own.
await expect(checkCard(p).getByTestId('paddle-check-rows')).toBeVisible({ timeout: 90_000 });
await settle(p);
},
steps: [
only(checkCard),
only((p) => checkCard(p).getByTestId('paddle-check-data')),
only((p) => checkCard(p).getByTestId('paddle-check-rows')),
{
target: (p) => checkCard(p).getByTestId('paddle-check-rows').getByRole('button').first(),
act: async (p) => {
await checkCard(p).getByTestId('paddle-check-rows').getByRole('button').first().click();
await finishMapMove(p);
},
},
{
target: safetyRow,
act: async (p) => {
await safetyRow(p).click();
await expect(safetyDialog(p)).toBeVisible();
await settle(p);
await p.keyboard.press('Escape');
await expect(safetyDialog(p)).toHaveCount(0);
},
},
],
cleanup: async (p) => {
await deleteToursWhere(p.request, await summerTripId(p.request), (t) => t.name === CARRY_COPY);
},
},
'tour-paddle-track-marks': {
guide: guide('tour-paddle-track-marks'),
start: async (p) => {
await removeImports(p);
await openTourTrip(p, { tab: 'tour-planner' });
},
steps: [
{
prepare: async (p) => {
await p.getByTestId('tour-rail-add').click();
await expect(addMenu(p)).toBeVisible();
await settle(p);
},
target: addMenu,
hover: async (p) => {
await addMenu(p).getByRole('button', { name: 'Import GPX or KML', exact: true }).hover();
},
act: async (p) => {
const chooser = p.waitForEvent('filechooser');
await addMenu(p).getByRole('button', { name: 'Import GPX or KML', exact: true }).click();
await (await chooser).setFiles(gpxFile);
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await previewDialog(p).getByTestId('tour-type-select').click();
await kayakOption(p).click();
await expect(previewDialog(p).getByTestId('gpx-preview-paddle')).toBeVisible();
await settle(p);
},
target: (p) => previewDialog(p).getByTestId('gpx-preview-paddle'),
act: async (p) => {
const fields = previewDialog(p).getByTestId('gpx-preview-paddle');
await fields
.getByRole('group', { name: 'Boat', exact: true })
.getByRole('button', { name: 'Kayak', exact: true })
.click();
await fields
.getByRole('group', { name: 'Water', exact: true })
.getByRole('button', { name: 'Lake', exact: true })
.click();
await expect(previewDialog(p).getByTestId('gpx-preview-import')).toBeEnabled();
await settle(p);
},
},
{
target: (p) => previewDialog(p).getByTestId('gpx-preview-import'),
act: async (p) => {
await previewDialog(p).getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
await expect(tourRow(p, /kayak round/i)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: (p) => tourRow(p, /kayak round/i),
act: async (p) => {
await tourRow(p, /kayak round/i).click();
await expect(marksCard(p)).toBeVisible({ timeout: 30_000 });
await finishMapMove(p);
},
},
{
target: (p) => p.getByTestId('track-marks-pick-portage'),
act: async (p) => {
await p.getByTestId('track-marks-pick-portage').click();
await expect(marksCard(p)).toContainText('Click the line where you get out.');
const out = await pointOnLine(p, 0.3);
await p.mouse.click(out.x, out.y);
await expect(marksCard(p)).toContainText('Now click where you put back in.');
const back = await pointOnLine(p, 0.36);
await p.mouse.click(back.x, back.y);
await expect(portageCard(marksCard(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => p.getByTestId('track-marks-pick-lock'),
act: async (p) => {
await p.getByTestId('track-marks-pick-lock').click();
await expect(marksCard(p)).toContainText('Click the line where the lock is.');
const at = await pointOnLine(p, 0.72);
await p.mouse.click(at.x, at.y);
await expect(lockCard(marksCard(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await openStop(lockCard(marksCard(p)));
await typeMinutes(marksCard(p).getByTestId('tour-planner-lock-wait'), '10');
await typeMinutes(marksCard(p).getByTestId('tour-planner-lock-transit'), '10');
await settle(p);
},
target: (p) => marksCard(p).getByRole('button', { name: 'Save', exact: true }),
act: async (p) => {
await marksCard(p).getByRole('button', { name: 'Save', exact: true }).click();
// Saved, the card has nothing left to save and its buttons go.
await expect(marksCard(p).getByRole('button', { name: 'Save', exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await settle(p);
},
},
],
cleanup: removeImports,
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
await forgetPaddleDraft(request);
await holdPaddleDownloads(request);
gpxFile = await paddleGpxFixture(request);
});
test.afterAll(async ({ request }) => {
// What a guide made for itself, again here so a failed run leaves none of it in the other screens' pictures.
await deleteTripsNamed(request, WIND_TRIP).catch(() => undefined);
const tripId = await summerTripId(request).catch(() => null);
if (tripId !== null) {
await deleteToursWhere(request, tripId, (t) => t.name === CARRY_COPY || isPaddleImport(t)).catch(() => undefined);
}
await forgetPaddleDraft(request).catch(() => undefined);
await restorePaddleDownloads(request);
});
test('every registered paddle tour guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourPaddleContext.guides].sort());
});
test('hero: trip-tour-paddle', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await forgetPaddleDraftBeforeLoad(page);
await captureHero(page, tripTourPaddleContext.id, openPaddleScreen);
await forgetPaddleDraft(page.request);
});
for (const id of tripTourPaddleContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
// Every guide starts without a draft and leaves none, also when it fails before its cleanup runs.
await forgetPaddleDraftBeforeLoad(page);
try {
await captureGuide(page, SCRIPTS[id]);
} finally {
await forgetPaddleDraft(page.request).catch(() => undefined);
}
});
}
+853
View File
@@ -0,0 +1,853 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourPlannerContext, tripTourPlannerGuides } from '../../src/help/contexts/tripTourPlanner';
import type { HelpGuide } from '../../src/help/types';
import { clearNotices } from '../screenshots/shot';
import {
centreOf,
closeEditor,
DRAWN_OFFSETS,
editorRail,
ensureOldPlan,
ensureSpare,
forgetPlannerState,
glide,
hutLine,
landMap,
layerPill,
mapSearch,
menuItem,
offDay,
openMenu,
openPlanner,
openRowMenu,
openSaved,
pin,
PLANNER_TOURS,
removeTour,
routeBand,
saveSpareElsewhere,
screenOf,
SEARCH_QUERY,
SHAPING_OFFSET,
shapingPin,
spotAt,
spotOnRoute,
summaryCard,
tourIdOf,
tourRow,
toursRail,
tripIdOf,
useTopo,
waitRouted,
waypointRows,
waypointsCard,
type HutLine,
} from './fixtures-trip-tour-planner';
import { captureGuide, captureHero, dismissReleaseNotice, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, TOUR_NAMES, TOUR_PLACES } from './tour-fixtures';
import { confirmDialog, tripReady } from './trip-shared';
/**
* Actions for the Tours tab, the tour planner, keyed by the ids in
* `src/help/contexts/tripTourPlanner.ts`. They run on "Summer in the
* Dolomites" (`ensureToursFixtures`) plus one plain day hike of this screen's
* own, "Rifugio Firenze to Col Raiser" (`ensureSpare`), cut from the hut
* trek's line. The editing guides open that hike, change it and throw the
* change away again; the two that save put it back as it was made. A new tour
* is drawn through spots on the hut trek's line, so every leg has a path.
*
* Every route here is planned by the router the app asks (Valhalla), and the
* map search goes through the server's place search, so the run needs the
* network. The Topo map is switched on before anything is clicked onto the
* map: its raster tiles are what `screenOf` finds a spot on the screen by.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourPlannerGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let geo: HutLine | null = null;
async function line(page: Page): Promise<HutLine> {
geo ??= await hutLine(page.request, await tripIdOf(page.request));
return geo;
}
// ── Where things are ──────────────────────────────────────────────────────────
const planNew = (page: Page) => page.getByRole('button', { name: 'Plan new tour', exact: true });
const addPoint = (page: Page) => page.getByTestId('tour-planner-add-point');
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
const backToTours = (page: Page) => page.getByRole('button', { name: 'Back to Tours', exact: true });
const pointSet = (page: Page) => page.getByRole('status').filter({ hasText: 'Point set' });
const nameField = (page: Page) => page.locator('#tour-planner-name');
const description = (page: Page) => page.locator('#tour-planner-description');
const website = (page: Page) => page.locator('#tour-planner-website');
const breaks = (page: Page) => page.locator('#tour-planner-breaks');
const planned = (page: Page) => page.locator('#tour-planner-planned-duration');
const rowButton = (page: Page, index: number) =>
waypointRows(page)
.nth(index)
.getByRole('button')
.filter({ has: page.getByTestId('tour-planner-waypoint-name') });
const shapingRow = (page: Page) => waypointRows(page).filter({ hasText: 'Shaping point' }).first();
const tourTypeField = (page: Page) => page.getByTestId('tour-type-select');
const typeList = (page: Page) => page.getByRole('group', { name: 'Tour type', exact: true });
const profileGroup = (page: Page) =>
page.getByTestId('tour-planner-bike-fields').getByRole('group', { name: 'Profile' });
const placePicker = (page: Page) => page.getByTestId('trip-place-picker');
const filterButton = (page: Page) => toursRail(page).getByRole('button', { name: 'Show', exact: true });
const filterMenu = (page: Page) => toursRail(page).getByRole('group', { name: 'Show', exact: true });
const totalsCard = (page: Page) => toursRail(page).getByRole('region', { name: 'Trip totals', exact: true });
const remoteNotice = (page: Page) => page.getByTestId('tour-remote-change');
// The dialog takes its name from the eyebrow above the title ("Not saved"), so it is found by its heading.
const conflictDialog = (page: Page) =>
page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: 'Changed in the meantime', exact: true }) });
/** A place of the trip in the picker, by its own name: a tour named after the place would match a substring too. */
const placeCandidate = (page: Page, name: string) =>
placePicker(page)
.getByTestId('trip-place-candidate')
.filter({ has: page.getByText(name, { exact: true }) });
// ── Getting there ─────────────────────────────────────────────────────────────
/** The trip on its Plan tab, for the one guide that starts by switching to Tours. */
async function openPlanTab(page: Page): Promise<void> {
const tripId = await tripIdOf(page.request);
await forgetPlannerState(page);
await page.goto(`/trips/${tripId}`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
}
/** The details are open by default and remembered; a guide that types into them makes sure. */
async function openDetails(page: Page): Promise<void> {
const show = page.getByRole('button', { name: 'Show details', exact: true });
if (await show.isVisible().catch(() => false)) await show.click();
await expect(description(page)).toBeVisible();
}
/** Plan new tour, on the Topo map. */
async function startDraft(page: Page): Promise<void> {
await planNew(page).click();
await expect(page.getByRole('heading', { name: 'New tour', exact: true })).toBeVisible();
await useTopo(page);
}
/** Pick the map search's answer that lies in the Dolomites; the first one when none says so. */
async function searchTheHut(page: Page): Promise<void> {
await typeInto(page, mapSearch(page), SEARCH_QUERY);
const options = page.getByRole('listbox', { name: 'Search place or address' }).getByRole('option');
await expect(options.first(), 'the map search found nothing for the hut').toBeVisible({ timeout: 30_000 });
const local = options.filter({
hasText: /Santa Cristina|Gardena|Gröden|Bolzano|Bozen|Südtirol|Alto Adige|Trentino/i,
});
await ((await local.count()) ? local.first() : options.first()).click();
await landMap(page);
}
/** Click spot `i` of the drawing onto the map and wait for its row. */
async function clickSpot(page: Page, i: number): Promise<void> {
const at = await screenOf(page, spotAt(await line(page), DRAWN_OFFSETS[i]));
await page.mouse.click(at.x, at.y);
await expect(waypointRows(page)).toHaveCount(i + 1, { timeout: 15_000 });
}
/** A new tour of four points, drawn and routed, the way the drawing guide draws it. */
async function drawDraft(page: Page): Promise<void> {
await startDraft(page);
await searchTheHut(page);
for (let i = 0; i < DRAWN_OFFSETS.length; i++) {
await clickSpot(page, i);
if (i > 0) await waitRouted(page);
}
}
/** The Tours tab with the spare hike open in the editor. */
async function openSpare(page: Page): Promise<void> {
await openPlanner(page);
await openSaved(page, PLANNER_TOURS.spare);
}
/** Where along the hut trek the spare's stop `k` (from 0) lies, in metres from the hut. */
function stopOffset(g: HutLine, k: number): number {
const length = g.line.length - g.hut;
const cut = [0, Math.round(length / 3), Math.round((2 * length) / 3), length - 1][k];
return g.along[g.hut + cut] - g.along[g.hut];
}
/** Drag the mouse from one point to another in small steps, for a pin Leaflet does not drag itself. */
async function dragBetween(page: Page, from: { x: number; y: number }, to: { x: number; y: number }): Promise<void> {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + 6, from.y + 6, { steps: 3 });
await page.mouse.move(to.x, to.y, { steps: 16 });
await page.mouse.up();
}
async function backToNeutral(page: Page): Promise<void> {
await closeEditor(page);
await settle(page);
}
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-open-workspace': {
guide: guide('tour-open-workspace'),
start: openPlanTab,
steps: [
{
// The trip's own tab bar; the Places | Tours switch of the plan is a tablist of tabs, not buttons.
target: (p) => p.getByRole('button', { name: 'Tours', exact: true }).first(),
act: async (p) => {
await p.getByRole('button', { name: 'Tours', exact: true }).first().click();
await expect(planNew(p)).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await landMap(p);
},
},
{ target: planNew },
{ target: toursRail },
{
prepare: async (p) => {
await p.getByTestId('tour-rail-add').click();
await expect(p.getByRole('group', { name: 'Add tour', exact: true })).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('group', { name: 'Add tour', exact: true }),
act: async (p) => {
await p.keyboard.press('Escape');
if (
await p
.getByRole('group', { name: 'Add tour', exact: true })
.isVisible()
.catch(() => false)
) {
await p.getByTestId('tour-rail-add').click();
}
await expect(p.getByRole('group', { name: 'Add tour', exact: true })).toHaveCount(0);
},
},
{ target: mapSearch },
{ target: layerPill },
{
target: (p) => tourRow(p, TOUR_NAMES.recorded),
act: async (p) => {
await tourRow(p, TOUR_NAMES.recorded).click();
await expect(p.getByRole('heading', { name: 'GPX tour', exact: true })).toBeVisible({ timeout: 30_000 });
await landMap(p);
},
},
],
cleanup: backToNeutral,
},
'tour-draw-route': {
guide: guide('tour-draw-route'),
start: openPlanner,
steps: [
{ target: planNew, act: startDraft },
{ target: mapSearch, act: searchTheHut },
{
target: addPoint,
act: async (p) => {
await expect(addPoint(p)).toHaveAttribute('aria-pressed', 'true');
await clickSpot(p, 0);
},
},
// Up for four seconds after a click, and held while the pointer rests on it.
{ target: pointSet },
{
target: (p) => pin(p, 1),
clip: async (p, frame) => {
for (let i = 1; i < DRAWN_OFFSETS.length; i++) {
const from = await centreOf(pin(p, i));
const to = await screenOf(p, spotAt(await line(p), DRAWN_OFFSETS[i]));
await glide(p, { x: from.x + 20, y: from.y + 20 }, to, frame);
await p.mouse.click(to.x, to.y);
await expect(waypointRows(p)).toHaveCount(i + 1, { timeout: 15_000 });
// The route arrives while the frames run: a beat each.
for (let k = 0; k < 6; k++) {
await p.waitForTimeout(300);
await frame();
}
await waitRouted(p);
await frame();
}
for (let k = 0; k < 3; k++) await frame();
},
},
{ prepare: waitRouted, target: waypointsCard },
],
cleanup: backToNeutral,
},
'tour-type-profile': {
guide: guide('tour-type-profile'),
start: async (p) => {
await openPlanner(p);
await openSaved(p, TOUR_NAMES.roadBike);
},
steps: [
{
target: tourTypeField,
act: async (p) => {
await tourTypeField(p).click();
await expect(typeList(p)).toBeVisible();
},
},
{
target: typeList,
act: async (p) => {
// The tour stays a bike tour: picking its own type closes the list and changes nothing.
await typeList(p).getByTestId('tour-type-option-bike').click();
await expect(typeList(p)).toHaveCount(0);
await settle(p);
},
},
{
target: profileGroup,
act: async (p) => {
await profileGroup(p).getByRole('button', { name: 'Touring', exact: true }).click();
await waitRouted(p);
},
},
{ target: summaryCard },
],
cleanup: backToNeutral,
},
'tour-edit-points': {
guide: guide('tour-edit-points'),
start: openSpare,
steps: [
{
target: (p) => pin(p, 2),
act: async (p) => {
const g = await line(p);
const from = await centreOf(pin(p, 2));
const to = await screenOf(p, spotAt(g, stopOffset(g, 1) + 150));
await dragBetween(p, from, to);
await waitRouted(p);
},
},
{
target: (p) => rowButton(p, 2),
act: async (p) => {
await rowButton(p, 2).click();
await settle(p);
},
},
{
prepare: async (p) => {
await waypointRows(p).nth(2).hover();
},
target: (p) => waypointRows(p).nth(2).getByTestId('tour-planner-waypoint-drag-handle'),
dropTo: (p) => waypointRows(p).nth(1),
act: async (p) => {
await waypointRows(p)
.nth(2)
.getByTestId('tour-planner-waypoint-drag-handle')
.dragTo(waypointRows(p).nth(1), { targetPosition: { x: 60, y: 4 } });
await waitRouted(p);
},
},
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Move waypoint down').click();
await waitRouted(p);
},
},
{
prepare: async (p) => {
await waypointRows(p).nth(2).hover();
},
target: (p) => waypointRows(p).nth(2).getByRole('button', { name: 'Remove waypoint', exact: true }),
act: async (p) => {
await waypointRows(p).nth(2).getByRole('button', { name: 'Remove waypoint', exact: true }).click();
await expect(waypointRows(p)).toHaveCount(3);
await waitRouted(p);
},
},
{
target: (p) => p.getByRole('button', { name: 'Undo waypoint edit', exact: true }),
act: async (p) => {
await p.getByRole('button', { name: 'Undo waypoint edit', exact: true }).click();
await expect(waypointRows(p)).toHaveCount(4);
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-shaping-points': {
guide: guide('tour-shaping-points'),
start: async (p) => {
await openSpare(p);
// On the Leaflet map a click on the route reaches the map as well, so with Add point on it
// also appends a new end on the same spot, whose pin then covers the shaping point. With
// the tool off the click only shapes the route, as the guide says. The tool is remembered
// in this page's session storage only, so the next guide finds it on again.
if ((await addPoint(p).getAttribute('aria-pressed')) === 'true') await addPoint(p).click();
await expect(addPoint(p)).toHaveAttribute('aria-pressed', 'false');
await settle(p);
},
steps: [
{
target: routeBand,
hover: async (p) => {
const at = await spotOnRoute(p, 0.1);
await p.mouse.move(at.x, at.y);
},
act: async (p) => {
const at = await spotOnRoute(p, 0.1);
await p.mouse.click(at.x, at.y);
await expect(shapingPin(p)).toHaveCount(1, { timeout: 15_000 });
await expect(waypointRows(p)).toHaveCount(5);
await waitRouted(p);
},
},
{
target: shapingPin,
// A pin's tooltip or a neighbour can sit over the dot's edge; its centre is where the hand goes.
hover: async (p) => {
const at = await centreOf(shapingPin(p));
await p.mouse.move(at.x, at.y);
},
clip: async (p, frame) => {
const from = await centreOf(shapingPin(p));
const to = await screenOf(p, spotAt(await line(p), SHAPING_OFFSET));
await glide(p, { x: from.x - 30, y: from.y + 20 }, from, frame, 4);
await p.mouse.down();
await p.mouse.move(from.x + 6, from.y + 6, { steps: 3 });
await frame();
await glide(p, { x: from.x + 6, y: from.y + 6 }, to, frame, 16);
await p.mouse.up();
for (let k = 0; k < 6; k++) {
await p.waitForTimeout(300);
await frame();
}
await waitRouted(p);
for (let k = 0; k < 3; k++) await frame();
},
},
{ target: shapingRow },
{
prepare: (p) => openRowMenu(p, shapingRow(p)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Make it a stop').click();
await expect(waypointRows(p).filter({ hasText: 'Shaping point' })).toHaveCount(0);
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-loop-reverse': {
guide: guide('tour-loop-reverse'),
start: openSpare,
steps: [
{
target: (p) => p.getByTestId('tour-planner-return-to-start'),
act: async (p) => {
await p.getByTestId('tour-planner-return-to-start').click();
await expect(waypointRows(p)).toHaveCount(5);
await waitRouted(p);
},
},
{ target: (p) => waypointRows(p).last() },
{
target: (p) => p.getByTestId('tour-planner-reverse'),
act: async (p) => {
await p.getByTestId('tour-planner-reverse').click();
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-leg-by-hand': {
guide: guide('tour-leg-by-hand'),
start: openSpare,
steps: [
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Draw the next leg by hand').click();
await expect(p.getByTestId('tour-planner-free-leg')).toBeVisible();
await waitRouted(p);
},
},
{ target: (p) => p.getByTestId('tour-planner-free-leg') },
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Route the next leg again').click();
await expect(p.getByTestId('tour-planner-free-leg')).toHaveCount(0);
await waitRouted(p);
},
},
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: (p) => menuItem(p, 'Plan the next leg again'),
act: async (p) => {
await menuItem(p, 'Plan the next leg again').click();
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-trip-places': {
guide: guide('tour-trip-places'),
start: openSpare,
steps: [
{
target: (p) => p.getByTestId('tour-planner-add-place'),
act: async (p) => {
await p.getByTestId('tour-planner-add-place').click();
await expect(placePicker(p)).toBeVisible();
await settle(p);
},
},
{ target: placePicker },
{
target: (p) => placeCandidate(p, TOUR_PLACES.firenze),
act: async (p) => {
await placeCandidate(p, TOUR_PLACES.firenze).click();
await expect(waypointRows(p)).toHaveCount(5);
await p.keyboard.press('Escape');
await waitRouted(p);
},
},
{ target: (p) => waypointRows(p).last().getByTestId('tour-planner-waypoint-linked') },
],
cleanup: backToNeutral,
},
'tour-details-times': {
guide: guide('tour-details-times'),
start: async (p) => {
await openSpare(p);
await openDetails(p);
},
steps: [
{ target: nameField },
{
target: description,
act: async (p) => {
await typeInto(
p,
description(p),
'From the hut across the Cisles meadows and down to the gondola. Last ride down at 17:30.'
);
await typeInto(p, website(p), 'https://www.valgardena.it/en/');
await settle(p);
},
},
{
target: breaks,
act: async (p) => {
await typeInto(p, breaks(p), '0:45');
await breaks(p).press('Tab');
await settle(p);
},
},
{
target: planned,
act: async (p) => {
await typeInto(p, planned(p), '3:30');
await planned(p).press('Enter');
await expect(p.getByTestId('tour-planner-planned-manual')).toBeVisible();
await settle(p);
},
},
],
cleanup: backToNeutral,
},
'tour-save': {
guide: guide('tour-save'),
start: async (p) => {
await openPlanner(p);
await drawDraft(p);
},
steps: [
{
target: nameField,
act: async (p) => {
await typeInto(p, nameField(p), PLANNER_TOURS.drawn);
await settle(p);
},
},
{ target: (p) => p.getByRole('heading', { name: 'New tour', exact: true }), act: waitRouted },
{
target: saveButton,
act: async (p) => {
await expect(saveButton(p)).toBeEnabled({ timeout: 30_000 });
await saveButton(p).click();
await expect(p.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{ target: (p) => tourRow(p, PLANNER_TOURS.drawn) },
],
cleanup: async (p) => {
await backToNeutral(p);
await removeTour(p.request, await tripIdOf(p.request), PLANNER_TOURS.drawn);
},
},
'tour-edit-saved': {
guide: guide('tour-edit-saved'),
start: async (p) => {
await ensureSpare(p.request, { reset: true });
await openPlanner(p);
},
steps: [
{
target: (p) => tourRow(p, PLANNER_TOURS.spare),
act: async (p) => {
await openSaved(p, PLANNER_TOURS.spare);
await openDetails(p);
},
},
{
target: description,
act: async (p) => {
await typeInto(
p,
description(p),
'From the hut across the Cisles meadows to the Col Raiser gondola, about an hour and a half.'
);
await settle(p);
},
},
{
target: saveButton,
act: async (p) => {
await expect(saveButton(p)).toBeEnabled({ timeout: 30_000 });
await saveButton(p).click();
await expect(p.getByText('Tour saved').first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: backToTours,
act: async (p) => {
await backToTours(p).click();
await expect(planNew(p)).toBeVisible();
await settle(p);
},
},
],
cleanup: async (p) => {
await backToNeutral(p);
await ensureSpare(p.request, { reset: true });
},
},
'tour-summary-profile': {
guide: guide('tour-summary-profile'),
start: openSpare,
steps: [
{ target: summaryCard },
{ target: (p) => summaryCard(p).getByRole('button', { name: 'How the walking time is estimated', exact: true }) },
{ target: (p) => p.locator('#tour-planner-editable-elevation-profile') },
{
target: (p) => p.getByRole('button', { name: 'Collapse elevation profile', exact: true }),
act: async (p) => {
await p.getByRole('button', { name: 'Collapse elevation profile', exact: true }).click();
await expect(p.getByRole('button', { name: 'Expand elevation profile', exact: true })).toBeVisible();
},
},
],
cleanup: async (p) => {
const expand = p.getByRole('button', { name: 'Expand elevation profile', exact: true });
if (await expand.isVisible().catch(() => false)) await expand.click();
await backToNeutral(p);
},
},
'tour-list-manage': {
guide: guide('tour-list-manage'),
start: openPlanner,
steps: [
{
target: totalsCard,
act: async (p) => {
await totalsCard(p).getByTestId('tour-scope-toggle').click();
await expect(totalsCard(p).getByTestId('tour-scope-hike')).toBeVisible();
await settle(p);
},
},
{
prepare: async (p) => {
await tourRow(p, TOUR_NAMES.mtb).hover();
},
target: (p) => toursRail(p).getByRole('button', { name: `Add to day: ${TOUR_NAMES.mtb}`, exact: true }),
act: async (p) => {
await toursRail(p)
.getByRole('button', { name: `Add to day: ${TOUR_NAMES.mtb}`, exact: true })
.click();
await expect(openMenu(p)).toBeVisible();
await settle(p);
},
},
{
target: openMenu,
act: async (p) => {
await openMenu(p)
.getByRole('button', { name: /^Day 10\b/ })
.click();
await expect(tourRow(p, TOUR_NAMES.mtb).getByTestId('tour-days-badge')).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: filterButton,
act: async (p) => {
await filterButton(p).click();
await expect(filterMenu(p)).toBeVisible();
},
},
{
target: filterMenu,
act: async (p) => {
await filterMenu(p)
.getByRole('button', { name: /^Unplanned/ })
.click();
await p.keyboard.press('Escape');
await expect(filterMenu(p)).toHaveCount(0);
await settle(p);
},
},
],
cleanup: async (p) => {
const tripId = await tripIdOf(p.request);
const mtb = await tourIdOf(p.request, tripId, TOUR_NAMES.mtb);
if (mtb) await offDay(p.request, tripId, 9, mtb);
await forgetPlannerState(p);
},
},
'tour-delete': {
guide: guide('tour-delete'),
start: async (p) => {
await ensureOldPlan(p.request);
await openPlanner(p);
},
steps: [
{
prepare: async (p) => {
await tourRow(p, PLANNER_TOURS.old).hover();
},
target: (p) => toursRail(p).getByRole('button', { name: `Delete ${PLANNER_TOURS.old}`, exact: true }),
act: async (p) => {
await toursRail(p)
.getByRole('button', { name: `Delete ${PLANNER_TOURS.old}`, exact: true })
.click();
await expect(confirmDialog(p)).toBeVisible();
await settle(p);
},
},
{
target: confirmDialog,
act: async (p) => {
await confirmDialog(p).getByRole('button', { name: 'Delete permanently', exact: true }).click();
await expect(tourRow(p, PLANNER_TOURS.old)).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
await removeTour(p.request, await tripIdOf(p.request), PLANNER_TOURS.old);
await forgetPlannerState(p);
},
},
'tour-saved-elsewhere': {
guide: guide('tour-saved-elsewhere'),
start: async (p) => {
await ensureSpare(p.request, { reset: true });
await openSpare(p);
await openDetails(p);
await typeInto(p, description(p), 'From the hut across the Cisles meadows, with lunch at the hut first.');
// Someone else saves the same tour meanwhile; the websocket brings the newer revision in.
await saveSpareElsewhere(p.request);
await expect(remoteNotice(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
steps: [
{ target: remoteNotice },
{ target: (p) => remoteNotice(p).getByRole('button', { name: 'Load newer', exact: true }) },
{
target: saveButton,
act: async (p) => {
await saveButton(p).click();
await expect(conflictDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: conflictDialog,
act: async (p) => {
await p.getByTestId('tour-conflict-reload').click();
await expect(conflictDialog(p)).toHaveCount(0);
await waitRouted(p);
},
},
],
cleanup: async (p) => {
await backToNeutral(p);
await ensureSpare(p.request, { reset: true });
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
await ensureSpare(request, { reset: true });
const tripId = await tripIdOf(request);
await request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
});
test.afterAll(async ({ request }) => {
// The spare and what the guides made from it stay out of every other screen's pictures.
const tripId = await tripIdOf(request);
for (const name of Object.values(PLANNER_TOURS)) await removeTour(request, tripId, name);
await request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
});
test('every registered tour planner guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourPlannerContext.guides].sort());
});
test('hero: trip-tour-planner', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourPlannerContext.id, openPlanner);
});
for (const id of tripTourPlannerContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+568
View File
@@ -0,0 +1,568 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourSkiContext, tripTourSkiGuides } from '../../src/help/contexts/tripTourSki';
import type { HelpGuide } from '../../src/help/types';
import { useTopo } from './fixtures-trip-tour-planner';
import {
deleteSkiTours,
forgetSkiDraft,
PLANNED_SKI_TOUR,
SKI_POINTS,
SKI_TRACK,
skiTrackFixture,
} from './fixtures-trip-tour-ski';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES } from './tour-fixtures';
import { confirmDialog } from './trip-shared';
/**
* Actions for Ski & winter tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourSki.ts`. They run on the ski week of the Tours
* demo data ("Ski week in Val Gardena", `ensureToursFixtures`): the planner
* guides start a new tour around Selva and plan it on the piste router, the
* map guide opens the saved tours, the import guide brings in a recorded
* cross-country track (`fixtures-trip-tour-ski.ts`). An unsaved draft is kept
* on the account and opened again on the next page load, so every guide starts
* by forgetting it and every test forgets it again when it ends, passed or not;
* what a guide saves is taken away in its `cleanup`.
*
* Every leg here is the piste router's (POST /api/trips/:id/ski/route over the
* TREK Places winter data): without it the legs fail and the guides that read
* planned legs fail with them, loudly, at the step that waits for the legs.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourSkiGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
// ── Where things are ──────────────────────────────────────────────────────────
/** The left rail, neutral or with a tour open: both are the section "Plan a tour". */
const leftRail = (page: Page) => page.locator('section[aria-label="Plan a tour"]').first();
/** The right rail, "Tours of this trip". */
const rightRail = (page: Page) => page.locator('aside[aria-label="Tours of this trip"]').first();
const typeSelect = (page: Page) => leftRail(page).getByTestId('tour-type-select');
const skiFields = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-fields');
const disciplines = (scope: Locator) => scope.getByRole('group', { name: 'Discipline' });
const skiOptions = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-options');
const addPoint = (page: Page) => leftRail(page).getByTestId('tour-planner-add-point');
const legSteps = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-leg-steps');
const legFailures = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-leg-failure');
/** A failure row the router reports, not the calm "summer paths" note. */
const legProblem = (page: Page) => legFailures(page).filter({ has: page.getByRole('alert') });
const liftClasses = (page: Page) => skiOptions(page).getByRole('group', { name: 'Leave out' });
const pisteRoute = (page: Page) => leftRail(page).locator('section[aria-label="Piste route"]');
const overview = (page: Page) => rightRail(page).getByTestId('tour-planner-overview');
const tourRow = (page: Page, name: string | RegExp) => rightRail(page).getByRole('option', { name }).first();
const importDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Import preview' });
const classifyDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Classify ski track' });
const LIFT_LABELS = ['Surface lifts', 'Open chairlifts', 'Gondolas', 'Cable cars', 'Funiculars and trains'];
// ── Getting there ─────────────────────────────────────────────────────────────
/**
* The ski week on its Tours tab, with the map over Val Gardena. The planner
* map frames, with nothing open, the area the plan map showed last, and that
* lives in memory only: so the plan opens on day 2 (the Sella Ronda) first and
* the Tours tab is clicked rather than loaded. A draft left on the account
* would open in the editor instead of the neutral rail, so it is forgotten
* before the page loads.
*
* With `topo`, the map is switched to Topo. The default Map is a vector style
* drawn on a canvas and has no tile images, and the pistes overlay's tiles come
* slowly or not at all, so the guides that click points on the map need the
* Topo raster to find them by (see `toScreen`).
*/
async function openSkiPlanner(page: Page, opts: { topo?: boolean } = {}): Promise<void> {
await forgetSkiDraft(page);
await openTourTrip(page, { trip: 'ski', day: 2 });
await page.getByRole('button', { name: 'Tours', exact: true }).first().click();
await expect(leftRail(page)).toBeVisible({ timeout: 30_000 });
await expect(rightRail(page)).toBeVisible();
await expect(
leftRail(page).getByRole('button', { name: 'Plan new tour' }),
'the editor opened on a leftover draft'
).toBeVisible({
timeout: 30_000,
});
if (opts.topo) await useTopo(page);
await settle(page);
}
/** A new tour of type Ski & winter, its discipline picked. */
async function newSkiDraft(page: Page, discipline = 'Alpine'): Promise<void> {
await leftRail(page).getByRole('button', { name: 'Plan new tour' }).click();
await pickSkiType(page);
await disciplines(skiFields(page)).getByRole('button', { name: discipline, exact: true }).click();
await expect(skiOptions(page)).toBeVisible();
}
async function pickSkiType(page: Page): Promise<void> {
await typeSelect(page).click();
await page.getByTestId('tour-type-option-ski').click();
await expect(skiFields(page)).toBeVisible();
}
/** Set points on: it is on by default in 2D, and a press turns it off, so only press when it is off. */
async function setPointsOn(page: Page): Promise<void> {
if ((await addPoint(page).getAttribute('aria-pressed')) !== 'true') await addPoint(page).click();
await expect(addPoint(page)).toHaveAttribute('aria-pressed', 'true');
}
/** Click the points on the map, then wait until every leg is planned (or, with `expectFailure`, one failed). */
async function placePoints(page: Page, points: [number, number][], expectFailure = false): Promise<void> {
await setPointsOn(page);
const at = await frameOn(page, points);
for (const p of at) {
await page.mouse.click(p.x, p.y);
await page.waitForTimeout(700);
}
if (expectFailure) {
await expect(legProblem(page).first()).toBeVisible({ timeout: 180_000 });
} else {
await expect(legSteps(page)).toHaveCount(points.length - 1, { timeout: 180_000 });
await expect(legProblem(page)).toHaveCount(0);
}
await settle(page);
}
/** The whole plan guide up to its last step, for the hero and the summary guide. */
async function plannedSkiDraft(page: Page): Promise<void> {
await openSkiPlanner(page, { topo: true });
await newSkiDraft(page);
await placePoints(page, SKI_POINTS);
await leftRail(page).locator('#tour-planner-name').fill(PLANNED_SKI_TOUR);
await settle(page);
}
/** Open a saved ski tour of the trip from the list: the Sella Ronda, or the winter walk where the router left it out. */
async function openSavedSkiTour(page: Page): Promise<void> {
const ronda = tourRow(page, new RegExp(`^${TOUR_NAMES.skiRonda}`));
const row = (await ronda.count()) ? ronda : tourRow(page, new RegExp(`^${TOUR_NAMES.winterWalk}`));
await row.click();
await expect(skiFields(page)).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/** Back to the list, throwing a draft away. */
async function leaveEditor(page: Page): Promise<void> {
const discard = leftRail(page).getByRole('button', { name: 'Discard', exact: true });
if (!(await discard.isVisible().catch(() => false)) || !(await discard.isEnabled())) return;
await discard.click();
const confirm = confirmDialog(page).getByRole('button', { name: 'Discard changes' });
if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click();
}
// ── The map, by its tiles ─────────────────────────────────────────────────────
type ScreenPoint = { x: number; y: number };
/**
* Where a coordinate is on the screen. The Leaflet map exposes no handle, but
* its tiles say where they are: a loaded tile's src names its z/x/y, and its box
* is where that tile sits, so the web-mercator pixel of any point follows from
* one of them. Off-screen points come out off-screen, which is what `frameOn`
* checks.
*/
async function toScreen(page: Page, points: [number, number][]): Promise<ScreenPoint[]> {
// Tiles still on their way after a pan or a zoom: wait for one before measuring.
await expect
.poll(
() =>
page.evaluate(
() =>
Array.from(
document.querySelectorAll<HTMLImageElement>('.leaflet-tile-pane img.leaflet-tile-loaded')
).filter(
(img) =>
/\/\d+\/\d+\/\d+(?:@2x)?\.(?:png|jpe?g|webp)/.test(img.src) && img.getBoundingClientRect().width > 0
).length
),
{ message: 'no loaded map tile to measure the map by: is the Topo map on?', timeout: 30_000 }
)
.toBeGreaterThan(0);
return page.evaluate((pts) => {
const tiles = Array.from(document.querySelectorAll<HTMLImageElement>('.leaflet-tile-pane img.leaflet-tile-loaded'))
.map((img) => {
const m = /\/(\d+)\/(\d+)\/(\d+)(?:@2x)?\.(?:png|jpe?g|webp)/.exec(img.src);
return m ? { z: Number(m[1]), x: Number(m[2]), y: Number(m[3]), box: img.getBoundingClientRect() } : null;
})
.filter((t): t is { z: number; x: number; y: number; box: DOMRect } => !!t && t.box.width > 0);
if (!tiles.length) throw new Error('no loaded map tile to measure the map by');
const counts = new Map<number, number>();
for (const t of tiles) counts.set(t.z, (counts.get(t.z) ?? 0) + 1);
const z = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
const tile = tiles.find((t) => t.z === z)!;
const scale = tile.box.width / 256;
const world = (lat: number, lng: number) => {
const n = 256 * 2 ** z;
const s = Math.sin((lat * Math.PI) / 180);
return { x: ((lng + 180) / 360) * n, y: (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n };
};
return pts.map(([lat, lng]) => {
const w = world(lat, lng);
return { x: tile.box.left + (w.x - tile.x * 256) * scale, y: tile.box.top + (w.y - tile.y * 256) * scale };
});
}, points);
}
/** The part of the map between the two rails and above the layer row, with a margin. */
async function openMapArea(page: Page): Promise<{ left: number; right: number; top: number; bottom: number }> {
const map = await page.locator('#trek-map, .leaflet-container').first().boundingBox();
const left = await leftRail(page).boundingBox();
const right = await rightRail(page).boundingBox();
if (!map) throw new Error('the tour map has no box');
return {
left: Math.max(map.x, (left?.x ?? 0) + (left?.width ?? 0)) + 70,
right: Math.min(map.x + map.width, right?.x ?? VIEWPORT.width) - 70,
top: map.y + 90,
bottom: map.y + map.height - 130,
};
}
/**
* Zoom the map until every point is in its open part and they are far enough
* apart to click: out around the middle while one is outside, in around the
* points while they huddle. The wheel is the one zoom this map has.
*/
async function frameOn(page: Page, points: [number, number][]): Promise<ScreenPoint[]> {
let zoomedIn = false;
let backedOut = false;
for (let round = 0; round < 16; round++) {
const area = await openMapArea(page);
const at = await toScreen(page, points);
const inside = at.every((p) => p.x > area.left && p.x < area.right && p.y > area.top && p.y < area.bottom);
const xs = at.map((p) => p.x);
const ys = at.map((p) => p.y);
const spread = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
if (inside && (spread > 380 || backedOut)) return at;
if (!inside) {
await page.mouse.move((area.left + area.right) / 2, (area.top + area.bottom) / 2);
await page.mouse.wheel(0, 240);
if (zoomedIn) {
await page.waitForTimeout(900);
await settle(page);
zoomedIn = false;
backedOut = true;
const back = await toScreen(page, points);
if (back.every((p) => p.x > area.left && p.x < area.right && p.y > area.top && p.y < area.bottom)) return back;
continue;
}
} else {
const cx = xs.reduce((s, x) => s + x, 0) / xs.length;
const cy = ys.reduce((s, y) => s + y, 0) / ys.length;
await page.mouse.move(cx, cy);
await page.mouse.wheel(0, -240);
zoomedIn = true;
}
await page.waitForTimeout(900);
await settle(page);
}
const area = await openMapArea(page);
const at = await toScreen(page, points);
if (!at.every((p) => p.x > area.left && p.x < area.right && p.y > area.top && p.y < area.bottom)) {
throw new Error('the ski points could not be brought into the open part of the map');
}
return at;
}
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-ski-plan': {
guide: guide('tour-ski-plan'),
start: (p) => openSkiPlanner(p, { topo: true }),
steps: [
{
target: (p) => leftRail(p).getByRole('button', { name: 'Plan new tour' }),
act: async (p) => {
await leftRail(p).getByRole('button', { name: 'Plan new tour' }).click();
await expect(typeSelect(p)).toBeVisible();
},
},
{
prepare: async (p) => {
await typeSelect(p).click();
await expect(p.getByTestId('tour-type-option-ski')).toBeVisible();
},
target: (p) => p.getByTestId('tour-type-option-ski'),
act: async (p) => {
await p.getByTestId('tour-type-option-ski').click();
await expect(skiFields(p)).toBeVisible();
},
},
{
target: (p) => disciplines(skiFields(p)),
act: async (p) => {
await disciplines(skiFields(p)).getByRole('button', { name: 'Alpine', exact: true }).click();
await expect(skiOptions(p)).toBeVisible();
},
},
{
target: addPoint,
act: async (p) => placePoints(p, SKI_POINTS),
},
{ target: (p) => legSteps(p).first() },
{
prepare: async (p) => {
await leftRail(p).locator('#tour-planner-name').fill(PLANNED_SKI_TOUR);
},
target: (p) => leftRail(p).getByRole('button', { name: 'Save', exact: true }),
act: async (p) => {
await leftRail(p).getByRole('button', { name: 'Save', exact: true }).click();
await expect(p.getByText('Tour saved').first()).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, new RegExp(`^${PLANNED_SKI_TOUR}`))).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
await deleteSkiTours(p, PLANNED_SKI_TOUR);
},
},
'tour-ski-options': {
guide: guide('tour-ski-options'),
start: async (p) => {
await openSkiPlanner(p);
await newSkiDraft(p);
},
steps: [
{ target: skiOptions },
{
target: (p) => p.locator('#tour-planner-ski-difficulty'),
act: async (p) => {
await p.locator('#tour-planner-ski-difficulty').click();
await p
.getByRole('group', { name: 'Hardest runs' })
.getByRole('button', { name: 'Advanced', exact: true })
.click();
await expect(p.locator('#tour-planner-ski-difficulty')).toContainText('Advanced');
},
},
{
target: (p) => skiOptions(p).getByRole('group', { name: 'Prefer' }),
act: async (p) => {
await skiOptions(p)
.getByRole('group', { name: 'Prefer' })
.getByRole('button', { name: 'Most skiing' })
.click();
await skiOptions(p).getByRole('group', { name: 'Pace' }).getByRole('button', { name: 'Relaxed' }).click();
},
},
{
// The first switch row: Ride lifts, the one every discipline has.
target: (p) => skiOptions(p).getByRole('button', { name: 'Ride lifts', exact: true }).locator('xpath=..'),
hover: async (p) => skiOptions(p).getByRole('button', { name: 'Ride lifts', exact: true }).hover(),
},
{
target: liftClasses,
act: async (p) => {
await liftClasses(p).getByRole('button', { name: 'Surface lifts' }).click();
await expect(liftClasses(p).getByRole('button', { name: 'Surface lifts' })).toHaveAttribute(
'aria-pressed',
'true'
);
},
},
],
cleanup: leaveEditor,
},
'tour-ski-fix-leg': {
guide: guide('tour-ski-fix-leg'),
// Every kind of lift left out, so the leg up from Selva has no way to go.
start: async (p) => {
await openSkiPlanner(p, { topo: true });
await newSkiDraft(p);
for (const name of LIFT_LABELS) await liftClasses(p).getByRole('button', { name }).click();
await placePoints(p, SKI_POINTS.slice(0, 2), true);
},
steps: [
{ target: (p) => legProblem(p).first() },
{
target: (p) => legProblem(p).first().getByRole('button').filter({ hasNotText: 'Draw straight' }).first(),
act: async (p) => {
await legProblem(p).first().getByRole('button').filter({ hasNotText: 'Draw straight' }).first().click();
// The leg is asked for again: planned, or failed for the next reason.
await p.waitForTimeout(2000);
await expect
.poll(async () => (await legSteps(p).count()) + (await legProblem(p).count()), { timeout: 180_000 })
.toBeGreaterThan(0);
await settle(p);
},
},
{
// The fix planned the leg: leave the lifts out again for a failure to draw straight.
prepare: async (p) => {
if ((await legProblem(p).count()) > 0) return;
for (const name of LIFT_LABELS) {
const badge = liftClasses(p).getByRole('button', { name });
if ((await badge.getAttribute('aria-pressed')) !== 'true') await badge.click();
}
await expect(legProblem(p).first()).toBeVisible({ timeout: 180_000 });
await settle(p);
},
target: (p) => legProblem(p).first().getByRole('button', { name: 'Draw straight' }),
act: async (p) => {
await legProblem(p).first().getByRole('button', { name: 'Draw straight' }).click();
await expect(legProblem(p)).toHaveCount(0, { timeout: 30_000 });
},
},
],
cleanup: leaveEditor,
},
'tour-ski-summary': {
guide: guide('tour-ski-summary'),
start: plannedSkiDraft,
steps: [
{ target: pisteRoute },
{ target: (p) => pisteRoute(p).getByTestId('ski-route-summary') },
{
target: (p) => overview(p).getByText('Ski time', { exact: true }).locator('xpath=..'),
},
],
cleanup: leaveEditor,
},
'tour-ski-pistes-layer': {
guide: guide('tour-ski-pistes-layer'),
start: async (p) => {
await openSkiPlanner(p);
await openSavedSkiTour(p);
await expect(p.getByTestId('map-overlay-pistes')).toHaveAttribute('aria-pressed', 'true');
},
steps: [
{ target: (p) => p.getByTestId('map-overlay-pistes') },
{
target: (p) => p.getByTestId('piste-key-button'),
act: async (p) => {
await p.getByTestId('piste-key-button').click();
await expect(p.getByRole('group', { name: 'Piste key' })).toBeVisible();
},
},
{
target: (p) => p.getByRole('group', { name: 'Piste key' }),
hover: async (p) => p.getByRole('link', { name: 'Full key at OpenSnowMap' }).hover(),
},
],
cleanup: async (p) => {
await p.keyboard.press('Escape');
},
},
'tour-ski-import': {
guide: guide('tour-ski-import'),
start: async (p) => {
await deleteSkiTours(p, SKI_TRACK.name);
await openSkiPlanner(p);
},
steps: [
{
prepare: async (p) => {
await rightRail(p).getByTestId('tour-rail-add').click();
await expect(p.getByRole('group', { name: 'Add tour' })).toBeVisible();
},
target: (p) => p.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: 'Import GPX or KML' }),
act: async (p) => {
const file = await skiTrackFixture(p.request);
const chooser = p.waitForEvent('filechooser');
await p.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: 'Import GPX or KML' }).click();
await (await chooser).setFiles(file);
await expect(importDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: (p) => importDialog(p).getByTestId('tour-type-select'),
act: async (p) => {
await importDialog(p).getByTestId('tour-type-select').click();
await p.getByTestId('tour-type-option-ski').click();
await expect(importDialog(p).getByTestId('tour-ski-fields')).toBeVisible();
},
},
{
target: (p) => importDialog(p).getByTestId('tour-ski-fields'),
act: async (p) => {
const fields = importDialog(p).getByTestId('tour-ski-fields');
await disciplines(fields).getByRole('button', { name: 'Cross-country', exact: true }).click();
await fields.getByRole('group', { name: 'Technique' }).getByRole('button', { name: 'Classic' }).click();
await expect(importDialog(p).getByTestId('gpx-preview-import')).toBeEnabled();
},
},
{
target: (p) => importDialog(p).getByTestId('gpx-preview-import'),
act: async (p) => {
await importDialog(p).getByTestId('gpx-preview-import').click();
await expect(importDialog(p)).toHaveCount(0, { timeout: 30_000 });
await expect(tourRow(p, /Vallunga cross-country/)).toBeVisible({ timeout: 30_000 });
},
},
{
prepare: async (p) => {
await tourRow(p, /Vallunga cross-country/).click();
await expect(p.getByTestId('ski-track-card')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
target: (p) => p.getByTestId('ski-track-card').getByRole('button', { name: 'Change' }),
act: async (p) => {
await p.getByTestId('ski-track-card').getByRole('button', { name: 'Change' }).click();
await expect(classifyDialog(p)).toBeVisible();
},
},
{
prepare: async (p) => {
await classifyDialog(p)
.getByRole('group', { name: 'Technique' })
.getByRole('button', { name: 'Skating' })
.click();
await expect(p.getByTestId('ski-classify-save')).toBeEnabled();
},
target: (p) => p.getByTestId('ski-classify-save'),
act: async (p) => {
await p.getByTestId('ski-classify-save').click();
await expect(p.getByText('Classification saved').first()).toBeVisible({ timeout: 30_000 });
await expect(p.getByTestId('ski-track-technique')).toContainText('Skating');
await settle(p);
},
},
],
cleanup: async (p) => {
await deleteSkiTours(p, SKI_TRACK.name);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
});
// A guide that fails, and the hero, which has no cleanup, leave their draft on
// the account. The page goes first, so no edit still settling can write the
// draft again after it is forgotten.
test.afterEach(async ({ page }) => {
await page.goto('about:blank').catch(() => undefined);
await forgetSkiDraft(page);
});
test('every registered ski and winter guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourSkiContext.guides].sort());
});
test('hero: trip-tour-ski', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourSkiContext.id, plannedSkiDraft);
});
for (const id of tripTourSkiContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+287
View File
@@ -0,0 +1,287 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { tripTourStagesContext, tripTourStagesGuides } from '../../src/help/contexts/tripTourStages';
import type { HelpGuide } from '../../src/help/types';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES, TOUR_PLACES, type ToursFixtures } from './tour-fixtures';
import { confirmDialog } from './trip-shared';
/**
* Actions for multi-day tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourStages.ts`. They run on "Summer in the
* Dolomites" (`ensureToursFixtures`): the three-stage Puez-Odle hut trek on
* days 4 to 6, and the Pisciadù via ferrata, a day tour whose route passes the
* Rifugio Pisciadù, which is where its first day is made to end.
*
* Splitting the ferrata hike is never saved: the draft lives only in this
* test's browser, and cleanup forgets it. Putting the hut trek on its days
* takes it off them first, through the API, and cleanup puts the seed's
* placements and start times back.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourStagesGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let fx: ToursFixtures;
// ── Where things are ──────────────────────────────────────────────────────────
/** The right rail of the Tours tab, "Tours of this trip". */
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
/** A tour's row in that rail; the option is named after the tour. */
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
/** The editor's Days field: Day tour or Multi-day tour. */
const stageMode = (page: Page) => page.getByTestId('tour-stage-mode');
const waypointsCard = (page: Page) => page.getByRole('region', { name: 'Waypoints', exact: true });
const waypointRow = (page: Page, name: string) =>
page.getByTestId('tour-planner-waypoint-row').filter({ hasText: name }).first();
/** The one context menu that is open; its entries are plain buttons. */
const openMenu = (page: Page) => page.getByRole('menu');
const menuItem = (page: Page, name: string | RegExp) =>
openMenu(page).getByRole('button', typeof name === 'string' ? { name, exact: true } : { name });
const stageItem = (page: Page, n: number) => page.locator(`[data-testid="tour-stage-item"][data-stage="${n}"]`);
// ── Getting there ─────────────────────────────────────────────────────────────
async function openToursTab(page: Page): Promise<number> {
const tripId = await openTourTrip(page, { tab: 'tour-planner' });
await expect(tourRow(page, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
return tripId;
}
/** The route's measures arrive after the points: nothing is drawn or counted before. */
async function waitForTheRoute(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
await expect(waypointsCard(page)).toBeVisible();
await expect(page.getByRole('region', { name: 'Tour summary', exact: true })).toBeVisible({ timeout: 120_000 });
await expect(page.locator('[data-testid="tour-planner-status"] .animate-spin')).toHaveCount(0, { timeout: 120_000 });
await settle(page);
}
/** Open a tour in the editor from its row; on the name, not on the plus or the fold at the row's end. */
async function openInEditor(page: Page, name: string): Promise<void> {
await tourRow(page, name).click({ position: { x: 80, y: 14 } });
await waitForTheRoute(page);
}
/** The draft kept on the account, and the copy older builds left in the browser. */
async function forgetDraft(page: Page, tripId: number): Promise<void> {
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
await page.evaluate((id) => {
try {
localStorage.removeItem(`tour-draft-${id}`);
} catch {
// Nothing kept, nothing to forget.
}
}, tripId);
}
type AssignmentRow = { id: number; place_id: number };
/** Takes the hut trek off every day it is on, so the guide can put it on again. */
async function clearHutTrekDays(api: APIRequestContext): Promise<void> {
const placeId = fx.tours.hutToHut;
if (!placeId) throw new Error(`no "${TOUR_NAMES.hutToHut}": the tour fixtures could not build it`);
for (const dayId of fx.dayIds) {
const res = await api.get(`/api/trips/${fx.tripId}/days/${dayId}/assignments`);
const { assignments = [] } = (await res.json()) as { assignments?: AssignmentRow[] };
for (const a of assignments.filter((x) => x.place_id === placeId)) {
const del = await api.delete(`/api/trips/${fx.tripId}/days/${dayId}/assignments/${a.id}`);
expect(del.ok(), `take the hut trek off day ${dayId}`).toBe(true);
}
}
}
// ── The guides ────────────────────────────────────────────────────────────────
let tripId = 0;
const SCRIPTS: Record<string, GuideScript> = {
'tour-multi-day': {
guide: guide('tour-multi-day'),
start: async (p) => {
tripId = await openToursTab(p);
await expect(tourRow(p, TOUR_NAMES.ferrata)).toBeVisible();
},
steps: [
{
target: (p) => tourRow(p, TOUR_NAMES.ferrata),
act: async (p) => {
await openInEditor(p, TOUR_NAMES.ferrata);
await expect(waypointRow(p, TOUR_PLACES.pisciadu)).toBeVisible();
},
},
{
target: (p) => stageMode(p).getByRole('button', { name: 'Multi-day tour', exact: true }),
act: async (p) => {
await stageMode(p).getByRole('button', { name: 'Multi-day tour', exact: true }).click();
await expect(
stageMode(p).getByText('In the menu of a stop, choose End stage here to split the route.')
).toBeVisible();
await settle(p);
},
},
{
prepare: async (p) => {
const row = waypointRow(p, TOUR_PLACES.pisciadu);
await row.scrollIntoViewIfNeeded();
await row.hover();
await row.getByRole('button', { name: 'More actions', exact: true }).click();
await expect(menuItem(p, 'End stage here')).toBeVisible();
},
target: (p) => menuItem(p, 'End stage here'),
act: async (p) => {
await menuItem(p, 'End stage here').click();
await expect(p.getByTestId('tour-stage-divider')).toHaveCount(2);
await settle(p);
},
},
{ target: waypointsCard },
{
target: (p) => p.getByTestId('tour-stage-row').first(),
act: async (p) => {
await p.getByTestId('tour-stage-row').first().click();
await expect(waypointRow(p, TOUR_PLACES.pisciadu).locator('button[aria-current="true"]')).toBeVisible();
await settle(p);
},
},
{
prepare: async (p) => {
await stageMode(p).getByRole('button', { name: 'Day tour', exact: true }).click();
await expect(confirmDialog(p).getByRole('button', { name: 'Remove stage ends', exact: true })).toBeVisible();
await settle(p);
},
target: confirmDialog,
act: async (p) => {
await confirmDialog(p).getByRole('button', { name: 'Remove stage ends', exact: true }).click();
await expect(p.getByTestId('tour-stage-divider')).toHaveCount(0);
await settle(p);
},
},
],
cleanup: (p) => forgetDraft(p, tripId),
},
'tour-stages-on-days': {
guide: guide('tour-stages-on-days'),
start: async (p) => {
await clearHutTrekDays(p.request);
tripId = await openToursTab(p);
await expect(tourRow(p, TOUR_NAMES.hutToHut).getByTestId('tour-days-badge')).toHaveCount(0);
},
steps: [
{
prepare: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut).hover();
},
target: (p) =>
tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: `Add to day: ${TOUR_NAMES.hutToHut}` }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut)
.getByRole('button', { name: `Add to day: ${TOUR_NAMES.hutToHut}` })
.click();
await expect(menuItem(p, /^Day 4(?!\d)/)).toBeVisible();
},
},
{
target: (p) => menuItem(p, /^Day 4(?!\d)/),
act: async (p) => {
await menuItem(p, /^Day 4(?!\d)/).click();
await expect(menuItem(p, 'Stages from this day on')).toBeVisible();
await settle(p);
},
},
{
target: (p) => menuItem(p, 'Stages from this day on'),
act: async (p) => {
await menuItem(p, 'Stages from this day on').click();
await expect(p.getByText('Stages from day 4 on.')).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, TOUR_NAMES.hutToHut).getByTestId('tour-days-badge')).toHaveText(/4, 5, 6/, {
timeout: 30_000,
});
await settle(p);
},
},
{
target: (p) => tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: 'Show stages', exact: true }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: 'Show stages', exact: true }).click();
await expect(p.getByTestId('tour-stage-item')).toHaveCount(3, { timeout: 30_000 });
await expect(stageItem(p, 3).getByTestId('tour-stage-day')).toHaveAttribute(
'data-stage-day',
String(fx.dayIds[5])
);
await settle(p);
},
},
{
target: (p) => stageItem(p, 3).getByTestId('tour-stage-day'),
act: async (p) => {
// The menu of days opens and closes again: the pictures move no stage.
await stageItem(p, 3).getByTestId('tour-stage-day').click();
await expect(openMenu(p)).toBeVisible();
// A click beside the menu closes it, on the rail's heading, which does
// nothing by itself. Escape does not reach the menu here: the stage list
// stops every key press that bubbles out of it, and the portalled menu
// bubbles through it in React's tree. The pill itself is no way out
// either, as the menu is pushed up over it at the foot of the window.
await toursRail(p).getByRole('heading', { name: 'Tours of this trip', exact: true }).click();
await expect(openMenu(p)).toHaveCount(0);
await settle(p);
},
},
{
target: (p) => stageItem(p, 2).getByTestId('tour-stage-open'),
act: async (p) => {
await stageItem(p, 2).getByTestId('tour-stage-open').click();
await waitForTheRoute(p);
await expect(waypointRow(p, TOUR_PLACES.genova).locator('button[aria-current="true"]')).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
},
],
cleanup: async (p) => {
// The seed's placements come back with their start times (08:30, 08:00, 08:00).
await clearHutTrekDays(p.request);
fx = await ensureToursFixtures(p.request);
await forgetDraft(p, tripId);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
test.setTimeout(600_000);
fx = await ensureToursFixtures(request);
});
test('every registered stages guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourStagesContext.guides].sort());
});
/** The hut trek open in the editor: its three stages in the list of points and in the summary. */
async function heroStart(page: Page): Promise<void> {
await openToursTab(page);
await openInEditor(page, TOUR_NAMES.hutToHut);
await expect(page.getByTestId('tour-stage-divider')).toHaveCount(3);
await expect(page.getByTestId('tour-stage-row')).toHaveCount(3);
await settle(page);
}
test('hero: trip-tour-stages', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourStagesContext.id, heroStart);
});
for (const id of tripTourStagesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+386
View File
@@ -0,0 +1,386 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourWalkContext, tripTourWalkGuides } from '../../src/help/contexts/tripTourWalk';
import type { HelpGuide } from '../../src/help/types';
import { ensureWalkHours, GOOGLE_WALK, resetKyotoVisits, WALK_HOURS } from './fixtures-trip-tour-walk';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES } from './tour-fixtures';
import { openTrip } from './trip-shared';
/**
* Actions for the city walk, keyed by the ids in
* `src/help/contexts/tripTourWalk.ts`. They run on "Summer in the Dolomites"
* from `ensureToursFixtures`, whose Bolzano old town walk is on day 1 at
* 15:00 with seven stops (the museum a must, the Dominican Church optional),
* and on the Higashiyama walk on day 9 of the seeded "Autumn in Japan", the
* day before the picture day, where its stops are checked off. Nothing a
* guide does here is saved: every edit stays a draft in the planner and is
* discarded in `cleanup`, and the marks of the check-off guide are taken
* back through the API.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourWalkGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
const WALK = TOUR_NAMES.cityWalk;
const KYOTO_WALK = TOUR_NAMES.kyotoWalk;
const MUSEUM = WALK_HOURS.stop;
// ── Where things are ──────────────────────────────────────────────────────────
/** A tour's row in Tours of this trip: an option named by the tour. */
const tourRow = (page: Page, name: string) => page.getByRole('option', { name, exact: true });
/** A row of the editor's Waypoints card. */
const waypointRow = (page: Page, name: string) =>
page.getByTestId('tour-planner-waypoint-row').filter({ hasText: name }).first();
/** The menu a row's More actions opens; only one is open at a time. */
const openMenu = (page: Page) => page.getByRole('menu').last();
/** The station dialog: the shared dialog shell around `station-dialog`. */
const stationDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByTestId('station-dialog') });
/** The Google Maps import dialog, by its title. */
const importDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Walk from a Google Maps link' });
/**
* A stop row of the day plan. Every row of a day card is a div with
* `role="button"`; a stop, a tour included, is the one whose name opens with
* the label of the lock on its picture (as in `trip-days.guide.ts`).
*/
const planRow = (page: Page, name: string) =>
page
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first();
/** The walk's stops in its detail card, as the section that holds them, its head with the counts included. */
const stationsSection = (page: Page) => page.getByTestId('walk-stations').locator('xpath=ancestor::section[1]');
const stationRow = (page: Page, name: string) => page.getByTestId('walk-station').filter({ hasText: name }).first();
// ── Getting there ─────────────────────────────────────────────────────────────
/**
* A draft is kept on the account per trip, and one left over from an earlier
* guide would open in the editor instead of Plan new tour. Older builds kept
* it in the browser as `tour-draft-<tripId>`. Every page these guides open
* starts without either.
*/
async function forgetDrafts(page: Page): Promise<void> {
const res = await page.request.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number }[] };
for (const trip of trips) await page.request.delete(`/api/trips/${trip.id}/tour-draft`).catch(() => undefined);
await page.addInitScript(() => {
try {
for (const key of Object.keys(localStorage)) if (key.startsWith('tour-draft-')) localStorage.removeItem(key);
} catch {
// Storage blocked: there is no draft to restore either.
}
});
}
/** The Tours tab of the Dolomites trip, nothing open in the editor, the trip's tours listed. */
async function openToursTab(page: Page): Promise<void> {
await forgetDrafts(page);
await openTourTrip(page, { tab: 'tour-planner' });
await expect(page.getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 30_000 });
await expect(tourRow(page, WALK)).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/**
* The Plan tab with day 1 selected. The planner reopens a trip on the tab it
* was last on in this browser tab (`trip-tab-<id>` in sessionStorage), so
* after the Tours tab a plain visit lands on Tours again and day 1 has no
* header to click; the remembered tab is forgotten first.
*/
async function openPlanTab(page: Page): Promise<void> {
await page.evaluate(() => {
try {
for (const key of Object.keys(sessionStorage)) if (key.startsWith('trip-tab-')) sessionStorage.removeItem(key);
} catch {
// Storage blocked: nothing is remembered either, and the trip opens on Plan.
}
});
await openTourTrip(page, { day: 1 });
}
/**
* The Bolzano walk open in the editor, with its route current: the arrivals
* under the stops only appear once the route belongs to the stops as they are.
*/
async function openWalk(page: Page): Promise<void> {
await openToursTab(page);
await tourRow(page, WALK).click();
await expect(page.getByTestId('tour-planner-waypoint-row')).toHaveCount(7, { timeout: 30_000 });
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('walk-station-schedule').first()).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** Select a stop of the open walk, so its row actions stay out while the pointer moves. */
async function selectStop(page: Page, name: string): Promise<void> {
await waypointRow(page, name).getByTestId('tour-planner-waypoint-name').click();
await expect(waypointRow(page, name).getByRole('button', { name: 'More actions' })).toBeVisible();
await settle(page);
}
/**
* Leave the editor without saving: Discard at the foot of the left column,
* and Discard changes where it asks. A dialog still open over it goes first.
*/
async function discardDraft(page: Page): Promise<void> {
for (let i = 0; i < 3 && (await page.getByRole('dialog').count()) > 0; i++) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
const discard = page.getByRole('button', { name: 'Discard', exact: true });
if ((await discard.count()) > 0 && (await discard.first().isEnabled())) {
await discard.first().click();
const confirm = page.getByRole('button', { name: 'Discard changes', exact: true });
if (await confirm.isVisible({ timeout: 2_000 }).catch(() => false)) await confirm.click();
}
await expect(page.getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 15_000 });
await forgetDrafts(page);
}
/** The first place the search found that is not a stop yet, a highlight where there is one. */
async function firstNewPoi(page: Page): Promise<Locator> {
const rows = page.getByTestId('tour-poi-list').locator('li').filter({ hasNotText: 'In the tour' });
const starred = rows.filter({ has: page.getByTestId('tour-poi-highlight') });
return ((await starred.count()) > 0 ? starred : rows).first().getByRole('button');
}
const only = (target: (p: Page) => Locator) => ({ target });
const SCRIPTS: Record<string, GuideScript> = {
'tour-walk-plan': {
guide: guide('tour-walk-plan'),
start: openToursTab,
steps: [
{
prepare: async (p) => {
await p.getByRole('button', { name: 'Plan new tour' }).first().click();
await expect(p.getByTestId('tour-type-select')).toBeVisible({ timeout: 15_000 });
await p.getByTestId('tour-type-select').click();
await expect(p.getByTestId('tour-type-option-walk')).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('group', { name: 'Tour type' }),
hover: async (p) => {
await p.getByTestId('tour-type-option-walk').hover();
},
act: async (p) => {
await p.getByTestId('tour-type-option-walk').click();
await expect(p.getByTestId('tour-type-select')).toContainText('City / sightseeing walk');
await settle(p);
},
},
{
// The two ways to add a stop, side by side under the empty Waypoints card.
target: (p) => p.getByTestId('tour-planner-add-place').locator('xpath=..'),
act: async (p) => {
// Drawing seven stops by clicking the map is no picture of its own; the
// rest of the guide works on the walk the fixtures planned that way.
await discardDraft(p);
await tourRow(p, WALK).click();
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(7, { timeout: 30_000 });
await expect(p.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
},
{
prepare: (p) => selectStop(p, MUSEUM),
target: (p) => waypointRow(p, MUSEUM).getByRole('button', { name: 'More actions' }),
act: async (p) => {
await waypointRow(p, MUSEUM).getByRole('button', { name: 'More actions' }).click();
await openMenu(p).getByRole('button', { name: 'Edit stop' }).click();
await expect(stationDialog(p)).toBeVisible();
await settle(p);
},
},
only((p) => stationDialog(p).getByLabel('Stay in minutes')),
{
target: (p) => stationDialog(p).getByRole('group', { name: 'Importance' }),
act: async (p) => {
await stationDialog(p).getByTestId('station-save').click();
await expect(stationDialog(p)).toHaveCount(0);
await settle(p);
},
},
only((p) => p.getByTestId('tour-planner-overview')),
],
cleanup: discardDraft,
},
'tour-walk-sights': {
guide: guide('tour-walk-sights'),
start: openWalk,
steps: [
{
target: (p) => p.getByTestId('tour-poi-themes'),
act: async (p) => {
await p.getByTestId('tour-poi-theme-culture').click();
await expect(p.getByTestId('tour-poi-theme-culture')).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
only((p) => p.getByTestId('tour-poi-categories')),
{
target: (p) => p.getByTestId('tour-poi-search'),
act: async (p) => {
await p.getByTestId('tour-poi-search').click();
// TREK's places index first, OpenStreetMap behind it, through the server.
await expect(p.getByTestId('tour-poi-list')).toBeVisible({ timeout: 90_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await (await firstNewPoi(p)).click();
await expect(p.getByTestId('tour-poi-overlay')).toBeVisible({ timeout: 15_000 });
await settle(p);
},
target: (p) => p.getByTestId('tour-poi-insert'),
},
{
target: (p) => p.getByTestId('tour-poi-add'),
act: async (p) => {
await p.getByTestId('tour-poi-add').click();
await expect(p.getByTestId('tour-poi-outcome')).toBeVisible({ timeout: 15_000 });
// The route is planned again with the new stop; the notice then says by how much.
await expect(p.getByTestId('tour-poi-outcome')).not.toContainText('being planned again', { timeout: 60_000 });
await settle(p);
},
},
],
cleanup: discardDraft,
},
'tour-walk-day-times': {
guide: guide('tour-walk-day-times'),
// Open, the walk's row in Tours of this trip is selected, so its + stays in sight.
start: openWalk,
steps: [
only((p) => tourRow(p, WALK).getByRole('button', { name: `Add to day: ${WALK}` })),
{
prepare: async (p) => {
await openPlanTab(p);
await expect(planRow(p, WALK)).toBeVisible({ timeout: 20_000 });
await settle(p);
},
target: (p) => planRow(p, WALK),
act: openWalk,
},
only((p) => p.getByTestId('walk-day-line')),
only((p) => waypointRow(p, MUSEUM).getByTestId('walk-station-schedule')),
],
cleanup: discardDraft,
},
'tour-walk-google-import': {
guide: guide('tour-walk-google-import'),
start: openToursTab,
steps: [
{
prepare: async (p) => {
await p.getByTestId('tour-rail-add').click();
await expect(p.getByRole('button', { name: 'Import walk from Google Maps' })).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('button', { name: 'Import walk from Google Maps' }),
act: async (p) => {
await p.getByRole('button', { name: 'Import walk from Google Maps' }).click();
await expect(importDialog(p)).toBeVisible();
await p.getByTestId('walk-import-url').fill(GOOGLE_WALK.link);
await settle(p);
},
},
{
// The field with the link in it and Read the link under it.
target: (p) => importDialog(p),
act: async (p) => {
await p.getByTestId('walk-import-read').click();
await expect(p.getByTestId('walk-import-preview')).toBeVisible({ timeout: 20_000 });
await expect(p.getByTestId('walk-import-station')).toHaveCount(GOOGLE_WALK.count);
await settle(p);
},
},
only((p) => p.getByTestId('walk-import-preview').locator('section').first()),
{
target: (p) => p.getByTestId('walk-import-load'),
act: async (p) => {
await p.getByTestId('walk-import-load').click();
await expect(importDialog(p)).toHaveCount(0);
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(GOOGLE_WALK.count, { timeout: 20_000 });
// The way on foot between the stops is TREK's own: wait for the router's line.
await expect(p.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
},
],
cleanup: discardDraft,
},
'tour-walk-check-off': {
guide: guide('tour-walk-check-off'),
start: async (p) => {
await resetKyotoVisits(p.request);
await openTrip(p, { day: 9 });
await expect(planRow(p, KYOTO_WALK)).toBeVisible({ timeout: 20_000 });
},
steps: [
{
target: (p) => planRow(p, KYOTO_WALK),
act: async (p) => {
// On the name: the row's centre can land on one of its badges.
await planRow(p, KYOTO_WALK).getByText(KYOTO_WALK, { exact: true }).first().click();
await expect(p.getByTestId('walk-stations')).toBeVisible({ timeout: 20_000 });
await expect(p.getByTestId('walk-station')).toHaveCount(7, { timeout: 20_000 });
await settle(p);
},
},
only(stationsSection),
{
target: (p) => stationRow(p, 'Kiyomizu-dera').getByRole('button', { name: 'Mark visited' }),
act: async (p) => {
await stationRow(p, 'Kiyomizu-dera').getByRole('button', { name: 'Mark visited' }).click();
await expect(stationRow(p, 'Kiyomizu-dera').getByRole('button', { name: 'Take back' })).toBeVisible({
timeout: 15_000,
});
await stationRow(p, 'Sannenzaka').getByRole('button', { name: 'Mark visited' }).click();
await expect(stationRow(p, 'Sannenzaka').getByRole('button', { name: 'Take back' })).toBeVisible({
timeout: 15_000,
});
await stationRow(p, 'Kodai-ji').getByRole('button', { name: 'Skip' }).click();
await expect(stationRow(p, 'Kodai-ji').getByRole('button', { name: 'Take back' })).toBeVisible({
timeout: 15_000,
});
await settle(p);
},
},
only((p) => stationsSection(p).locator('> div').first()),
],
cleanup: async (p) => {
await resetKyotoVisits(p.request);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
await ensureWalkHours(request);
});
test('every registered city walk guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourWalkContext.guides].sort());
});
test('hero: trip-tour-walk', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourWalkContext.id, openWalk);
});
for (const id of tripTourWalkContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
+420
View File
@@ -0,0 +1,420 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripToursContext, tripToursGuides } from '../../src/help/contexts/tripTours';
import type { HelpGuide } from '../../src/help/types';
import {
backOnDay,
clearHiddenTours,
ensureLunch,
LUNCH,
modeTab,
offAllDays,
openToursPlan,
removeByName,
resetTime,
tourIdOf,
} from './fixtures-trip-tours';
import { beat, captureGuide, captureHero, dragLoop, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, TOUR_GPX, TOUR_NAMES, tourGpxFixture, type ToursFixtures } from './tour-fixtures';
import { closeMenu, modal, selectDay } from './trip-shared';
/**
* Actions for the tours on the Plan tab, keyed by the ids in
* `src/help/contexts/tripTours.ts`. They run on "Summer in the Dolomites"
* (`ensureToursFixtures`): ten days, the tours on days 1 to 9, day 10 empty,
* and two tours on no day (the MTB loop and the recorded Seceda walk), which
* the add-to-day guide puts on day 10. What a guide writes it takes back in
* `cleanup`: the fixtures never rebuild a tour that exists, so a tour left on
* the wrong day would stay there for every later picture.
*/
const guide = (id: string): HelpGuide => {
const g = tripToursGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let fx: ToursFixtures;
// ── Where things are ──────────────────────────────────────────────────────────
/** The list of the right column; its rows are options named by the tour. */
const tourList = (page: Page) => page.getByRole('listbox', { name: 'Tours', exact: true });
const tourRow = (page: Page, name: string) => tourList(page).getByRole('option', { name, exact: true });
const importButton = (page: Page) => page.getByRole('button', { name: 'Import GPX or KML' });
/** The head band of the list: the import, the filter and the hidden-on-the-map line. */
const listHead = (page: Page) =>
page
.locator('div.border-b.border-edge-faint')
.filter({ has: importButton(page) })
.last();
const filterButton = (page: Page) => listHead(page).getByRole('button', { name: 'Show', exact: true });
const filterMenu = (page: Page) => listHead(page).getByRole('group', { name: 'Show', exact: true });
/** A day card's head band (`.dp-day-header`, a div with the button role). */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) });
const dayCard = (page: Page, n: number) => dayHeader(page, n).locator('xpath=..');
/**
* A row of the days column. Its accessible name opens with the label of the
* lock on its picture, a tour's tile included, which tells it from the note and
* booking rows beside it.
*/
const planRow = (page: Page, name: string) =>
page
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first();
/** The menu of a row of the days column or of a tour's +; a tooltip shares the popover class. */
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last();
/** The menu the round + of a tour row opens (a ContextMenu, role menu). */
const dayMenu = (page: Page) => page.getByRole('menu').last();
/** The tour's card over the map (DetailShell), known by its Trip days section. */
const tourCard = (page: Page) =>
page
.locator('div.rounded-\\[20px\\]')
.filter({ has: page.getByText('Trip days', { exact: true }) })
.first();
const previewDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByTestId('gpx-preview-import') });
async function rowMenu(page: Page, name: string): Promise<void> {
const row = planRow(page, name);
await row.scrollIntoViewIfNeeded();
await row.hover();
await row.getByRole('button', { name: 'More options' }).click();
await expect(menu(page).getByRole('button', { name: 'Edit', exact: true })).toBeVisible();
await beat(page, 300);
}
async function hoverRow(page: Page, name: string): Promise<void> {
await tourRow(page, name).scrollIntoViewIfNeeded();
await tourRow(page, name).hover();
await beat(page, 200);
}
const id = async (page: Page, name: string): Promise<number> => {
const found = await tourIdOf(page.request, fx.tripId, name);
if (!found) throw new Error(`the tour "${name}" is missing: see the [tour-fixtures] warnings`);
return found;
};
const only = (target: (p: Page) => Locator) => ({ target });
const openTours = (p: Page) => openToursPlan(p, { tours: true }).then(() => undefined);
/**
* The MTB loop and the Seceda walk on no day, as the fixtures made them. A
* guide that failed before its cleanup leaves them on day 10, and then the
* Unplanned filter holds the walk alone and the add-to-day picture shows a
* day that is no longer empty.
*/
async function noDayTours(page: Page): Promise<void> {
for (const name of [TOUR_NAMES.mtb, TOUR_NAMES.recorded]) {
await offAllDays(page.request, fx.tripId, fx.dayIds, await id(page, name));
}
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-show-list': {
guide: guide('tour-show-list'),
start: (p) => openToursPlan(p).then(() => undefined),
steps: [
{
target: (p) => modeTab(p, 'Tours'),
act: async (p) => {
await modeTab(p, 'Tours').click();
await expect(tourRow(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only((p) => tourRow(p, TOUR_NAMES.hutToHut)),
{
target: (p) => tourRow(p, TOUR_NAMES.hutToHut),
act: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut).click();
await expect(tourCard(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
only(tourCard),
only((p) => modeTab(p, 'Places')),
],
},
'tour-add-to-day': {
guide: guide('tour-add-to-day'),
start: async (p) => {
await noDayTours(p);
await openTours(p);
},
steps: [
{
prepare: (p) => hoverRow(p, TOUR_NAMES.mtb),
target: (p) => tourRow(p, TOUR_NAMES.mtb).getByRole('button', { name: 'Add to day', exact: true }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.mtb).getByRole('button', { name: 'Add to day', exact: true }).click();
await expect(dayMenu(p)).toBeVisible();
await settle(p);
},
},
{
target: dayMenu,
act: async (p) => {
await dayMenu(p)
.getByRole('button', { name: /^Day 10,/ })
.click();
await expect(tourRow(p, TOUR_NAMES.mtb).getByTestId('tour-days-badge')).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
only((p) => tourRow(p, TOUR_NAMES.mtb)),
{
// Day 10 is the last card of the days column, below the fold when the
// trip opens, and a drop can only land where the pointer can reach.
prepare: async (p) => {
await dayHeader(p, 10).scrollIntoViewIfNeeded();
await settle(p);
},
target: (p) => tourRow(p, TOUR_NAMES.recorded),
dropTo: (p) => dayHeader(p, 10),
clip: async (p, frame) => {
await dragLoop(p, tourRow(p, TOUR_NAMES.recorded), dayHeader(p, 10), frame);
await expect(dayCard(p, 10).getByText(TOUR_NAMES.recorded).first()).toBeVisible({ timeout: 15_000 });
await settle(p);
await frame();
await frame();
},
},
{
// The hut trek has three stages, so the day it is given asks how next.
prepare: async (p) => {
await hoverRow(p, TOUR_NAMES.hutToHut);
await tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: 'Add to day', exact: true }).click();
await expect(dayMenu(p)).toBeVisible();
await dayMenu(p)
.getByRole('button', { name: /^Day 8,/ })
.click();
await expect(dayMenu(p).getByRole('button', { name: /^Stages from this day on/ })).toBeVisible({
timeout: 10_000,
});
await settle(p);
},
target: dayMenu,
// Nothing chosen: the hut trek keeps its days 4 to 6.
act: closeMenu,
},
],
cleanup: noDayTours,
},
'tour-day-times': {
guide: guide('tour-day-times'),
start: async (p) => {
await ensureLunch(p.request, fx.tripId, fx.dayIds[1]);
await openTours(p);
},
steps: [
{
prepare: (p) => rowMenu(p, TOUR_NAMES.horse),
target: menu,
act: async (p) => {
await menu(p).getByRole('button', { name: 'Edit', exact: true }).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('10:30');
await times.first().blur();
await expect(modal(p).getByTestId('tour-day-end-hint')).toBeVisible();
await beat(p, 300);
},
// The two fields and the worked-out end under them share one block.
target: (p) => modal(p).getByTestId('tour-day-end-hint').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(planRow(p, TOUR_NAMES.horse).getByText(/10:30/)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
only((p) => planRow(p, TOUR_NAMES.horse).getByTestId('tour-day-overrun')),
],
cleanup: async (p) => {
await resetTime(p.request, fx.tripId, fx.dayIds[1], await id(p, TOUR_NAMES.horse), '10:00');
await removeByName(p.request, fx.tripId, LUNCH.name);
},
},
'tour-remove-from-day': {
guide: guide('tour-remove-from-day'),
start: openTours,
steps: [
{
prepare: (p) => rowMenu(p, TOUR_NAMES.run),
target: (p) => menu(p).getByRole('button', { name: 'Remove from day', exact: true }),
act: async (p) => {
await menu(p).getByRole('button', { name: 'Remove from day', exact: true }).click();
await expect(planRow(p, TOUR_NAMES.run)).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
only((p) => tourRow(p, TOUR_NAMES.run)),
{
// The card's day buttons belong to the open day, so day 1 is opened first.
prepare: async (p) => {
await selectDay(p, 1);
await tourRow(p, TOUR_NAMES.cityWalk).click();
await expect(tourCard(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 15_000 });
await settle(p);
},
target: (p) => tourCard(p).getByRole('button', { name: 'Remove from Day' }),
},
],
cleanup: async (p) => {
await backOnDay(p.request, fx.tripId, fx.dayIds[7], await id(p, TOUR_NAMES.run), '07:00');
},
},
'tour-filter-list': {
guide: guide('tour-filter-list'),
// Under Unplanned the Type group lists the types of the unplanned tours
// only, so Bike tour is there only while the MTB loop is on no day.
start: async (p) => {
await noDayTours(p);
await openTours(p);
},
steps: [
{
target: filterButton,
act: async (p) => {
await filterButton(p).click();
await expect(filterMenu(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => filterMenu(p).getByRole('group', { name: 'Status', exact: true }),
act: async (p) => {
await filterMenu(p)
.getByRole('group', { name: 'Status', exact: true })
.getByRole('button', { name: /^Unplanned/ })
.click();
await expect(filterButton(p)).toContainText('Unplanned');
await settle(p);
},
},
{
target: filterMenu,
act: async (p) => {
await filterMenu(p)
.getByRole('group', { name: 'Type', exact: true })
.getByRole('button', { name: /^Bike tour/ })
.click();
await expect(tourRow(p, TOUR_NAMES.recorded)).toHaveCount(0);
await p.keyboard.press('Escape');
await expect(filterMenu(p)).toHaveCount(0);
await settle(p);
},
},
only(filterButton),
],
},
'tour-hide-on-map': {
guide: guide('tour-hide-on-map'),
start: async (p) => {
await openTours(p);
await clearHiddenTours(p);
},
steps: [
{
prepare: (p) => hoverRow(p, TOUR_NAMES.roadBike),
target: (p) => tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Hide on the map' }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Hide on the map' }).click();
await expect(tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Show on the map' })).toBeVisible();
// Off the row, so the picture shows the eye a hidden tour keeps.
await p.mouse.move(VIEWPORT.width / 2, VIEWPORT.height / 2);
await settle(p);
},
},
{
target: (p) => tourRow(p, TOUR_NAMES.roadBike),
hover: async (p) => {
await tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Show on the map' }).hover();
},
},
{
target: (p) => listHead(p).getByRole('status'),
act: async (p) => {
await listHead(p).getByRole('button', { name: 'Show all', exact: true }).click();
await expect(listHead(p).getByRole('status')).toHaveCount(0);
await settle(p);
},
},
],
cleanup: clearHiddenTours,
},
'tour-import-plan-tab': {
guide: guide('tour-import-plan-tab'),
start: async (p) => {
await removeByName(p.request, fx.tripId, TOUR_GPX.rasciesa.name);
await openToursPlan(p);
},
steps: [
{
target: (p) => modeTab(p, 'Tours'),
act: async (p) => {
await modeTab(p, 'Tours').click();
await expect(importButton(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: importButton,
act: async (p) => {
const file = await tourGpxFixture('rasciesa');
const chooser = p.waitForEvent('filechooser');
await importButton(p).click();
await (await chooser).setFiles(file);
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: (p) =>
previewDialog(p)
.locator('section')
.filter({ has: p.getByText('Import as', { exact: true }) })
.first(),
act: async (p) => {
await previewDialog(p).getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 30_000 });
await expect(tourRow(p, TOUR_GPX.rasciesa.name)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: (p) => removeByName(p.request, fx.tripId, TOUR_GPX.rasciesa.name),
},
};
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
fx = await ensureToursFixtures(request);
});
test('every registered Plan-tab tours guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripToursContext.guides].sort());
});
test('hero: trip-tours', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripToursContext.id, openTours);
});
for (const gid of tripToursContext.guides) {
test(`pictures: ${gid}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[gid]);
});
}
+205 -3
View File
@@ -20,8 +20,25 @@ const guide = (id: string): HelpGuide => {
return g
}
/** The flight the seed creates. Its row on day 1 is its title, then the Departure span label. */
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND Departure/ }
/**
* The flight the seed creates. Its row on day 1 is its title, then the time
* pill (13:05), then the Departure span label: the time came first in 3ff88062f.
*/
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND .*Departure/ }
/** The train the day fixtures put on day 5: platform, seat and a booking code for the detail's tiles. */
const TRAIN = { title: 'Nozomi 21 Tokyo → Kyoto' }
/**
* What car-stops creates on day 3, the Hakone day. Every end and stop is one of
* the trip's own places, which the picker offers before anything is typed, so
* the guide needs no geocoder. The car comes back where it was collected.
*/
const CAR = {
title: 'Rental car through Hakone',
base: 'Hakone Open-Air Museum',
stops: ['Owakudani', 'Lake Ashi'] as const,
}
/** The file the export writes: the trip's title made safe, then the section (bookingsExport.ts). */
const CSV_FILE = 'autumn-in-japan-transports.csv'
/**
* The connection the fixtures store on day 6, so no guide has to search for one
* first. Its card is named by its title, the two ends with an arrow between them.
@@ -56,6 +73,13 @@ const toolbarButton = (page: Page, name: 'Filter' | 'View options') => page.getB
const toolbarMenu = (page: Page, name: 'Filter' | 'View options') => toolbarButton(page, name).locator('xpath=../..').getByRole('menu')
/** A transport's row in the day plan; the whole row is the button that opens it. */
const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first()
/** The same row found by the title it shows, for a check that does not care about its pills. */
const transportRowTitled = (page: Page, title: string) =>
page.locator('[data-dp="transport-row"]').filter({ hasText: title }).first()
/** The tab bar's search box, and the bar it sits in (search, filter, views, options, export). */
const searchBox = (page: Page) => page.getByRole('textbox', { name: 'Search', exact: true }).first()
const bookingsBar = (page: Page) => searchBox(page).locator('xpath=../..')
const exportButton = (page: Page) => page.getByRole('button', { name: /^Export (as spreadsheet|the \d+ of \d+ shown rows) \(CSV\)$/ })
/** The booking a click on a row or a card opens, named by the booking's title. */
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/** The Edit at the foot of that booking, which leads on to its form. */
@@ -100,6 +124,25 @@ async function searchLocation(page: Page, box: Locator, query: string, match: Re
await expect(box).toHaveValue(match, { timeout: 15_000 })
}
/**
* The transport form's location picker offers the trip's own places while the
* box is empty, in the same list the search answers in, so a place is picked
* without a geocoder in the way.
*/
async function pickTripPlace(page: Page, box: Locator, name: string): Promise<void> {
await box.click()
const option = suggestions(box).locator('button').filter({ hasText: name }).first()
await expect(option, `the trip's place "${name}" is not offered`).toBeVisible({ timeout: 10_000 })
await option.click()
await expect(box).toHaveValue(name, { timeout: 10_000 })
}
/** The car's stops block: its eyebrow, the stop rows and Add stop under it. */
const carStops = (page: Page) => modal(page).getByText('Stops along the way', { exact: true }).locator('xpath=..')
/** The Rename box the detail's title opens; its head names it. */
const renameDialog = (page: Page) => page.getByRole('dialog', { name: 'Rename' })
/** The transit search offers the day's own stops as soon as the box has focus. */
async function pickStop(page: Page, box: Locator, name: string): Promise<void> {
await box.click()
@@ -303,6 +346,88 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByTitle(p, TAXI.title),
},
'car-stops': {
guide: guide('car-stops'),
start: p => openTrip(p),
steps: [
{
// Day 3 is the Hakone day: the three places the drive uses are its own.
prepare: p => openDayMenu(p, 3),
target: p => dayMenu(p).getByRole('button', { name: 'Add transport' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Add transport' }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typePill(p).click()
await expect(typeMenu(p)).toBeVisible()
await beat(p, 300)
},
target: p => typeMenu(p).getByRole('button', { name: /^Car$/ }).first(),
act: async p => {
await typeMenu(p).getByRole('button', { name: /^Car$/ }).first().click()
await expect(typeMenu(p)).toHaveCount(0)
await expect(carStops(p)).toBeVisible()
await expect(modal(p).getByText('Pickup time', { exact: true })).toBeVisible()
await settle(p)
},
},
{
// Times before any stop exists: with no stop rows the two time boxes
// are Pickup time and Return time, in that order.
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), CAR.title)
await pickTripPlace(p, locationBox(p, 0), CAR.base)
await pickTripPlace(p, locationBox(p, 1), CAR.base)
await modal(p).getByPlaceholder('00:00').nth(0).fill('09:00')
await modal(p).getByPlaceholder('00:00').nth(1).fill('18:00')
await settle(p)
},
target: p => block(p, 'Route'),
},
{
// The stops come after From and To in the form, so their pickers are
// the third and fourth location boxes.
prepare: async p => {
for (const [i, name] of CAR.stops.entries()) {
await modal(p).getByRole('button', { name: 'Add stop', exact: true }).click()
await pickTripPlace(p, locationBox(p, 2 + i), name)
}
await settle(p)
},
target: carStops,
},
{
// The second stop's Move up: the first one's is disabled.
target: p => modal(p).getByRole('button', { name: 'Move up', exact: true }).nth(1),
act: async p => {
await modal(p).getByRole('button', { name: 'Move up', exact: true }).nth(1).click()
await expect(locationBox(p, 2)).toHaveValue(CAR.stops[1])
await expect(locationBox(p, 3)).toHaveValue(CAR.stops[0])
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
const { tripId } = seededTrip()
await expect.poll(async () => {
const res = await p.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: ReservationRow[] } | ReservationRow[]
const list = Array.isArray(body) ? body : (body.reservations ?? [])
return list.some(r => r.title === CAR.title)
}, { timeout: 20_000 }).toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, CAR.title),
},
'import-transport-file': {
guide: guide('import-transport-file'),
// See import-booking-file: the extractor and no addon, and the mail has to
@@ -509,6 +634,55 @@ const SCRIPTS: Record<string, GuideScript> = {
await resetLegModes(p, 0)
},
},
'transport-details': {
guide: guide('transport-details'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
target: p => card(p, TRAIN.title),
act: async p => {
await card(p, TRAIN.title).click()
await expect(bookingDetail(p, TRAIN.title)).toBeVisible()
await settle(p)
},
},
// Pointed at, not clicked: a headless clipboard refuses the write and
// the picture would carry the failure toast.
only(p => bookingDetail(p, TRAIN.title).getByRole('button', { name: 'Copy booking code' })),
// The tile row: departure and arrival with their stations, then Platform and Seat.
only(p => bookingDetail(p, TRAIN.title).getByText('Platform', { exact: true }).first().locator('xpath=ancestor::div[2]')),
{
prepare: async p => {
await bookingDetail(p, TRAIN.title).getByRole('button', { name: TRAIN.title, exact: true }).click()
await expect(renameDialog(p)).toBeVisible()
await beat(p, 300)
},
target: renameDialog,
// Cancel, not Save: the train keeps the name the other guides find it by.
act: async p => {
await renameDialog(p).getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(renameDialog(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => bookingDetail(p, TRAIN.title).getByRole('button', { name: 'On map', exact: true }),
act: async p => {
await bookingDetail(p, TRAIN.title).getByRole('button', { name: 'On map', exact: true }).click()
// The Plan opens on day 5 with the train's line switched on.
await expect(transportRowTitled(p, TRAIN.title).getByRole('button', { name: 'Hide booking routes' })).toBeVisible({ timeout: 20_000 })
await settle(p)
await p.waitForTimeout(1500)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
await p.evaluate(id => {
try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ }
}, tripId)
},
},
'edit-transport': {
guide: guide('edit-transport'),
start: p => openTrip(p),
@@ -599,6 +773,34 @@ const SCRIPTS: Record<string, GuideScript> = {
}, tripId)
},
},
'export-transports-csv': {
guide: guide('export-transports-csv'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
// A search in force, so the picture shows the tab narrowed to what the file will hold.
prepare: async p => {
await typeInto(p, searchBox(p), 'Kyoto')
await expect(card(p, FLIGHT.title)).toHaveCount(0)
await expect(card(p, TRAIN.title)).toBeVisible()
await settle(p)
},
target: bookingsBar,
},
{
target: exportButton,
act: async p => {
const [download] = await Promise.all([p.waitForEvent('download'), exportButton(p).click()])
expect(download.suggestedFilename()).toBe(CSV_FILE)
await settle(p)
},
},
],
cleanup: async p => {
if (await searchBox(p).isVisible().catch(() => false)) await searchBox(p).fill('')
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
'airtrail-import': {
guide: guide('airtrail-import'),
// The connection is made here and taken away in cleanup, so the hero and
@@ -610,7 +812,7 @@ const SCRIPTS: Record<string, GuideScript> = {
// loads. One more load now that the connection exists, and the button
// is waited for, so a missing connection fails here and not at step 1.
await p.reload()
await expect(p.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(p.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
+137 -26
View File
@@ -1,15 +1,16 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureUsers, seededTrip, ADMIN_USERS } from './fixtures'
import { ensureUsers, seededTrip, ADMIN_USERS, JOURNEY_TITLE } from './fixtures'
import { openTrip, openTripOnDay, modal } from './trip-shared'
import { tripContext, tripGuides } from '../../src/help/contexts/trip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the trip frame, keyed by the ids in `src/help/contexts/trip.ts`:
* the people of the trip (the Share dialog), the columns and the undo. They run
* on the seeded "Autumn in Japan"; what a guide creates (a member, a guest, a
* link) it leaves for the next one or takes away again in `cleanup`.
* the trip's Settings (people, sharing, the linked journal), the columns and
* the undo. They run on the seeded "Autumn in Japan"; what a guide creates (a
* member, a guest, a link, a linked journal) it leaves for the next one or
* takes away again in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
@@ -18,9 +19,18 @@ const guide = (id: string): HelpGuide => {
return g
}
/** A section of the Share dialog by the words it opens with (its label, or the title of a link card). */
/**
* A part of the Settings dialog by the words it opens with: a DialogSection
* (its label) or a LinkCard (its title). The areas around them are sections
* too, but they open with the area's own name, so they never match.
*/
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
const inviteBlock = (page: Page) => block(page, /^Invite User/)
const accessBlock = (page: Page) => block(page, /^Access\s*\d/)
/** An area in the dialog's list on the left; each carries the area's name as its label. */
const area = (page: Page, name: 'People' | 'Sharing' | 'Journal') => modal(page).getByRole('button', { name, exact: true })
/** The Trip settings pill beside the trip's name. */
const settingsPill = (page: Page) => page.getByRole('button', { name: 'Trip settings', exact: true })
/**
* The days column's fold tab: the one button straight inside the column's
* frame, which sits 10px in from the left. Its name is Collapse while the
@@ -28,12 +38,23 @@ const inviteBlock = (page: Page) => block(page, /^Invite User/)
* elsewhere (the day cards fold with Collapse, the first tab is Plan).
*/
const leftToggle = (page: Page) => page.locator('div[style*="left: 10px"][style*="top: 10px"] > button').first()
const leftDivider = (page: Page) => page.getByRole('separator', { name: 'Panel width' }).first()
async function openShare(page: Page): Promise<void> {
await openTrip(page)
await page.getByRole('button', { name: 'Share', exact: true }).click()
async function clickSettings(page: Page): Promise<void> {
await settingsPill(page).click()
await expect(modal(page)).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Trip settings' })).toBeVisible()
await settle(page)
}
async function openSettings(page: Page): Promise<void> {
await openTrip(page)
await clickSettings(page)
}
async function pickArea(page: Page, name: 'Sharing' | 'Journal'): Promise<void> {
await area(page, name).click()
await expect(area(page, name)).toHaveAttribute('aria-current', 'page')
await settle(page)
}
@@ -42,6 +63,12 @@ const closeModal = async (page: Page): Promise<void> => {
await expect(modal(page)).toHaveCount(0)
}
/** Take the journal off the trip again; a trip without one answers that too, which is fine. */
async function unlinkJournal(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.request.delete(`/api/trips/${tripId}/journal`)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
@@ -50,12 +77,8 @@ const SCRIPTS: Record<string, GuideScript> = {
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Share', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Share', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(p)
},
target: settingsPill,
act: clickSettings,
},
{
prepare: async p => {
@@ -67,17 +90,20 @@ const SCRIPTS: Record<string, GuideScript> = {
target: inviteBlock,
act: async p => {
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
await expect(block(p, /^Access\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(accessBlock(p).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Access\s*\d/)),
only(accessBlock),
],
cleanup: closeModal,
},
'trip-invite-link': {
guide: guide('trip-invite-link'),
start: openShare,
start: async p => {
await openSettings(p)
await pickArea(p, 'Sharing')
},
steps: [
{
target: p => modal(p).getByRole('button', { name: 'Create invite link' }),
@@ -88,6 +114,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
only(p => block(p, /^Trip invite link/)),
// The row with Regenerate and Disable.
only(p => modal(p).getByRole('button', { name: 'Regenerate' }).locator('xpath=..')),
],
cleanup: async p => {
@@ -98,7 +125,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'add-guest': {
guide: guide('add-guest'),
start: openShare,
start: openSettings,
steps: [
only(p => block(p, /^Guests/)),
{
@@ -109,7 +136,7 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => modal(p).getByPlaceholder('Guest name').locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Add guest' }).click()
await expect(modal(p).getByText('Aunt Lise')).toBeVisible({ timeout: 15_000 })
await expect(block(p, /^Guests/).getByText('Aunt Lise', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
@@ -118,13 +145,26 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'public-link': {
guide: guide('public-link'),
start: openShare,
start: openSettings,
steps: [
{
target: p => area(p, 'Sharing'),
act: p => pickArea(p, 'Sharing'),
},
{
// The first row of chips: Map & Plan, Bookings, Packing, Costs, Chat.
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Bookings', exact: true }).click()
await beat(p, 300)
await modal(p).getByRole('button', { name: 'Packing', exact: true }).click()
await expect(modal(p).getByRole('button', { name: 'Packing', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
// Options with its two chips; the pointer on the first shows what it leaves out.
target: p => modal(p).getByRole('button', { name: 'Travel & stays only' }).locator('xpath=../..'),
hover: async p => {
await modal(p).getByRole('button', { name: 'Travel & stays only' }).hover()
},
},
{
@@ -143,17 +183,66 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
// The chips live in the dialog until a link is saved, and the link is gone again.
cleanup: closeModal,
},
'transfer-ownership': {
guide: guide('transfer-ownership'),
start: openShare,
start: openSettings,
steps: [
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
],
cleanup: closeModal,
},
'link-journal': {
guide: guide('link-journal'),
start: async p => {
await unlinkJournal(p)
await openSettings(p)
},
steps: [
{
target: p => area(p, 'Journal'),
act: async p => {
await pickArea(p, 'Journal')
await expect(modal(p).getByRole('button', { name: 'Link a journal' })).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => modal(p).getByRole('button', { name: 'Link a journal' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Link a journal' }).click()
await modal(p).getByRole('button', { name: 'Choose a journal' }).click()
// The select's list is rendered outside the dialog.
await p.getByRole('button', { name: JOURNEY_TITLE, exact: true }).last().click()
await expect(modal(p).getByText('Access for trip members', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The heading row (Everyone can read / Nobody) and a row per person.
target: p => modal(p).getByText('Access for trip members', { exact: true }).locator('xpath=../..'),
act: async p => {
const edit = modal(p).getByRole('radio', { name: 'Edit' }).first()
await edit.click()
await expect(edit).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Link journal', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Link journal', exact: true }).click()
await expect(modal(p).getByText('Linked', { exact: true })).toBeVisible({ timeout: 15_000 })
await closeModal(p)
await expect(p.getByRole('button', { name: 'Journal', exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: unlinkJournal,
},
'collapse-columns': {
guide: guide('collapse-columns'),
start: p => openTrip(p),
@@ -176,7 +265,29 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
// The divider is invisible until hovered; the hover shows it.
target: p => p.locator('div[style*="col-resize"]').first(),
target: leftDivider,
// Out to a wider column and back again, so the loop ends where it began.
clip: async (p, frame) => {
const box = await leftDivider(p).boundingBox()
if (!box) throw new Error('the days column has no divider')
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await p.mouse.move(x, y)
for (let i = 0; i < 3; i++) await frame()
await p.mouse.down()
for (let i = 1; i <= 16; i++) {
await p.mouse.move(x + (140 * i) / 16, y, { steps: 2 })
await frame()
}
for (let i = 0; i < 4; i++) await frame()
for (let i = 1; i <= 16; i++) {
await p.mouse.move(x + 140 - (140 * i) / 16, y, { steps: 2 })
await frame()
}
await p.mouse.up()
await p.waitForTimeout(300)
for (let i = 0; i < 3; i++) await frame()
},
},
],
},
@@ -205,7 +316,7 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => p.getByRole('button', { name: 'Undo' }),
act: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeDisabled({ timeout: 15_000 })
await expect(p.getByText(/^Undone: /).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
+248 -75
View File
@@ -1,7 +1,11 @@
import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test'
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
import { E2E_BASE_URL } from '../../playwright.config'
import { monthName, year } from '../dates'
import { monthName, monthsAhead, year } from '../dates'
import {
PARTNER, ensureFused, ensureSeparate, clearPartnerDays, requestFusionFromPartner, clearFusionRequest,
shareWithAdmin, unshareWithAdmin, ownColor, setOwnColor, ensurePublicCalendar, calendarColors,
restoreCalendarColors, companyHolidayFraction, setCompanyHoliday,
} from './fixtures-vacay'
import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
import type { HelpGuide } from '../../src/help/types'
@@ -10,7 +14,9 @@ import type { HelpGuide } from '../../src/help/types'
* They run in the order the context lists them and build on each other: the
* year the calendar shows gets a few logged days, company holidays and a
* public-holiday calendar as the run goes on, which is what a lived-in plan
* looks like anyway.
* looks like anyway. The guides about planning together fuse the seeded
* member mira into the plan through the API (`fixtures-vacay.ts`) and split
* it again in their cleanup, so every other guide finds a plan of one.
*/
const YEAR = year()
@@ -19,6 +25,22 @@ const NEXT_MONTH = monthName(1)
/** What the entitlement guide sets: not the seed's 30, so the change shows. */
const ENTITLEMENT = '28'
/**
* Weekdays of the next month from the 10th on. A fixed day number fell on a
* weekend in some months, and Block Weekends (on by default) ignores the
* click there, so the picture showed an empty cell under a step that says it
* fills.
*/
const WORKDAYS: number[] = Array.from({ length: 19 }, (_, i) => i + 10).filter(d => {
const weekday = new Date(`${monthsAhead(1, d)}T12:00:00Z`).getUTCDay()
return weekday !== 0 && weekday !== 6
})
const [LOG_DAY, HALF_DAY, COMP_DAY, , PARTNER_DAY] = WORKDAYS
/** The `YYYY-MM-DD` of a day number in the next month. */
const nextMonthDate = (n: number): string => monthsAhead(1, n)
/** The day the company-holiday guides mark, whole and then half. */
const CHRISTMAS_EVE = `${YEAR}-12-24`
const guide = (id: string): HelpGuide => {
const g = vacayGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
@@ -66,6 +88,9 @@ const day = (page: Page, month: string, n: number) => monthCard(page, month).get
const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter({ hasText: label }).first()
/** The settings dialog. */
const settings = (page: Page) => page.locator('.trek-modal-backdrop')
/** The window Dissolve opens before it splits the plan; it stacks on top of Settings. */
const dissolveConfirm = (page: Page) =>
page.locator('.trek-modal-enter').filter({ hasText: 'Separate calendars again' }).filter({ has: page.getByRole('button', { name: 'Cancel' }) }).last()
/** The invite and share forms: small dialogs portalled to the body. */
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
/** A settings row by its label: icon, label, hint and, for a toggle, its switch. */
@@ -167,64 +192,6 @@ const settingsCloseStep: StepAction = {
act: closeSettings,
}
// ── A calendar shared with the admin ──────────────────────────────────────────
/**
* The seeded member whose calendar the share-calendar guide shows as shared
* with the admin. Not jonas: the admin shares with jonas in the same guide,
* and two rows named jonas would hide which one is incoming.
*/
const SHARER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' }
/**
* A request context acting as the member. Its own context with a bearer
* token: a login on the page's context would put the member's session cookie
* on the admin's requests.
*/
async function asMember(member: typeof SHARER): Promise<APIRequestContext> {
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined })
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } })
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined
await anon.dispose()
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`)
return apiRequest.newContext({
baseURL: E2E_BASE_URL,
storageState: undefined,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
})
}
/** The member shares her calendar with the admin, as she would from her own Vacay. */
async function shareWithAdmin(page: Page): Promise<void> {
const me = await page.request.get('/api/auth/me')
const adminId = ((await me.json()) as { user: { id: number } }).user.id
const member = await asMember(SHARER)
try {
const res = await member.post('/api/addons/vacay/shares', { data: { user_id: adminId } })
// A retried guide finds the share from its first attempt still there.
if (!res.ok() && !(await res.text()).includes('Already shared')) {
throw new Error(`could not share ${SHARER.username}'s calendar with the admin: ${res.status()}`)
}
} finally {
await member.dispose()
}
}
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
async function unshareWithAdmin(page: Page): Promise<void> {
const shares = (await (await page.request.get('/api/addons/vacay/shares')).json()) as { incoming: { id: number; username: string }[] }
for (const s of shares.incoming.filter(x => x.username === SHARER.username)) {
const res = await page.request.delete(`/api/addons/vacay/shares/${s.id}`)
if (!res.ok()) throw new Error(`could not remove ${SHARER.username}'s share: ${res.status()}`)
}
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
notifications: { id: number; title_key: string; sender_username: string | null }[]
}
for (const n of inbox.notifications.filter(x => x.title_key === 'notif.vacay_share.title' && x.sender_username === SHARER.username)) {
await page.request.delete(`/api/notifications/in-app/${n.id}`)
}
}
/**
* Pick an option in a CustomSelect: open it, filter, click the match inside
* the open menu. Scoped to the menu on purpose: a calendar row already set to
@@ -233,13 +200,29 @@ async function unshareWithAdmin(page: Page): Promise<void> {
*/
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
const search = page.getByPlaceholder('...')
// The menu's filter field; its placeholder is the app's "Search".
const search = page.getByPlaceholder('Search', { exact: true }).last()
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
const menu = search.locator('xpath=../..')
await menu.getByRole('button', { name: text, exact: true }).first().click()
}
/** A row of the Persons card (a fused person or a pending invite) by its name. */
const personRow = (page: Page, name: string): Locator => cardRow(page, 'Persons', name)
/** Your own row in Persons, the one with the "you" badge. */
const ownPersonRow = (page: Page): Locator =>
sideCard(page, 'Persons').locator('.group').filter({ has: page.getByText('you', { exact: true }) }).first()
/** The first holiday calendar row in Settings: colour square, label, country, region and bin. */
const calendarRow = (page: Page): Locator => settings(page).getByPlaceholder('Label (optional)').first().locator('xpath=../..')
/** The Fusion Request dialog, which opens by itself while an invite waits for an answer. */
const fusionDialog = (page: Page): Locator => page.getByRole('dialog', { name: 'Fusion Request' })
/** What a guide has to put back, read in its start and used in its cleanup. */
let savedColor = ''
let savedCalendarColors = new Map<number, string>()
let christmasEveBefore: number | null = null
const SCRIPTS: Record<string, GuideScript> = {
'log-day': {
guide: guide('log-day'),
@@ -247,16 +230,16 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, 14),
act: async p => { await day(p, NEXT_MONTH, 14).click(); await beat(p, 600) },
target: p => day(p, NEXT_MONTH, LOG_DAY),
act: async p => { await day(p, NEXT_MONTH, LOG_DAY).click(); await beat(p, 600) },
},
{
target: p => day(p, NEXT_MONTH, 14),
target: p => day(p, NEXT_MONTH, LOG_DAY),
act: async p => {
await day(p, NEXT_MONTH, 14).click()
await day(p, NEXT_MONTH, LOG_DAY).click()
await beat(p, 600)
// Leave the day logged for the after-picture and the guides that follow.
await day(p, NEXT_MONTH, 14).click()
await day(p, NEXT_MONTH, LOG_DAY).click()
await toTop(p)
},
},
@@ -272,8 +255,8 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
},
{
target: p => day(p, NEXT_MONTH, 15),
act: async p => { await day(p, NEXT_MONTH, 15).click(); await settle(p) },
target: p => day(p, NEXT_MONTH, HALF_DAY),
act: async p => { await day(p, NEXT_MONTH, HALF_DAY).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
@@ -291,9 +274,9 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
},
{
target: p => day(p, NEXT_MONTH, 16),
target: p => day(p, NEXT_MONTH, COMP_DAY),
act: async p => {
await day(p, NEXT_MONTH, 16).click()
await day(p, NEXT_MONTH, COMP_DAY).click()
await settle(p)
await p.getByRole('button', { name: 'Comp / Flex' }).click()
await toTop(p)
@@ -404,6 +387,42 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'half-company-holiday': {
guide: guide('half-company-holiday'),
// A full run finds Christmas Eve a whole company holiday from the guide
// before; the click with Half day on then converts it in place.
start: async p => {
christmasEveBefore = await companyHolidayFraction(p, YEAR, CHRISTMAS_EVE)
await openVacay(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Company Holiday' }),
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => {
await p.getByRole('button', { name: 'Half day' }).click()
await expect(p.getByRole('button', { name: 'Half day' })).toHaveAttribute('aria-pressed', 'true')
},
},
{
target: p => day(p, 'December', 24),
act: async p => {
await day(p, 'December', 24).click()
await settle(p)
// Both switches back, so the next guide starts on plain vacation. The page
// stays where it is: the half-filled cell is what the result shows.
await p.getByRole('button', { name: 'Half day' }).click()
await toolbar(p).getByRole('button').first().click()
await settle(p)
},
},
],
cleanup: async p => { await setCompanyHoliday(p, YEAR, CHRISTMAS_EVE, christmasEveBefore) },
},
'public-holidays': {
guide: guide('public-holidays'),
start: openVacay,
@@ -412,7 +431,8 @@ const SCRIPTS: Record<string, GuideScript> = {
// The whole row, as in company-holidays step 1: name and switch together.
prepare: openSettings,
target: p => settingRow(p, 'Public Holidays'),
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
// switchOn rather than a click: a guide run on its own may find it on already.
act: async p => { await switchOn(p, 'Public Holidays') },
},
{
// The step is about the country and the region, so the picture shows
@@ -432,7 +452,7 @@ const SCRIPTS: Record<string, GuideScript> = {
{
prepare: openSettings,
target: p => settingRow(p, 'School Holidays'),
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
act: async p => { await switchOn(p, 'School Holidays') },
},
{
// The last Add calendar: the public-holiday one above it is the first.
@@ -444,6 +464,40 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'edit-holiday-calendar': {
guide: guide('edit-holiday-calendar'),
start: async p => {
await ensurePublicCalendar(p)
savedCalendarColors = await calendarColors(p)
await openVacay(p)
},
steps: [
{
prepare: openSettings,
target: calendarRow,
hover: parkPointer,
},
{
// The swatches open before the picture, so it shows what the click offers.
prepare: async p => {
await calendarRow(p).getByTitle('Color').click()
await expect(calendarRow(p).locator('div[style*="display: grid"] > button').first()).toBeVisible()
},
target: p => calendarRow(p).getByTitle('Color'),
act: async p => {
await calendarRow(p).locator('div[style*="display: grid"] > button').nth(1).click()
await settle(p)
},
},
{
// The bin is the row's last button, after the colour square and the two lists.
target: p => calendarRow(p).locator('button').last(),
act: closeSettings,
},
],
cleanup: async p => { await restoreCalendarColors(p, savedCalendarColors) },
},
weekends: {
guide: guide('weekends'),
start: openVacay,
@@ -494,6 +548,30 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'person-color': {
guide: guide('person-color'),
start: async p => {
savedColor = await ownColor(p)
await openVacay(p)
},
steps: [
{
target: p => ownPersonRow(p).getByTitle('Change color'),
act: async p => { await ownPersonRow(p).getByTitle('Change color').click(); await settle(p) },
},
{
target: p => dialog(p).getByRole('group', { name: 'Color' }),
act: async p => {
// One that is not the current colour, or the pick would change nothing.
await dialog(p).getByRole('group', { name: 'Color' }).getByRole('button', { pressed: false }).nth(4).click()
await expect(p.getByRole('dialog', { name: 'Change color' })).toBeHidden()
await toTop(p)
},
},
],
cleanup: async p => { await setOwnColor(p, savedColor) },
},
invite: {
guide: guide('invite'),
start: openVacay,
@@ -526,6 +604,101 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'answer-fusion-request': {
guide: guide('answer-fusion-request'),
start: async p => {
await requestFusionFromPartner(p)
await openVacay(p)
await expect(fusionDialog(p)).toBeVisible()
},
steps: [
{ target: p => fusionDialog(p).locator('header').first(), hover: parkPointer },
{ target: p => fusionDialog(p).locator('ul').first(), hover: parkPointer },
{
target: p => fusionDialog(p).locator('footer'),
hover: async p => { await fusionDialog(p).getByRole('button', { name: 'Accept & Fuse' }).hover() },
// Declined, so the admin's plan stays as the guides that follow expect it.
act: async p => {
await fusionDialog(p).getByRole('button', { name: 'Decline' }).click()
await expect(fusionDialog(p)).toBeHidden()
await settle(p)
},
},
],
cleanup: clearFusionRequest,
},
'log-for-partner': {
guide: guide('log-for-partner'),
start: async p => {
await ensureFused(p)
await openVacay(p)
await expect(personRow(p, PARTNER.username)).toBeVisible()
},
steps: [
{
target: p => personRow(p, PARTNER.username),
act: async p => {
await personRow(p, PARTNER.username).click()
await expect(toolbar(p).getByRole('button').first()).toContainText(PARTNER.username)
},
},
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, PARTNER_DAY),
act: async p => {
await day(p, NEXT_MONTH, PARTNER_DAY).click()
await settle(p)
await ownPersonRow(p).click()
await toTop(p)
},
},
],
cleanup: async p => {
await clearPartnerDays(p, YEAR, [nextMonthDate(PARTNER_DAY)])
await ensureSeparate(p)
},
},
'dissolve-fusion': {
guide: guide('dissolve-fusion'),
start: async p => {
await ensureFused(p)
await openVacay(p)
await expect(personRow(p, PARTNER.username)).toBeVisible()
},
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
// The whole red section: its name, the people in the plan and the button.
target: p => settings(p).getByText('Dissolve Fusion', { exact: true }).locator('xpath=ancestor::div[contains(@class, "rounded-xl")][1]'),
hover: async p => { await settings(p).getByRole('button', { name: 'Dissolve', exact: true }).hover() },
act: async p => {
await settings(p).getByRole('button', { name: 'Dissolve', exact: true }).click()
await expect(dissolveConfirm(p)).toBeVisible()
await settle(p)
},
},
{
// The window asks first; its Dissolve splits the plan.
target: dissolveConfirm,
hover: async p => { await dissolveConfirm(p).getByRole('button', { name: 'Dissolve', exact: true }).hover() },
act: async p => {
await dissolveConfirm(p).getByRole('button', { name: 'Dissolve', exact: true }).click()
await expect(settings(p)).toBeHidden()
await expect(personRow(p, PARTNER.username)).toBeHidden()
await p.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
await toTop(p)
},
},
],
// Already separate after the last step; this covers a run that failed before it.
cleanup: ensureSeparate,
},
'share-calendar': {
guide: guide('share-calendar'),
start: openVacay,
@@ -553,7 +726,7 @@ const SCRIPTS: Record<string, GuideScript> = {
// the socket; a reload is the fallback should that message not come.
prepare: async p => {
await shareWithAdmin(p)
const incoming = cardRow(p, 'Shared Calendars', SHARER.username)
const incoming = cardRow(p, 'Shared Calendars', PARTNER.username)
if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p)
await expect(incoming).toBeVisible()
},
+76
View File
@@ -0,0 +1,76 @@
import type { APIRequestContext } from '@playwright/test';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
/**
* Cover photos for the demo trips.
*
* A drawn cover reads as a placeholder at the size the dashboard shows it, and
* the help pictures are meant to look like a real traveller's TREK. So the
* covers are landscape photographs from Wikimedia Commons under free licences,
* downloaded at run time into `e2e/.tmp/photos/` and never committed: the
* repository carries no photographs. No person is recognisable in any of them.
*
* When Commons cannot be reached the trip simply keeps no cover, which the
* dashboard handles; the run does not fail over a decoration.
*/
export const COVER_PHOTOS = {
// Kiyomizu-dera, Kyoto, November 2016 -02.jpg, CC BY-SA 4.0
japan:
'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ae/Kiyomizu-dera%2C_Kyoto%2C_November_2016_-02.jpg/1920px-Kiyomizu-dera%2C_Kyoto%2C_November_2016_-02.jpg',
// Croda dei Toni and Tre Cime di Lavaredo from Monte Specie, 2025 October.jpg, CC BY-SA 4.0
dolomites:
'https://thumb.wikimedia.org/wikipedia/commons/thumb/7/77/Croda_dei_Toni_and_Tre_Cime_di_Lavaredo_from_Monte_Specie%2C_Val_Pusteria%2C_Trentino-Alto_Adige%2C_Italy%2C_2025_October.jpg/1920px-Croda_dei_Toni_and_Tre_Cime_di_Lavaredo_from_Monte_Specie%2C_Val_Pusteria%2C_Trentino-Alto_Adige%2C_Italy%2C_2025_October.jpg',
// Sella Ronda Overview.jpg, CC0
ski: 'https://thumb.wikimedia.org/wikipedia/commons/thumb/6/60/Sella_Ronda_Overview.jpg/1920px-Sella_Ronda_Overview.jpg',
// Geirangerfjord from Ørnesvingen, 2013 June.jpg, CC BY-SA 3.0
norway:
'https://thumb.wikimedia.org/wikipedia/commons/thumb/2/2b/Geirangerfjord_from_%C3%98rnesvingen%2C_2013_June.jpg/1920px-Geirangerfjord_from_%C3%98rnesvingen%2C_2013_June.jpg',
// RhB ABe 4-4 III mit Bernina-Express am Lago Bianco.jpg, CC BY-SA 3.0
alps: 'https://thumb.wikimedia.org/wikipedia/commons/thumb/7/7c/RhB_ABe_4-4_III_mit_Bernina-Express_am_Lago_Bianco.jpg/1920px-RhB_ABe_4-4_III_mit_Bernina-Express_am_Lago_Bianco.jpg',
// Le tram 28.jpg, CC BY-SA 4.0
lisbon: 'https://thumb.wikimedia.org/wikipedia/commons/thumb/b/b5/Le_tram_28.jpg/1920px-Le_tram_28.jpg',
} as const;
export type CoverPhoto = keyof typeof COVER_PHOTOS;
const PHOTO_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'photos');
// Commons asks every client to name itself; anonymous bursts get a 429.
const USER_AGENT = 'TREK-help-media/1.0 (documentation screenshots; https://github.com/liketrek/TREK)';
/** The photo on disk, downloaded once; null when Commons cannot be reached. */
export async function coverPhoto(key: CoverPhoto): Promise<string | null> {
const file = path.join(PHOTO_DIR, `${key}.jpg`);
if (existsSync(file)) return file;
mkdirSync(PHOTO_DIR, { recursive: true });
for (let attempt = 1; attempt <= 4; attempt++) {
try {
const res = await fetch(COVER_PHOTOS[key], {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(30_000),
});
if (res.status === 429) {
await new Promise((resolve) => setTimeout(resolve, attempt * 5_000));
continue;
}
if (!res.ok) break;
writeFileSync(file, Buffer.from(await res.arrayBuffer()));
return file;
} catch {
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
console.warn(`cover photo "${key}" could not be downloaded; the trip keeps no cover`);
return null;
}
/** Upload the photo as the trip's cover, the way the trip form does. */
export async function setTripCover(api: APIRequestContext, tripId: number, key: CoverPhoto): Promise<void> {
const file = await coverPhoto(key);
if (!file) return;
const { readFileSync } = await import('node:fs');
const res = await api.post(`/api/trips/${tripId}/cover`, {
multipart: { cover: { name: `${key}.jpg`, mimeType: 'image/jpeg', buffer: readFileSync(file) } },
});
if (!res.ok()) console.warn(`cover for trip ${tripId} was refused: ${res.status()} ${await res.text()}`);
}
+5
View File
@@ -2,6 +2,7 @@ import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
import { ensureShowcase } from '../help/showcase'
import { E2E_BASE_URL, E2E_SEED_FILE } from '../../playwright.config'
/**
@@ -29,6 +30,10 @@ setup('seed the demo trip', async ({ page, playwright }) => {
}),
)
// The other trips with covers, places and the bookings ahead, so the
// dashboard and the lists around the seeded trip look lived in.
await ensureShowcase(page.request)
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
+108 -34
View File
@@ -1,6 +1,7 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
import { at, day, ordinal, short } from '../dates'
import { setTripCover } from './photos'
/**
* Demo data for the documentation screenshots.
@@ -39,32 +40,62 @@ const MEMBERS = [
const PLACES = [
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
notes: 'Go before 08:00 — the gate is empty and the light is better.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
notes: 'Early is best: the gate is still empty and the light is softer.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0, start: '09:30', end: '11:00', category: 'Attraction' },
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
description: 'Immersive digital art museum you walk through barefoot.',
notes: 'Timed entry — book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
notes: 'Timed entry, book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0, start: '14:00', end: '16:00', category: 'Activity' },
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
duration_minutes: 45, price: 0, currency: 'JPY', day: 1, start: '18:30', end: '19:15', category: 'Attraction' },
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
description: 'Forest shrine in the middle of the city.',
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
duration_minutes: 75, price: 0, currency: 'JPY', day: 1, start: '09:30', end: '10:45', category: 'Attraction' },
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
notes: 'The crowds thin out after the first 20 minutes of climbing.',
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
duration_minutes: 150, price: 0, currency: 'JPY', day: 4, start: '07:45', end: '10:15', category: 'Attraction' },
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
duration_minutes: 60, price: 0, currency: 'JPY', day: 5, start: '08:00', end: '09:00', category: 'Nature' },
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
description: "Five covered blocks of food stalls, known as Kyoto's kitchen.",
notes: 'Come hungry. Try the tamagoyaki.',
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5, start: '12:30', end: '14:00', category: 'Shopping' },
{ name: 'Lake Ashi', lat: 35.2009, lng: 139.0247, address: 'Motohakone, Hakone, Kanagawa',
description: 'Pirate-ship ferry across the crater lake, Fuji in view on a clear morning.',
duration_minutes: 60, price: 1200, currency: 'JPY', day: 2, start: '10:00', end: '11:00', category: 'Activity' },
{ name: 'Hakone Open-Air Museum', lat: 35.2446, lng: 139.0503, address: '1121 Ninotaira, Hakone, Kanagawa',
description: 'Sculpture park on a hillside, with a Picasso pavilion and a foot bath.',
duration_minutes: 150, price: 2000, currency: 'JPY', day: 2, start: '13:00', end: '15:30', category: 'Attraction' },
{ name: 'Owakudani', lat: 35.2440, lng: 139.0196, address: 'Sengokuhara, Hakone, Kanagawa',
description: 'Volcanic valley with sulphur vents and the famous black eggs.',
notes: 'Take the ropeway up, the bus back down.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 3, start: '09:00', end: '10:30', category: 'Nature' },
{ name: 'Todai-ji', lat: 34.6890, lng: 135.8398, address: '406-1 Zoshicho, Nara',
description: 'The Great Buddha Hall, still one of the largest wooden buildings in the world.',
duration_minutes: 90, price: 800, currency: 'JPY', day: 6, start: '09:30', end: '11:00', category: 'Attraction' },
{ name: 'Nara Park', lat: 34.6851, lng: 135.8430, address: 'Zoshicho, Nara',
description: 'Open park with over a thousand free-roaming deer.',
notes: 'Deer crackers are sold at the stands, 200 yen a bundle.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 6, start: '11:00', end: '12:30', category: 'Nature' },
{ name: 'Kinkaku-ji', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto',
description: 'The Golden Pavilion, mirrored in its pond.',
duration_minutes: 60, price: 500, currency: 'JPY', day: 7, start: '09:00', end: '10:00', category: 'Attraction' },
{ name: 'Ryoan-ji', lat: 35.0345, lng: 135.7185, address: '13 Ryoanji Goryonoshitacho, Ukyo Ward, Kyoto',
description: 'The rock garden: fifteen stones, never all visible at once.',
duration_minutes: 60, price: 600, currency: 'JPY', day: 7, start: '10:30', end: '11:30', category: 'Attraction' },
{ name: 'Hanamikoji Street', lat: 35.0027, lng: 135.7751, address: 'Gionmachi Minamigawa, Higashiyama Ward, Kyoto',
description: 'Lantern-lit street of teahouses in Gion.',
notes: 'Respect the no-photo signs on the private side streets.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 8, start: '18:00', end: '19:30', category: 'Attraction' },
]
const EXPENSES = [
/** Who paid, so the balances in Costs are not all one-sided. */
type Payer = 'admin' | 'mira' | 'jonas'
const EXPENSES: Array<{ name: string; category: string; total_price: number; currency: string; expense_date: string; note?: string; paidBy?: Payer }> = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: day(-9), note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
@@ -74,13 +105,33 @@ const EXPENSES = [
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: day(-8) },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: day(-4) },
expense_date: day(-4), paidBy: 'mira' },
{ name: 'Shinkansen seat reservations', category: 'transport', total_price: 4320, currency: 'JPY',
expense_date: day(-5), note: 'Covered by the JR Pass, only the reserved seats.', paidBy: 'jonas' },
{ name: 'Sushi breakfast at Tsukiji', category: 'food', total_price: 9600, currency: 'JPY',
expense_date: day(-8), paidBy: 'jonas' },
{ name: 'Suica cards, three top-ups', category: 'transport', total_price: 9000, currency: 'JPY',
expense_date: day(-8), paidBy: 'mira' },
{ name: 'Shibuya Sky tickets', category: 'activities', total_price: 6600, currency: 'JPY',
expense_date: day(-8) },
{ name: 'Ramen in Shinjuku', category: 'food', total_price: 3450, currency: 'JPY',
expense_date: day(-7), paidBy: 'jonas' },
{ name: 'Kaiseki dinner in Gion', category: 'food', total_price: 36000, currency: 'JPY',
expense_date: day(-3), paidBy: 'mira' },
{ name: 'Matcha and wagashi souvenirs', category: 'shopping', total_price: 5400, currency: 'JPY',
expense_date: day(-2), paidBy: 'jonas' },
{ name: 'Travel insurance', category: 'other', total_price: 74, currency: 'EUR',
expense_date: day(-30), note: 'Group policy, two weeks.' },
]
const PACKING = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
/** Most of it packed already: the trip is running, the list is nearly done. */
const PACKING: Array<{ category: string; items: Array<string | [string, 'open']> }> = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance', 'Printed hotel confirmations', 'Credit card and some yen'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers', 'Merino socks', 'Warm fleece for Hakone', ['Slip-on shoes for temples', 'open']] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera', 'Spare SD card', ['eSIM activation code', 'open']] },
{ category: 'Toiletries', items: ['Toothbrush and paste', 'Sunscreen', 'Small towel for the onsen', ['Travel-size shampoo', 'open']] },
{ category: 'Health', items: ['Plasters', 'Ibuprofen', 'Hand sanitiser', 'Face masks'] },
{ category: 'Day bag', items: ['Water bottle', 'Coin purse', 'Folding umbrella', ['Goshuin book', 'open']] },
]
/** Chat lines the collab guides look for by their text. */
@@ -88,10 +139,14 @@ export const SEED_CHAT = {
teamlab: `Booked the teamLab slot for the ${ordinal(-8)}, 14:00. Tickets are in the Files tab.`,
}
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32) },
const TODOS: Array<{ name: string; category: string; due_date?: string; priority?: number; done?: boolean }> = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2, done: true },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1, done: true },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32), done: true },
{ name: 'Order yen at the bank', category: 'Before departure', due_date: day(-14), done: true },
{ name: 'Reserve Shinkansen seats on the Fuji side', category: 'On arrival', due_date: day(-6), priority: 2, done: true },
{ name: 'Send postcards home', category: 'During the trip', due_date: day(-1) },
{ name: 'Buy a goshuin book at Senso-ji', category: 'During the trip' },
]
export interface SeedResult {
@@ -150,6 +205,7 @@ export async function seedDemoData(
// 3. The trip, in JPY.
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
const tripId = trip.id
await setTripCover(api, tripId, 'japan')
for (const m of MEMBERS) {
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
@@ -161,16 +217,25 @@ export async function seedDemoData(
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
// 5. Places, then pin each onto its day.
const categories = await call<{ categories: { id: number; name: string }[] }>(api, 'get', '/api/categories')
.then(r => r.categories).catch(() => [] as { id: number; name: string }[])
const placeIds: number[] = []
for (const p of PLACES) {
const { day, ...payload } = p
const { day, start, end, category, ...payload } = p
const categoryId = categories.find(c => c.name === category)?.id
const { place } = await call<{ place: { id: number } }>(
api, 'post', `/api/trips/${tripId}/places`, payload)
api, 'post', `/api/trips/${tripId}/places`, categoryId ? { ...payload, category_id: categoryId } : payload)
placeIds.push(place.id)
const dayId = dayIds[day]
if (dayId) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
{ place_id: place.id }).catch(() => {})
const created = await call<{ assignment: { id: number } }>(
api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`, { place_id: place.id }).catch(() => null)
// A day plan with times reads like a real one: the timeline, the gaps
// and the order all come from them.
if (created) {
await call(api, 'put', `/api/trips/${tripId}/assignments/${created.assignment.id}/time`,
{ place_time: start, end_time: end }).catch(() => {})
}
}
}
@@ -186,10 +251,12 @@ export async function seedDemoData(
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
// and a hand-supplied one fights the settlement maths.
const allMembers = [1, ...memberIds]
for (const e of EXPENSES) {
const payerId = (who: Payer | undefined): number =>
who === 'mira' ? (memberIds[0] ?? 1) : who === 'jonas' ? (memberIds[1] ?? 1) : 1
for (const { paidBy, ...e } of EXPENSES) {
await call(api, 'post', `/api/trips/${tripId}/budget`, {
...e,
payers: [{ user_id: 1, amount: e.total_price }],
payers: [{ user_id: payerId(paidBy), amount: e.total_price }],
member_ids: allMembers,
}).catch(() => {})
}
@@ -203,15 +270,22 @@ export async function seedDemoData(
// 8. Packing — category is free text on the item, there is no category resource.
for (const group of PACKING) {
for (const name of group.items) {
await call(api, 'post', `/api/trips/${tripId}/packing`, {
for (const entry of group.items) {
const [name, state] = typeof entry === 'string' ? [entry, 'packed'] : entry
const created = await call<{ item: { id: number } }>(api, 'post', `/api/trips/${tripId}/packing`, {
name, category: group.category, visibility: 'common',
}).catch(() => {})
}).catch(() => null)
if (created && state === 'packed') {
await call(api, 'put', `/api/trips/${tripId}/packing/${created.item.id}`, { checked: true }).catch(() => {})
}
}
}
for (const t of TODOS) {
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
for (const { done, ...todo } of TODOS) {
const created = await call<{ item: { id: number } }>(api, 'post', `/api/trips/${tripId}/todo`, todo).catch(() => null)
if (created && done) {
await call(api, 'put', `/api/trips/${tripId}/todo/${created.item.id}`, { checked: true }).catch(() => {})
}
}
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
@@ -244,7 +318,7 @@ export async function seedDemoData(
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
color: '#ef4444', icon: 'MapPin' })
collectionId = 'id' in created ? created.id : created.collection.id
for (const placeId of placeIds.slice(4)) {
for (const placeId of placeIds.slice(4, 7)) {
await call(api, 'post', '/api/addons/collections/places/from-trip', {
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
}).catch(() => {})
@@ -288,9 +362,9 @@ export async function seedDemoData(
for (const n of [
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
content: 'The 14-day JR Pass covers the Tokyo to Kyoto legs. Activate it at the airport counter on arrival, not before.' },
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp, and being late is genuinely rude.' },
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
]) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Some files were not shown because too many files have changed in this diff Show More