mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
f8e51cb83d
An iPhone or iPad running TREK from the Home Screen ignores print(), in the page and in an iframe alike, so "Save as PDF" in the preview did nothing there. Mobile Safari prints the same preview fine. In the installed iOS app the preview now shows "Open in Safari" in place of the dead button, with a line saying why. The link opens the trip with ?export=pdf, and both shells open their export dialog or sheet for it, so the PDF is one tap away once Safari is up. The parameter is dropped once read and survives the login redirect. Everywhere else the preview prints as before. The preview overlay moves out of TripPDF.tsx into pdfPreview.ts, which TripPDF had no room left for under lint:size, and its backdrop and stacking now use the --overlay and --z-modal tokens.
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
|
import { http, HttpResponse } from 'msw'
|
|
import MExportSheet from '../../../../src/mobile/screens/trip/sheets/MExportSheet'
|
|
import { downloadTripPDF } from '../../../../src/components/PDF/TripPDF'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import type { Day, DayNote } from '../../../../src/types'
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
|
import { server } from '../../../helpers/msw/server'
|
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
|
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
|
|
|
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-016
|
|
// This sheet reads its copy from useTranslation(), so the assertions are English.
|
|
|
|
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
|
|
|
|
vi.mock('../../../../src/components/Planner/IcsSubscribeModal', () => ({
|
|
IcsSubscribeModal: ({ endpoint, onClose }: { endpoint: string; onClose: () => void }) => (
|
|
<div data-testid="ics-subscribe" data-endpoint={endpoint}>
|
|
<button type="button" onClick={onClose}>dismiss subscribe</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
const DAYS = [{ id: 2, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }] as unknown as Day[]
|
|
|
|
const NOTES: Record<string, DayNote[]> = {
|
|
'2': [{ id: 41, day_id: 2, text: 'Pack the day bag' }] as unknown as DayNote[],
|
|
'3': [{ id: 42, day_id: 3, text: 'Return the keys' }] as unknown as DayNote[],
|
|
}
|
|
|
|
function renderSheet(plannerOverrides: Record<string, unknown> = {}, sheetId: string | null = 'export') {
|
|
const planner = buildPlanner({ days: DAYS, ...plannerOverrides })
|
|
const shell = buildShell({ sheet: sheetId ? { id: sheetId } : null })
|
|
const view = render(<MExportSheet planner={planner} shell={shell} />)
|
|
return { ...view, planner, shell }
|
|
}
|
|
|
|
describe('MExportSheet', () => {
|
|
// The download anchor is never attached to the DOM, so the click spy's
|
|
// recorded `this` is the only handle on it.
|
|
let clickSpy: MockInstance<() => void>
|
|
const clickedAnchor = () => clickSpy.mock.contexts[0] as HTMLAnchorElement
|
|
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
seedStore(useTripStore, { dayNotes: NOTES })
|
|
vi.mocked(downloadTripPDF).mockReset().mockResolvedValue(undefined)
|
|
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:trek/ics')
|
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-001: stays closed while another sheet id is active', () => {
|
|
renderSheet({}, 'mehr')
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-002: lists the export routes and names the ics after the trip', () => {
|
|
renderSheet()
|
|
// Renamed from "Export Calendar": the sheet carries the PDF and GPX exports too.
|
|
expect(screen.getByRole('dialog', { name: 'Export' })).toBeInTheDocument()
|
|
expect(screen.getByText('PDF')).toBeInTheDocument()
|
|
expect(screen.getByText('Export day plan as PDF')).toBeInTheDocument()
|
|
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
|
expect(screen.getByText('Japan 2026.ics')).toBeInTheDocument()
|
|
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
|
expect(screen.getByText('Auto-updates in your calendar app')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-002b: drops the subscription row without share_manage, keeps the two exports', () => {
|
|
// Same gate as the desktop toolbar: the subscription hands out a link that
|
|
// works without an account, the download does not.
|
|
renderSheet({ can: vi.fn((action: string) => action !== 'share_manage') })
|
|
expect(screen.getByText('PDF')).toBeInTheDocument()
|
|
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
|
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-003: hands the planner slices and the flattened day notes to the PDF export', async () => {
|
|
const { planner } = renderSheet({
|
|
places: [{ id: 9, name: 'Fushimi Inari' }],
|
|
categories: [{ id: 3, name: 'Sights' }],
|
|
reservations: [{ id: 4, title: 'Ryokan' }],
|
|
})
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
|
|
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
|
const arg = vi.mocked(downloadTripPDF).mock.calls[0][0]
|
|
expect(arg.trip).toBe(planner.trip)
|
|
expect(arg.days).toBe(planner.days)
|
|
expect(arg.places).toBe(planner.places)
|
|
// The raw store, not the hook's filtered list: the export applies the plan's
|
|
// own predicate, and the switch travels with it.
|
|
expect(arg.assignments).toBe(planner.storedAssignments)
|
|
expect(arg.showServiceStops).toBe(true)
|
|
expect(arg.reservations).toBe(planner.reservations)
|
|
expect(arg.categories).toBe(planner.categories)
|
|
// dayNotes come out of the store keyed by day, flattened with a numeric day_id.
|
|
expect(arg.dayNotes).toEqual([
|
|
{ id: 41, day_id: 2, text: 'Pack the day bag' },
|
|
{ id: 42, day_id: 3, text: 'Return the keys' },
|
|
])
|
|
expect(typeof arg.t).toBe('function')
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-004: shows the busy label and refuses a second export while one runs', async () => {
|
|
let release: () => void = () => {}
|
|
vi.mocked(downloadTripPDF).mockImplementation(() => new Promise<void>(res => { release = () => res() }))
|
|
renderSheet()
|
|
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
const busy = await screen.findByText('Loading...')
|
|
fireEvent.click(busy)
|
|
expect(downloadTripPDF).toHaveBeenCalledTimes(1)
|
|
|
|
release()
|
|
await waitFor(() => expect(screen.getByText('PDF')).toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-005: reports a failing PDF export with the underlying message', async () => {
|
|
vi.mocked(downloadTripPDF).mockRejectedValue(new Error('font missing'))
|
|
const { planner } = renderSheet()
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Failed to export PDF: font missing'))
|
|
// The busy flag is released in the finally branch.
|
|
expect(screen.getByText('PDF')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-006: stringifies a non-Error rejection in the toast', async () => {
|
|
vi.mocked(downloadTripPDF).mockRejectedValue('renderer gone')
|
|
const { planner } = renderSheet()
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Failed to export PDF: renderer gone'))
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-007: does not export without a loaded trip', () => {
|
|
renderSheet({ trip: null })
|
|
expect(screen.getByText('trip.ics')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
expect(downloadTripPDF).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-008: downloads the ics through a named anchor and closes the sheet', async () => {
|
|
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.text('BEGIN:VCALENDAR')))
|
|
const { shell } = renderSheet()
|
|
fireEvent.click(screen.getByText('Download .ics'))
|
|
|
|
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
|
|
expect(clickedAnchor().getAttribute('href')).toBe('blob:trek/ics')
|
|
expect(clickedAnchor().download).toBe('Japan 2026.ics')
|
|
// The object URL outlives the click so slower browsers can still read it.
|
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:trek/ics'))
|
|
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-008b: a second tap while the ics request runs is ignored', async () => {
|
|
let release: () => void = () => {}
|
|
server.use(http.get('/api/trips/1/export.ics', async () => {
|
|
await new Promise<void>(res => { release = res })
|
|
return HttpResponse.text('BEGIN:VCALENDAR')
|
|
}))
|
|
const { shell } = renderSheet()
|
|
fireEvent.click(screen.getByText('Download .ics'))
|
|
|
|
const busy = await screen.findByText('Loading...')
|
|
fireEvent.click(busy)
|
|
release()
|
|
|
|
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1))
|
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-009: falls back to a generic filename without a trip title', async () => {
|
|
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.text('BEGIN:VCALENDAR')))
|
|
renderSheet({ trip: null })
|
|
fireEvent.click(screen.getByText('Download .ics'))
|
|
|
|
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
|
|
expect(clickedAnchor().download).toBe('trip.ics')
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-010: toasts and keeps the sheet open when the ics endpoint errors', async () => {
|
|
server.use(http.get('/api/trips/1/export.ics', () => new HttpResponse(null, { status: 500 })))
|
|
const { planner, shell } = renderSheet()
|
|
fireEvent.click(screen.getByText('Download .ics'))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('ICS export failed'))
|
|
expect(shell.closeSheet).not.toHaveBeenCalled()
|
|
expect(clickSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-011: toasts when the ics request never reaches the server', async () => {
|
|
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.error()))
|
|
const { planner } = renderSheet()
|
|
fireEvent.click(screen.getByText('Download .ics'))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('ICS export failed'))
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-012: opens the subscribe dialog on the trip feed endpoint and dismisses it again', () => {
|
|
renderSheet()
|
|
expect(screen.queryByTestId('ics-subscribe')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('Subscribe to calendar'))
|
|
expect(screen.getByTestId('ics-subscribe')).toHaveAttribute('data-endpoint', '/api/trips/1/feed')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dismiss subscribe' }))
|
|
expect(screen.queryByTestId('ics-subscribe')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-013: the header close hands the dismissal back to the shell', () => {
|
|
const { shell } = renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-014: while the PDF routes the row counts the legs, and the rest can be left out (#2876)', async () => {
|
|
let finish = () => {}
|
|
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
|
|
renderSheet()
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
|
const { route } = vi.mocked(downloadTripPDF).mock.calls[0][0]
|
|
|
|
act(() => route?.onProgress?.(3, 10))
|
|
expect(screen.getByText('Calculating routes: 3 of 10')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Export without the remaining routes' }))
|
|
expect(route?.signal?.aborted).toBe(true)
|
|
await act(async () => finish())
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-015: hiding the sheet while the PDF routes gives the export up', async () => {
|
|
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(() => undefined))
|
|
const { rerender, planner } = renderSheet()
|
|
fireEvent.click(screen.getByText('PDF'))
|
|
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
|
const { route, signal } = vi.mocked(downloadTripPDF).mock.calls[0][0]
|
|
|
|
rerender(<MExportSheet planner={planner} shell={buildShell({ sheet: { id: 'mehr' } })} />)
|
|
expect(route?.signal?.aborted).toBe(true)
|
|
expect(signal?.aborted).toBe(true)
|
|
})
|
|
|
|
it('FE-MOB-EXPSH-016: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the sheet', () => {
|
|
const shell = buildShell({ sheet: null })
|
|
render(<MExportSheet planner={buildPlanner({ days: DAYS })} shell={shell} />, { initialEntries: ['/trips/1?export=pdf'] })
|
|
expect(shell.openSheet).toHaveBeenCalledWith('export')
|
|
})
|
|
})
|