// FE-COMP-TRIPPDF-001 to FE-COMP-TRIPPDF-010 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { downloadTripPDF } from './TripPDF' import { server } from '../../../tests/helpers/msw/server' import { clearExchangeRateCache } from '../../hooks/useExchangeRates' import { getMergedItems, getTransportForDay } from '../../utils/dayMerge' // ── Helpers ─────────────────────────────────────────────────────────────────── const minimalArgs = { trip: { id: 1, title: 'My Trip', description: null, cover_image: null } as any, days: [{ id: 1, day_number: 1, title: null, date: '2025-06-01' }] as any[], places: [], assignments: {}, categories: [], dayNotes: [], reservations: [], t: (key: string, params?: any) => { if (params?.n !== undefined) return `Day ${params.n}` return key }, locale: 'en-US', } function getOverlay(): HTMLElement | null { return document.getElementById('pdf-preview-overlay') } function getIframe(): HTMLIFrameElement | null { return document.querySelector('#pdf-preview-overlay iframe') } /** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */ function plannedStat(html: string): number | null { const match = /
(\d+)<\/div>\s*
pdf\.planned<\/div>/.exec(html) return match ? Number(match[1]) : null } // ── Setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { // Stub window.location.origin Object.defineProperty(window, 'location', { value: { origin: 'http://localhost:3000', pathname: '/', href: 'http://localhost:3000/', search: '' }, writable: true, configurable: true, }) // Default MSW handlers for this test suite server.use( http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [] }) ), http.get('/api/maps/place-photo/:placeId', () => HttpResponse.json({ photoUrl: null }) ), http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({ sections: [] }) ), // Mixed-currency exports fetch FX rates; keep the suite hermetic. http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])), ) // The FX cache is module-level and would leak rates between tests in this file. clearExchangeRateCache() }) afterEach(() => { // Clean up any overlay left by the function under test document.getElementById('pdf-preview-overlay')?.remove() vi.restoreAllMocks() }) // ── Shared rich fixtures ────────────────────────────────────────────────────── const dayWithPlaces = { id: 10, day_number: 1, title: 'Rome Day', date: '2025-06-01' } as any const placeWithDetails = { id: 100, name: 'Colosseum', description: 'Ancient amphitheater', address: 'Piazza del Colosseo, Rome', category_id: 5, price: '15', image_url: null, google_place_id: null, place_time: '10:00', notes: 'Book tickets in advance', } as any const assignmentForDay = { id: 200, day_id: 10, place_id: 100, order_index: 0, place: placeWithDetails } const categoryForPlace = { id: 5, name: 'Landmark', icon: 'landmark', color: '#e11d48' } as any const dayNote = { id: 300, day_id: 10, text: 'Remember sunscreen', time: '08:00', icon: 'Info', sort_order: 1 } as any const transportReservation = { id: 400, title: 'Flight to Rome', type: 'flight', day_id: 10, reservation_time: '2025-06-01T14:30:00', confirmation_number: 'ABC123', metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), } as any const multiLegFlight = { id: 401, title: 'Flight to Tokyo', type: 'flight', day_id: 10, reservation_time: '2025-06-01T08:00:00', confirmation_number: 'XYZ789', metadata: JSON.stringify({ legs: [ { from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' }, { from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' }, ], departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1', }), } as any const richArgs = { trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any, days: [dayWithPlaces], places: [placeWithDetails], assignments: { '10': [assignmentForDay] } as any, categories: [categoryForPlace], dayNotes: [dayNote], reservations: [transportReservation], t: (key: string, params?: any) => { if (params?.n !== undefined) return `Day ${params.n}` return key }, locale: 'en-US', } // ── Tests ───────────────────────────────────────────────────────────────────── describe('downloadTripPDF', () => { it('FE-COMP-TRIPPDF-001: resolves without throwing', async () => { await expect(downloadTripPDF(minimalArgs)).resolves.not.toThrow() }) it('FE-COMP-TRIPPDF-002: appends an overlay div to document.body', async () => { await downloadTripPDF(minimalArgs) expect(document.getElementById('pdf-preview-overlay')).not.toBeNull() }) it('FE-COMP-TRIPPDF-003: overlay contains an iframe with srcdoc', async () => { await downloadTripPDF(minimalArgs) const iframe = getIframe() expect(iframe).not.toBeNull() expect(iframe!.srcdoc).toBeTruthy() expect(iframe!.srcdoc.length).toBeGreaterThan(0) }) it('FE-COMP-TRIPPDF-004: HTML contains the trip title', async () => { await downloadTripPDF(minimalArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('My Trip') }) it('FE-COMP-TRIPPDF-005: HTML contains a day section for each day', async () => { const args = { ...minimalArgs, days: [{ id: 1, day_number: 1, title: 'Day One', date: '2025-06-01' }] as any[], } await downloadTripPDF(args) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Day One') }) it('FE-COMP-TRIPPDF-005b: day is a table with a thead header that repeats on overflow pages (#1471)', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() const srcdoc = iframe!.srcdoc // The day is a real whose is repeated by the browser's print // engine on every page an overflowing day spills onto. expect(srcdoc).toContain('
') expect(srcdoc).toContain('') // The dark bar (background/padding/flex) lives in an inner wrapper inside the thead. expect(srcdoc).toContain('class="day-header-bar"') // Day content still renders inside the new structure. expect(srcdoc).toContain('Rome Day') expect(srcdoc).toContain('Colosseum') }) it('FE-COMP-TRIPPDF-005c: the gap under the day header lives in the repeated thead cell (#1531)', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() const srcdoc = iframe!.srcdoc // The thead is repeated on every overflow page, so the spacing below the header bar // must be declared on its cell... expect(srcdoc).toContain('.day-header > tr > td { padding-bottom: 12px; }') // ...and not as a block-start padding on .day-body, which the print engine only paints // on the first fragment of the (fragmented) body cell. expect(srcdoc).toContain('.day-body { padding: 0 28px 6px; }') }) it('FE-COMP-TRIPPDF-006: escHtml prevents XSS in trip title', async () => { const args = { ...minimalArgs, trip: { id: 1, title: '', description: null, cover_image: null } as any, } await downloadTripPDF(args) const iframe = getIframe() expect(iframe!.srcdoc).not.toContain('') expect(iframe!.srcdoc).toContain('<script>') }) it('FE-COMP-TRIPPDF-007: close button removes the overlay from the DOM', async () => { await downloadTripPDF(minimalArgs) const closeBtn = document.getElementById('pdf-close-btn') as HTMLButtonElement expect(closeBtn).not.toBeNull() closeBtn.click() expect(document.getElementById('pdf-preview-overlay')).toBeNull() }) it('FE-COMP-TRIPPDF-008: clicking backdrop outside the card removes the overlay', async () => { await downloadTripPDF(minimalArgs) const overlay = getOverlay()! overlay.click() expect(document.getElementById('pdf-preview-overlay')).toBeNull() }) it('FE-COMP-TRIPPDF-009: works with no days (empty itinerary)', async () => { const args = { ...minimalArgs, days: [] } await expect(downloadTripPDF(args)).resolves.not.toThrow() const iframe = getIframe() expect(iframe!.srcdoc).toContain('') // No day sections — should not contain day-section class expect(iframe!.srcdoc).not.toContain('class="day-section') }) it('FE-COMP-TRIPPDF-010: calls accommodationsApi.list with the trip id', async () => { const { accommodationsApi } = await import('../../api/client') const spy = vi.spyOn(accommodationsApi, 'list') await downloadTripPDF(minimalArgs) expect(spy).toHaveBeenCalledWith(1) }) it('FE-COMP-TRIPPDF-011: renders place cards with name, address and category badge', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Colosseum') expect(iframe!.srcdoc).toContain('Piazza del Colosseo, Rome') expect(iframe!.srcdoc).toContain('Landmark') }) it('FE-COMP-TRIPPDF-012: renders note cards in day body', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Remember sunscreen') }) it('FE-COMP-TRIPPDF-013: renders transport reservation cards', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Flight to Rome') expect(iframe!.srcdoc).toContain('ABC123') // Single-leg flight keeps its full-route subtitle. expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO') }) it('FE-COMP-TRIPPDF-013f: the stop a booked night wrote is not listed, so a morning flight prints before the hotel (#2434)', async () => { // The desktop toolbar hands the export the store's assignments, hidden stop and // all, and that stop heads its check-in day. The day plan hides it and shows the // booking as the accommodation block, and the print lists what the plan lists. const hotelPlace = { id: 101, name: 'Hotel Hafen Hamburg', address: 'Seewartenstr. 9', place_time: null, price: '120' } as any const hotelStop = { id: 201, day_id: 10, place_id: 101, order_index: 0, accommodation_id: 30, place: hotelPlace } const flight = { ...transportReservation, title: 'Morning flight', reservation_time: '2025-06-01T08:00:00' } await downloadTripPDF({ ...richArgs, assignments: { '10': [hotelStop, { ...assignmentForDay, order_index: 1 }] } as any, reservations: [flight], }) const html = getIframe()!.srcdoc expect(html).not.toContain('Seewartenstr. 9') expect(html.indexOf('Morning flight')).toBeLessThan(html.indexOf('Colosseum')) expect(html.indexOf('Morning flight')).toBeGreaterThan(-1) // The cover and the day header read the same list: the hidden stop is not a planned // stop and its hotel's price is not part of the day, whichever shell asked. expect(plannedStat(html)).toBe(1) const money = html.replace(/[\u00a0\u202f]/g, ' ') expect(money).toContain('15,00 €') expect(money).not.toContain('135,00 €') }) it('FE-COMP-TRIPPDF-013g: a service stop the plan keeps to the road trip view is not printed either, and comes back with the switch', async () => { // "Show in Days too" off keeps petrol stations and rest areas out of the day lists. // The print follows the same switch, so the desktop, which hands over the store's // assignments, and the phone print the same day. const pump = { id: 102, name: 'Aral Autohof', address: 'Autobahnkreuz 1', stop_type: 'fuel', place_time: null } as any const pumpStop = { id: 202, day_id: 10, place_id: 102, order_index: 1, place: pump } const assignments = { '10': [assignmentForDay, pumpStop] } as any await downloadTripPDF({ ...richArgs, assignments, showServiceStops: false }) const hidden = getIframe()!.srcdoc expect(hidden).not.toContain('Aral Autohof') expect(hidden).toContain('Colosseum') expect(plannedStat(hidden)).toBe(1) // On, and by default, the stop prints where the plan lists it. getOverlay()?.remove() await downloadTripPDF({ ...richArgs, assignments }) const shown = getIframe()!.srcdoc expect(shown).toContain('Aral Autohof') expect(plannedStat(shown)).toBe(2) }) it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => { const sameDay = { ...transportReservation, reservation_end_time: '2025-06-01T16:45:00' } await downloadTripPDF({ ...richArgs, reservations: [sameDay] }) const iframe = getIframe() // Without the landing time the reader cannot tell what is left of the day. expect(iframe!.srcdoc).toContain('14:30 – 16:45') }) it('FE-COMP-TRIPPDF-013d: a flight without a landing time still shows its departure', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('14:30') expect(iframe!.srcdoc).not.toContain('14:30 –') }) it('FE-COMP-TRIPPDF-013e: an overnight flight keeps its arrival on the arrival day (#1310)', async () => { // Landing tomorrow: the departure day must not carry tomorrow's clock next // to today's departure — the arrival day shows it as its own time. const overnight = { ...transportReservation, day_id: 10, end_day_id: 11, reservation_end_time: '2025-06-02T06:15:00' } await downloadTripPDF({ ...richArgs, days: [dayWithPlaces, { id: 11, day_number: 2, title: null, date: '2025-06-02' } as never], reservations: [overnight], }) const iframe = getIframe() expect(iframe!.srcdoc).not.toContain('14:30 – 06:15') expect(iframe!.srcdoc).toContain('06:15') }) it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => { await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] }) const iframe = getIframe() // One subtitle line per leg, each with its own flight number and segment route. expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER') expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND') }) it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() // Cover image rendered as background-image on .cover-bg expect(iframe!.srcdoc).toContain('cover.jpg') }) it('FE-COMP-TRIPPDF-023: a cover url cannot close url() and add a second declaration', async () => { // cover_image is a free string on the write path, and the style attribute is decoded // before the CSS is parsed, so the quote and the paren have to be encoded. const hostile = "http://host/a.jpg');background-image:url('http://elsewhere/leak.jpg" await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, cover_image: hostile } as any }) const iframe = getIframe() const style = /
{ server.use( http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [{ id: 1, start_day_id: 10, end_day_id: 10, place_name: 'Hotel Roma', place_address: 'Via Roma 1', check_in: '15:00', check_out: '11:00', notes: 'Breakfast included', confirmation: 'CONF999', }], }) ), ) await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Hotel Roma') expect(iframe!.srcdoc).toContain('CONF999') }) it('FE-COMP-TRIPPDF-016: renders place description and a currency-formatted price chip', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Ancient amphitheater') // richArgs trip has no explicit currency, place has no currency override — // formatMoney falls back to EUR, formatted via Intl (symbol, not literal "EUR" text). expect(iframe!.srcdoc).toContain('15,00') expect(iframe!.srcdoc).toContain('€') }) it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => { const usdArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'USD' }, } await downloadTripPDF(usdArgs) const iframe = getIframe() // Place price chip: place has no currency override, falls back to trip.currency (USD). expect(iframe!.srcdoc).toContain('$15.00') // No literal "EUR" text should leak into a USD trip's export. expect(iframe!.srcdoc).not.toContain('EUR') // Price chip icon must stay currency-neutral — not the euro-shaped glyph. expect(iframe!.srcdoc).not.toContain('M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55') }) it('FE-COMP-TRIPPDF-016c: a place with its own currency overrides the trip currency for its price chip', async () => { const mixedArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'EUR' }, assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, currency: 'JPY', price: '1500' }, }], } as any, } await downloadTripPDF(mixedArgs) const iframe = getIframe() // JPY is a zero-decimal currency (currencyDecimals) and uses its own symbol, not EUR. // Note: Intl renders JPY with the fullwidth yen sign (U+FFE5 "¥"), not U+00A5 "¥". expect(iframe!.srcdoc).toContain('¥1,500') }) it('FE-COMP-TRIPPDF-016d: converts foreign-currency prices into the trip currency for day and cover totals (#1561)', async () => { server.use(http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => { expect(new URL(request.url).searchParams.get('base')).toBe('NOK') return HttpResponse.json([{ quote: 'USD', rate: 0.1 }]) // 1 NOK = 0.1 USD })) const mixedArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'NOK' }, assignments: { '10': [ { ...assignmentForDay, place: { ...placeWithDetails, currency: 'USD', price: '273' } }, { ...assignmentForDay, id: 201, place: { ...placeWithDetails, id: 101, name: 'Museum', currency: null, price: '2500' } }, ], } as any, } await downloadTripPDF(mixedArgs) const srcdoc = getIframe()!.srcdoc.replace(/[\u00A0\u202F]/g, ' ') // 2500 NOK + 273 USD / 0.1 = 5230 NOK, marked approximate, in day header AND cover stat. expect(srcdoc).toContain('≈ 5 230,00 kr') expect((srcdoc.match(/≈ 5 230,00 kr/g) || []).length).toBeGreaterThanOrEqual(2) }) it('FE-COMP-TRIPPDF-016e: falls back to per-currency breakdowns when the FX fetch fails (#1561)', async () => { server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.error())) const mixedArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'NOK' }, assignments: { '10': [ { ...assignmentForDay, place: { ...placeWithDetails, currency: 'USD', price: '2730.27' } }, { ...assignmentForDay, id: 201, place: { ...placeWithDetails, id: 101, name: 'Museum', currency: null, price: '2500' } }, ], } as any, } // Export still resolves — a dead FX endpoint must never break the PDF. await expect(downloadTripPDF(mixedArgs)).resolves.not.toThrow() const srcdoc = getIframe()!.srcdoc.replace(/[\u00A0\u202F]/g, ' ') // Honest breakdown, base currency first; the USD amount is never NOK-labeled. expect(srcdoc).toContain('2 500,00 kr + $2,730.27') expect(srcdoc).not.toContain('≈') expect(srcdoc).not.toMatch(/5 ?230/) }) it('FE-COMP-TRIPPDF-016g: with Costs on, the day and cover totals are the expenses, not the place prices (#2551)', async () => { const args = { ...richArgs, trip: { ...richArgs.trip, currency: 'USD' }, costsEnabled: true, budgetItems: [ { id: 1, trip_id: 10, category: 'activities', name: 'Tickets', total_price: 42, currency: null, place_id: 100 }, { id: 2, trip_id: 10, category: 'other', name: 'Insurance', total_price: 8, currency: null }, ], } await downloadTripPDF(args) const srcdoc = getIframe()!.srcdoc // The day carries the ticket linked to its place, the cover the whole trip. expect(srcdoc).toContain('$42.00') expect(srcdoc).toContain('$50.00') document.getElementById('pdf-preview-overlay')?.remove() // Every expense deleted: nothing is left to add up, whatever price the place carries. await downloadTripPDF({ ...args, budgetItems: [] }) const after = getIframe()!.srcdoc expect(after).not.toContain('$42.00') expect(after).not.toContain('$50.00') expect(after).toContain('$15.00') // the place's own price chip stays on its card }) it('FE-COMP-TRIPPDF-016f: an all-same-currency trip makes no FX request', async () => { let fxCalled = false server.use(http.get('https://api.frankfurter.dev/v2/rates', () => { fxCalled = true return HttpResponse.json([]) })) await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, currency: 'EUR' } }) expect(fxCalled).toBe(false) // Totals render exactly as before for the single-currency case. const srcdoc = getIframe()!.srcdoc.replace(/[\u00A0\u202F]/g, ' ') expect(srcdoc).toContain('15,00 €') expect(srcdoc).not.toContain('≈') }) it('FE-COMP-TRIPPDF-017: renders trip description on cover', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Summer adventure') }) it('FE-COMP-TRIPPDF-018: renders place with direct image URL', async () => { const argsWithImg = { ...richArgs, assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url: '/uploads/colosseum.jpg' }, }], } as any, } await downloadTripPDF(argsWithImg) const iframe = getIframe() expect(iframe!.srcdoc).toContain('colosseum.jpg') }) it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an , not the category icon (#1130)', async () => { const args = { ...richArgs, assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' }, }], } as any, } await downloadTripPDF(args) const iframe = getIframe() // The proxy path (no file extension) must still embed as an absolute . expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes') expect(iframe!.srcdoc).toContain('class="place-thumb"') }) it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => { let photoCalled = false server.use( http.get('/api/maps/place-photo/:placeId', () => { photoCalled = true return HttpResponse.json({ photoUrl: 'https://example.com/photo.jpg' }) }), ) const argsWithGooglePlace = { ...richArgs, assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url: null, google_place_id: 'ChIJrTLr-GyuEmsRBfy61i59si0' }, }], } as any, } await downloadTripPDF(argsWithGooglePlace) expect(photoCalled).toBe(true) }) it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => { let fetchedId: string | null = null server.use( http.get('/api/maps/place-photo/:placeId', ({ params }) => { fetchedId = params.placeId as string return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' }) }), ) // The assignment projection drops osm_id; the full place in `places` carries it. const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 } const args = { ...richArgs, places: [osmPlace], assignments: { '10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }], } as any, } await downloadTripPDF(args) // osm_id is used as the photo key (not the coords fallback), proving the pool lookup works. expect(fetchedId).toBe('node/240109189') }) it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => { const args = { ...minimalArgs, days: [{ id: 99, day_number: 2, title: 'Free Day', date: '2025-06-02' }] as any[], assignments: {}, } await downloadTripPDF(args) const iframe = getIframe() // The empty-day div should appear (contains the translation key for empty day) expect(iframe!.srcdoc).toContain('dayplan.emptyDay') }) it('FE-COMP-TRIPPDF-021: appends plugin pdf sections after the days, escaped', async () => { server.use( http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({ sections: [{ pluginId: 'weather', title: 'Weather Forecast', paragraphs: ['Sunny all week'], table: { headers: ['Day', 'Temp'], rows: [['Mon', '24°C']] }, }], }) ), ) await downloadTripPDF(richArgs) const srcdoc = getIframe()!.srcdoc expect(srcdoc).toContain('class="plugin-section"') // Plugin text is escHtml'd like the core content — no markup passes through. expect(srcdoc).not.toContain('Forecast') expect(srcdoc).toContain('Weather <b>Forecast</b>') expect(srcdoc).toContain('Sunny all week') expect(srcdoc).toContain('24°C') // Sections come after the last day section. expect(srcdoc.indexOf('class="plugin-sections')).toBeGreaterThan(srcdoc.lastIndexOf('class="day-section')) }) it('FE-COMP-TRIPPDF-022: renders no plugin block when the sections fetch fails (fail-safe)', async () => { server.use(http.get('/api/pdf-sections/:tripId', () => HttpResponse.error())) await expect(downloadTripPDF(minimalArgs)).resolves.not.toThrow() expect(getIframe()!.srcdoc).not.toContain('class="plugin-sections') }) }) // FE-W5PDF-001 to FE-W5PDF-030 — multi-day transport spans, the remaining // reservation subtitles, accommodation phases and cover fallbacks. describe('downloadTripPDF remaining branches', () => { const dA = { id: 10, day_number: 1, title: 'Day A', date: '2025-06-01' } as any const dB = { id: 11, day_number: 2, title: null, date: null } as any const dC = { id: 12, day_number: 3, title: 'Day C', date: '2025-06-03' } as any const spanArgs = (reservations: any[], overrides: Record = {}) => ({ ...minimalArgs, trip: { id: 10, title: 'Span Trip', description: null, cover_image: null } as any, days: [dA, dB, dC], reservations, ...overrides, }) const srcdoc = () => getIframe()!.srcdoc // #2066 — the document is assembled outside React, so it read no setting at all // and printed the stored column. Four surfaces carried a clock; all four were 24h // whatever the reader had chosen. describe('time format (#2066)', () => { // The place chip reads the assignment's embedded place, not the places array, // so both have to carry the clock under test. const at = (placeTime: string, over: Record = {}) => { const place = { ...placeWithDetails, place_time: placeTime } return { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] }, reservations: [{ id: 700, title: 'Ferry', type: 'ferry', day_id: 10, reservation_time: '2025-06-01T09:05', reservation_end_time: '2025-06-01T16:45', }], ...over, } } it('FE-W5PDF-031: a 12h reader gets meridiem clocks on places and transports', async () => { await downloadTripPDF(at('14:30', { timeFormat: '12h' }) as never) const html = srcdoc() expect(html).toContain('2:30 PM') expect(html).toContain('9:05 AM') expect(html).toContain('4:45 PM') expect(html).not.toContain('14:30') expect(html).not.toContain('16:45') }) it('FE-W5PDF-032: a 24h reader gets the same times without a meridiem', async () => { await downloadTripPDF(at('14:30', { timeFormat: '24h' }) as never) const html = srcdoc() expect(html).toContain('14:30') expect(html).toContain('09:05') expect(html).toContain('16:45') expect(html).not.toContain('2:30 PM') }) // check_in / check_out come off the accommodation row and were the one pair // that printed the raw column in BOTH directions. it('FE-W5PDF-033: accommodation check-in and check-out follow the setting too', async () => { server.use(http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [{ id: 1, place_id: 1, place_name: 'Hotel Roma', place_address: 'Via Roma 1', start_day_id: 10, end_day_id: 10, check_in: '15:00', check_out: '11:00', notes: null, }], }))) await downloadTripPDF(at('14:30', { timeFormat: '12h' }) as never) expect(srcdoc()).toContain('3:00 PM') }) // A clock stored with a meridiem — the booking importer and a 12h user typing // into the place form both produce one — must not print as 3 AM for a 24h reader. it('FE-W5PDF-034: a stored meridiem is converted, not printed raw', async () => { await downloadTripPDF(at('3:00 PM', { timeFormat: '24h' }) as never) const html = srcdoc() expect(html).toContain('15:00') expect(html).not.toContain('3:00 PM') }) }) it('FE-W5PDF-001: a multi-day cruise is labelled start / ongoing / end with the right times', async () => { await downloadTripPDF(spanArgs([{ id: 500, title: 'Nordic Cruise', type: 'cruise', day_id: 10, end_day_id: 12, reservation_time: '2025-06-01T09:00', reservation_end_time: '2025-06-03T18:00', }])) const html = srcdoc() expect(html).toContain('reservations.span.start: Nordic Cruise') expect(html).toContain('reservations.span.ongoing: Nordic Cruise') expect(html).toContain('reservations.span.end: Nordic Cruise') expect(html).toContain('09:00') expect(html).toContain('18:00') }) it('FE-W5PDF-002: a multi-day flight uses departure/arrival wording', async () => { await downloadTripPDF(spanArgs([{ id: 501, title: 'Red Eye', type: 'flight', day_id: 10, end_day_id: 11, reservation_time: '2025-06-01T23:00', reservation_end_time: '2025-06-02T06:00', metadata: JSON.stringify({}), }])) const html = srcdoc() expect(html).toContain('reservations.span.departure: Red Eye') expect(html).toContain('reservations.span.arrival: Red Eye') }) it('FE-W5PDF-003: a multi-day car hire is labelled pickup/return and skips the middle day', async () => { await downloadTripPDF(spanArgs([{ id: 502, title: 'Rental', type: 'car', day_id: 10, end_day_id: 12, reservation_time: '2025-06-01T10:00', reservation_end_time: '2025-06-03T10:00', }])) const html = srcdoc() expect(html).toContain('reservations.span.pickup: Rental') expect(html).toContain('reservations.span.return: Rental') expect(html).not.toContain('reservations.span.active') }) it('FE-W5PDF-029: a multi-day parking is labelled drop-off/pickup and skips the middle day (#1937)', async () => { await downloadTripPDF(spanArgs([{ id: 506, title: 'Airport Parking', type: 'parking', day_id: 10, end_day_id: 12, reservation_time: '2025-06-01T05:30', reservation_end_time: '2025-06-03T19:00', }])) const html = srcdoc() expect(html).toContain('reservations.span.dropOff: Airport Parking') expect(html).toContain('reservations.span.pickup: Airport Parking') expect(html).not.toContain('reservations.span.ongoing') // Day B holds nothing else, so it prints the empty-day hint rather than the booking. expect(html).toContain('dayplan.emptyDay') }) it('FE-W5PDF-004: hotels, day-less and unknown-day reservations are all skipped', async () => { await downloadTripPDF(spanArgs([ { id: 503, title: 'Hotel Row', type: 'hotel', day_id: 10 }, { id: 504, title: 'Floating', type: 'event', day_id: null }, { id: 505, title: 'Ghost Span', type: 'train', day_id: 900, end_day_id: 901 }, ])) const html = srcdoc() expect(html).not.toContain('Hotel Row') expect(html).not.toContain('Floating') expect(html).not.toContain('Ghost Span') expect(html).toContain('dayplan.emptyDay') }) it('FE-W5PDF-005: a single-leg train renders its number, platform, seat and route', async () => { await downloadTripPDF(spanArgs([{ id: 506, title: 'ICE 599', type: 'train', day_id: 10, reservation_time: '10:15', endpoints: [{ sequence: 1, code: 'BER' }, { sequence: 0, code: 'FRA' }], metadata: { train_number: 'ICE 599', platform: '7', seat: '21A' }, }])) const html = srcdoc() expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER') }) it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => { await downloadTripPDF(spanArgs([{ id: 507, title: 'Alpine Run', type: 'train', day_id: 10, metadata: JSON.stringify({ legs: [ { train_number: 'IC 1', platform: '3', from: 'BER', to: 'MUC' }, { train_number: 'EC 2', from: 'MUC', to: 'ZRH' }, ], }), }])) const html = srcdoc() expect(html).toContain('IC 1 · Gl. 3 · BER → MUC') expect(html).toContain('EC 2 · MUC → ZRH') }) it('FE-W5PDF-007: restaurant, event, tour and unknown types get their own subtitles', async () => { await downloadTripPDF(spanArgs([ { id: 508, title: 'Dinner', type: 'restaurant', day_id: 10, metadata: { party_size: 4 } }, { id: 509, title: 'Concert', type: 'event', day_id: 10, metadata: { venue: 'Arena' } }, { id: 510, title: 'Walk', type: 'tour', day_id: 10, metadata: { operator: 'GuideCo' } }, { id: 511, title: 'Mystery', type: 'submarine', day_id: 10, metadata: null, location: 'Docks' }, ])) const html = srcdoc() expect(html).toContain('4 guests') expect(html).toContain('Arena') expect(html).toContain('GuideCo') expect(html).toContain('Mystery') expect(html).toContain('Docks') }) it('FE-W5PDF-008: reservation positions come from day_positions, then the plan position, then the end', async () => { await downloadTripPDF(spanArgs([ { id: 512, title: 'By Numeric Key', type: 'event', day_id: 10, day_positions: { 10: -5 } }, { id: 513, title: 'By String Key', type: 'event', day_id: 10, day_positions: { '10': -4 } }, { id: 514, title: 'By Plan Position', type: 'event', day_id: 10, day_plan_position: -3 }, { id: 515, title: 'By Fallback', type: 'event', day_id: 10 }, ])) const html = srcdoc() expect(html.indexOf('By Numeric Key')).toBeLessThan(html.indexOf('By String Key')) expect(html.indexOf('By String Key')).toBeLessThan(html.indexOf('By Plan Position')) expect(html.indexOf('By Plan Position')).toBeLessThan(html.indexOf('By Fallback')) }) it('FE-W5PDF-009: a flight leg without a route still lists airline and number', async () => { await downloadTripPDF(spanArgs([{ id: 516, title: 'Charter', type: 'flight', day_id: 10, metadata: JSON.stringify({ legs: [ { airline: 'AirX', flight_number: 'X1' }, { airline: 'AirX', flight_number: 'X2', from: 'AAA', to: 'BBB' }, ], }), }])) const html = srcdoc() expect(html).toContain('AirX · X1') expect(html).toContain('AirX · X2 · AAA → BBB') }) it('FE-W5PDF-030: a stopover flight prints the booking code of each segment that has one (#1943)', async () => { await downloadTripPDF(spanArgs([{ id: 518, title: 'Layover', type: 'flight', day_id: 10, confirmation_number: 'BOOK1', metadata: JSON.stringify({ legs: [ { airline: 'LH', flight_number: 'LH1', from: 'FRA', to: 'BER', confirmation_number: 'ABC123' }, { airline: 'ANA', flight_number: 'NH2', from: 'BER', to: 'HND' }, ], }), }])) const html = srcdoc() expect(html).toContain('LH · LH1 · FRA → BER · ABC123') // A segment without one keeps the line it had before, and the booking's own // reference still prints once for the whole card. expect(html).toContain('ANA · NH2 · BER → HND') expect(html).toContain('Code: BOOK1') }) it('FE-W5PDF-010: a single-leg flight with waypoints joins the whole route', async () => { await downloadTripPDF(spanArgs([{ id: 517, title: 'Long Haul', type: 'flight', day_id: 10, endpoints: [{ sequence: 0, code: 'FRA' }, { sequence: 1, name: 'Berlin' }, { sequence: 2, code: 'HND' }], metadata: { airline: 'LH', flight_number: 'LH7' }, }])) expect(srcdoc()).toContain('LH · LH7 · FRA → Berlin → HND') }) it('FE-W5PDF-011: an assignment without a place contributes nothing', async () => { await downloadTripPDF(spanArgs([], { assignments: { '10': [{ id: 1, day_id: 10, order_index: 0, place: null }] } as any, })) expect(srcdoc()).toContain('class="day-body"') expect(srcdoc()).not.toContain('class="place-card"') }) it('FE-W5PDF-012: a bare place renders without badge, address, coordinates or chips', async () => { await downloadTripPDF(spanArgs([], { assignments: { '10': [{ id: 1, day_id: 10, order_index: 0, place: { id: 70, name: 'Bare Place', price: '0' } }], } as any, })) const html = srcdoc() expect(html).toContain('Bare Place') expect(html).not.toContain('class="cat-badge"') expect(html).not.toContain('
') expect(html).toContain('class="place-thumb-fallback"') }) it('FE-W5PDF-013: coordinates are printed for a place that has no address', async () => { await downloadTripPDF(spanArgs([], { assignments: { '10': [{ id: 1, day_id: 10, order_index: 0, place: { id: 71, name: 'Pin Only', lat: 48.858093, lng: 2.294694 } }], } as any, })) expect(srcdoc()).toContain('48.85809, 2.29469') }) it('FE-W5PDF-014: a note without a time renders only its text and falls back to the default icon', async () => { await downloadTripPDF(spanArgs([], { dayNotes: [{ id: 1, day_id: 10, text: 'Just a note', time: null, icon: 'NotAnIcon', sort_order: 0 }] as any, })) const html = srcdoc() expect(html).toContain('Just a note') expect(html).not.toContain('class="note-time"') }) it('FE-W5PDF-015: check-in, middle and check-out days each get their own accommodation block', async () => { server.use( http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [{ id: 1, start_day_id: 10, end_day_id: 12, place_name: 'Hotel Nord', place_address: 'Main St', check_in: '15:00', check_out: '10:00', confirmation: 'CONF-9', notes: 'Late arrival', }], }), ), ) await downloadTripPDF(spanArgs([])) const html = srcdoc() expect(html).toContain('reservations.meta.checkIn') expect(html).toContain('reservations.meta.linkAccommodation') expect(html).toContain('reservations.meta.checkOut') expect(html).toContain('Hotel Nord') expect(html).toContain('Main St') expect(html).toContain('Late arrival') expect(html).toContain('CONF-9') expect(html).toContain('class="day-accommodations single"') }) it('FE-W5PDF-016: two accommodations on one day are ordered by their start day', async () => { server.use( http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [ { id: 2, start_day_id: 11, end_day_id: 12, place_name: 'Later Inn', place_address: null, check_in: null, check_out: null, confirmation: null }, { id: 1, start_day_id: 10, end_day_id: 12, place_name: 'Earlier Inn', place_address: null, check_in: null, check_out: null, confirmation: null }, ], }), ), ) await downloadTripPDF(spanArgs([])) const html = srcdoc() expect(html.indexOf('Earlier Inn')).toBeLessThan(html.indexOf('Later Inn')) // the shared days list two hotels, so they lose the single-column class expect(html).toContain('class="day-accommodations "') }) it('FE-W5PDF-017: a trip without dated days prints no date range and no day dates', async () => { await downloadTripPDF({ ...minimalArgs, trip: { id: 1, title: 'Undated', description: null, cover_image: null } as any, days: [{ id: 1, day_number: 1, title: null, date: null }] as any[], }) const html = srcdoc() expect(html).not.toContain('class="cover-dates"') expect(html).not.toContain('class="day-date"') expect(html).toContain('class="cover-circle-ph"') }) it('FE-W5PDF-018: the print button asks the preview frame to print', async () => { await downloadTripPDF(minimalArgs) const iframe = getIframe()! const print = vi.fn() Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true }) document.getElementById('pdf-print-btn')!.click() expect(print).toHaveBeenCalled() }) it('FE-W5PDF-019: an external cover image is used verbatim, a non-image one is dropped', async () => { await downloadTripPDF({ ...minimalArgs, trip: { id: 1, title: 'Cover Trip', description: null, cover_image: 'https://cdn.example.com/a.jpg' } as any, }) expect(srcdoc()).toContain('https://cdn.example.com/a.jpg') getOverlay()?.remove() await downloadTripPDF({ ...minimalArgs, trip: { id: 1, title: 'Cover Trip', description: null, cover_image: '/uploads/cover.svg' } as any, }) expect(srcdoc()).not.toContain('/uploads/cover.svg') expect(srcdoc()).toContain('class="cover-circle-ph"') }) it('FE-W5PDF-020: plugin sections render their paragraphs and tables', async () => { server.use( http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({ sections: [ { title: 'Packing', paragraphs: ['Bring a towel'], table: { headers: ['Item', 'Qty'], rows: [['Socks', '3']] } }, { title: 'Bare', paragraphs: null, table: null }, ], }), ), ) await downloadTripPDF(minimalArgs) const html = srcdoc() expect(html).toContain('Packing') expect(html).toContain('Bring a towel') expect(html).toContain('
') expect(html).toContain('') expect(html).toContain('Bare') }) }) // FE-W5PDF-021 to FE-W5PDF-026 — the defaulting arms of the exporter. describe('downloadTripPDF defaults', () => { type Args = Parameters[0] const srcdoc = () => getIframe()!.srcdoc it('FE-W5PDF-021: a call without days, places, notes or a translator still produces a document', async () => { await downloadTripPDF({ trip: { id: 1 }, assignments: {}, locale: 'en-US' } as unknown as Args) const html = srcdoc() // no translator, no days, no places, no notes expect(html).toContain('pdf.travelPlan') expect(html).toContain('
My Trip
') expect(html).toContain('
0
') }) it('FE-W5PDF-022: an accommodations response without the key degrades to no hotels', async () => { server.use( http.get('/api/trips/:id/accommodations', () => HttpResponse.json({})), http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({})), ) await downloadTripPDF({ ...minimalArgs } as unknown as Args) expect(srcdoc()).not.toContain('day-accommodations-overview"') expect(srcdoc()).not.toContain('class="plugin-sections') }) it('FE-W5PDF-023: items without an explicit order fall back to position zero', async () => { await downloadTripPDF({ ...minimalArgs, days: [{ id: 1, day_number: 1, title: 'Only Day', date: '2025-06-01' }] as any[], assignments: { '1': [{ id: 1, day_id: 1, order_index: null, place: { id: 5, name: 'Unordered Place' } }] } as any, dayNotes: [{ id: 2, day_id: 1, text: 'Unordered Note', sort_order: null, icon: 'Info' }] as any, }) const html = srcdoc() expect(html).toContain('Unordered Place') expect(html).toContain('Unordered Note') }) it('FE-W5PDF-024: an empty metadata string and a missing title are handled', async () => { await downloadTripPDF({ ...minimalArgs, trip: { id: 1, title: null, description: null, cover_image: null } as any, days: [{ id: 1, day_number: 1, title: null, date: '2025-06-01' }] as any[], reservations: [{ id: 1, title: null, type: 'restaurant', day_id: 1, metadata: '' }] as any[], }) const html = srcdoc() expect(html).toContain('pdf.travelPlan') expect(html).toContain('note-card') expect(html).toContain('Day 1') }) it('FE-W5PDF-025: an extensionless relative image url is resolved against the origin', async () => { await downloadTripPDF({ ...minimalArgs, days: [{ id: 1, day_number: 1, title: 'Only Day', date: '2025-06-01' }] as any[], assignments: { '1': [{ id: 1, day_id: 1, order_index: 0, place: { id: 5, name: 'Proxy Photo', image_url: 'uploads/places/photo.jpg' } }], } as any, }) // no leading slash: absUrl has to insert one expect(srcdoc()).toContain('http://localhost:3000/uploads/places/photo.jpg') }) it('FE-W5PDF-026: the last day of a span without an end time prints no time', async () => { await downloadTripPDF({ ...minimalArgs, days: [ { id: 1, day_number: 1, title: 'Start Day', date: '2025-06-01' }, { id: 2, day_number: 2, title: 'End Day', date: '2025-06-02' }, ] as any[], reservations: [{ id: 1, title: 'Overnight Bus', type: 'bus', day_id: 1, end_day_id: 2, reservation_time: '22:00', reservation_end_time: null }] as any[], }) const html = srcdoc() const endSection = html.slice(html.indexOf('End Day')) expect(html).toContain('reservations.span.start: Overnight Bus') expect(endSection).toContain('reservations.span.end: Overnight Bus') expect(endSection).not.toContain('22:00') }) it('FE-W5PDF-027: an export without a locale still renders, with lang="en"', async () => { await downloadTripPDF({ ...minimalArgs, locale: '' }) expect(srcdoc()).toContain('') }) it('FE-W5PDF-028: an export without assignments renders the days as empty', async () => { await downloadTripPDF({ ...minimalArgs, assignments: undefined as any }) expect(srcdoc()).toContain('Day 1') }) }) // #1292 — every day started a new page, so a plan of short days printed one // sheet per handful of lines. The flowing layout is opt-in and remembered. describe('page breaks between days (#1292)', () => { const twoDays = { ...minimalArgs, days: [ { id: 1, day_number: 1, title: 'First', date: '2025-06-01' }, { id: 2, day_number: 2, title: 'Second', date: '2025-06-02' }, ] as any[], } beforeEach(() => { localStorage.removeItem('trek_pdf_page_break_per_day') }) const toggle = () => document.querySelector('#pdf-daybreak-toggle') const isOn = () => toggle()!.getAttribute('aria-checked') === 'true' it('FE-PDF-BREAK-001: days break onto their own page by default', async () => { await downloadTripPDF(twoDays) const html = getIframe()!.srcdoc expect(html).toContain('day-section day-break') expect(html).not.toContain('class="pdf-flow"') expect(isOn()).toBe(true) }) it('FE-PDF-BREAK-001b: the control is the switch from the rest of the app, not a checkbox', async () => { await downloadTripPDF(twoDays) const el = toggle()! expect(el.tagName).toBe('BUTTON') expect(el.getAttribute('role')).toBe('switch') expect(el.style.background).toContain('--accent') expect(document.querySelector('#pdf-daybreak-toggle input')).toBeNull() }) it('FE-PDF-BREAK-002: the remembered flowing layout comes back on the next export', async () => { localStorage.setItem('trek_pdf_page_break_per_day', '0') await downloadTripPDF(twoDays) const html = getIframe()!.srcdoc // The class stays on the day; the body is what decides whether it breaks. expect(html).toContain('day-section day-break') expect(html).toContain('') expect(isOn()).toBe(false) // Off uses the neutral track, so the two states are told apart by more than the knob. expect(toggle()!.style.background).toContain('--border-primary') }) it('FE-PDF-BREAK-003: the first day never carries a break of its own', async () => { await downloadTripPDF(twoDays) const html = getIframe()!.srcdoc expect(html.indexOf('
ItemSocks
')).toBeGreaterThan(-1) expect(html.match(/day-section day-break/g)).toHaveLength(1) }) it('FE-PDF-BREAK-004: turning the breaks off is remembered and shown at once', async () => { await downloadTripPDF(twoDays) toggle()!.click() expect(isOn()).toBe(false) expect(localStorage.getItem('trek_pdf_page_break_per_day')).toBe('0') const body = getIframe()!.contentDocument?.body if (body) expect(body.classList.contains('pdf-flow')).toBe(true) }) it('FE-PDF-BREAK-005: turning them back on is remembered too', async () => { localStorage.setItem('trek_pdf_page_break_per_day', '0') await downloadTripPDF(twoDays) toggle()!.click() expect(isOn()).toBe(true) expect(localStorage.getItem('trek_pdf_page_break_per_day')).toBe('1') const body = getIframe()!.contentDocument?.body if (body) expect(body.classList.contains('pdf-flow')).toBe(false) }) it('FE-PDF-BREAK-006: a storage the browser blocks costs the preference, not the export', async () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked') }) vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked') }) await downloadTripPDF(twoDays) const html = getIframe()!.srcdoc expect(html).toContain('day-section day-break') expect(html).not.toContain('class="pdf-flow"') expect(() => toggle()!.click()).not.toThrow() }) // Verified against a real print engine: without this rule Chromium tore a stay // across the page edge, the check-in time on one sheet and the hotel name on // the next. Place and note cards already carried it. it('FE-PDF-BREAK-007: a stay may not be split by a page edge', async () => { await downloadTripPDF(twoDays) const html = getIframe()!.srcdoc const rule = html.slice(html.indexOf('.day-accommodation {')) expect(rule.slice(0, rule.indexOf('}'))).toContain('break-inside: avoid') }) // Measured against Chromium: with the days flowing, a header bar could be left // at the foot of a sheet while its content moved on, and the repeat from #1471 // then read as the same day printed twice. break-after on the thead and // break-before on the tbody are both ignored; holding the day together is what // works, and a day too long for one page still breaks and still repeats. it('FE-PDF-BREAK-008: a flowing day is held together so its header is never stranded', async () => { localStorage.setItem('trek_pdf_page_break_per_day', '0') await downloadTripPDF(twoDays) const html = getIframe()!.srcdoc expect(html).toContain('.pdf-flow .day-section { break-inside: avoid; page-break-inside: avoid; }') // Scoped: a day that starts its own page cannot strand a header. const at = html.indexOf('.day-section { break-inside: avoid') expect(html.slice(at - 10, at)).toContain('.pdf-flow ') }) }) /** * The order a day prints in (#1978). * * The export used to order a day by itself, on `day_plan_position` — one global * number per booking, auto-seeded from whichever day happened to render first. * On any other day of a span it therefore pointed at the wrong slot, and where * it was still null the export fell back to the order the API returned rows in. * That is why the same trip printed correctly some of the time and swapped two * bookings the rest of it, and why adding a place and removing it again "fixed" * it: the reseed happened to land on the day being looked at. * * It now goes through utils/dayMerge, the same functions the day plan uses, so * the two cannot disagree. These cases are written from the plan's rules rather * than from the old implementation's. */ describe('a printed day follows the plan (#1978)', () => { const d1 = { id: 21, day_number: 1, title: 'Day One', date: '2025-06-01' } as any const d2 = { id: 22, day_number: 2, title: 'Day Two', date: '2025-06-02' } as any const srcdoc = () => getIframe()!.srcdoc /** Where each needle first appears, so order can be asserted without parsing. */ const positions = (html: string, needles: string[]) => needles.map(n => html.indexOf(n)) it('orders the day by the clock, not by the order the API sent rows in', async () => { await downloadTripPDF({ ...minimalArgs, trip: { id: 21, title: 'Order Trip', description: null, cover_image: null } as any, days: [d1], // As the API returns them: reservation_time ASC with nulls first. reservations: [ { id: 1, title: 'Untimed Transfer', type: 'transfer', day_id: 21, reservation_time: null }, { id: 2, title: 'Evening Train', type: 'train', day_id: 21, reservation_time: '2025-06-01T19:30' }, { id: 3, title: 'Morning Flight', type: 'flight', day_id: 21, reservation_time: '2025-06-01T07:15' }, ], }) const [morning, evening] = positions(srcdoc(), ['Morning Flight', 'Evening Train']) expect(morning).toBeGreaterThan(-1) expect(evening).toBeGreaterThan(-1) expect(morning).toBeLessThan(evening) }) /* * The reported shape: a booking on the last day of a span whose global * position was seeded from a busier earlier day, which sank it below * everything on the day it actually belongs to. */ it('ignores a global position seeded from another day', async () => { await downloadTripPDF({ ...minimalArgs, trip: { id: 22, title: 'Span Trip', description: null, cover_image: null } as any, days: [d1, d2], reservations: [ { id: 4, title: 'Car Return', type: 'car', day_id: 21, end_day_id: 22, reservation_time: '2025-06-01T10:00', reservation_end_time: '2025-06-02T09:00', // Seeded from day one, where five places had already been placed. day_plan_position: 5, }, { id: 5, title: 'Late Flight', type: 'flight', day_id: 22, reservation_time: '2025-06-02T18:00' }, ], }) const html = srcdoc() // On day two the 09:00 return comes before the 18:00 flight, whatever the // stale global position says. const [ret, flight] = positions(html, ['Car Return', 'Late Flight']) expect(ret).toBeGreaterThan(-1) expect(flight).toBeGreaterThan(-1) expect(ret).toBeLessThan(flight) }) /* * The strongest form of the same claim: whatever the plan would show, the * print shows, in that order. Asserted against getMergedItems itself rather * than against a hand-written expectation, so the two cannot drift apart * again without this failing. */ it('prints a mixed day in exactly the order the plan merges it', async () => { const reservations = [ { id: 8, title: 'Untimed Transfer', type: 'transfer', day_id: 21, reservation_time: null }, { id: 9, title: 'Evening Train', type: 'train', day_id: 21, reservation_time: '2025-06-01T19:30' }, { id: 10, title: 'Morning Flight', type: 'flight', day_id: 21, reservation_time: '2025-06-01T07:15' }, ] const dayAssignments = [ { id: 100, day_id: 21, order_index: 0, place: { id: 1, name: 'Museum', place_time: '11:00' } }, { id: 101, day_id: 21, order_index: 1, place: { id: 2, name: 'Market', place_time: '16:00' } }, ] await downloadTripPDF({ ...minimalArgs, trip: { id: 24, title: 'Mixed Trip', description: null, cover_image: null } as any, days: [d1], assignments: { '21': dayAssignments } as any, reservations, }) const html = srcdoc() const expected = getMergedItems({ dayAssignments, dayNotes: [], dayTransports: getTransportForDay({ reservations, dayId: 21, dayAssignmentIds: dayAssignments.map(a => a.id), days: [d1], }), dayId: 21, }).map(item => (item.type === 'place' ? item.data.place.name : item.data.title)) const seen = expected.map(name => html.indexOf(name)) for (const at of seen) expect(at).toBeGreaterThan(-1) expect(seen).toEqual([...seen].sort((a, b) => a - b)) }) })