Files
TREK/client/tests/unit/mobile/trip/MDaysSheet.test.tsx
T
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00

193 lines
8.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import MDaysSheet from '../../../../src/mobile/screens/trip/sheets/MDaysSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { cleanup, fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-019
//
// The sheet reads its copy from the real TranslationProvider (useTranslation),
// not from planner.t — assertions therefore go against the English strings.
// Deliberately unsorted: the sheet orders by day_number.
const DAYS = [
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
] as unknown as Day[]
function renderSheet(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner({ days: DAYS, ...plannerOverrides })
const shell = buildShell({ sheet: { id: 'days' }, ...shellOverrides })
render(<MDaysSheet planner={planner} shell={shell} />)
return { planner, shell }
}
describe('MDaysSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-DAYSS-001: stays closed while another sheet id is active', () => {
renderSheet({}, { sheet: { id: 'mehr' } })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-002: stays closed when no sheet is open at all', () => {
renderSheet({}, { sheet: null })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-003: renders the reorder header with its hint', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Reorder days' })).toBeInTheDocument()
expect(screen.getByText("A day's places, notes and bookings move with it.")).toBeInTheDocument()
})
it('FE-MOB-DAYSS-003b: the hint wraps instead of being cut off mid-sentence', () => {
// The shared header truncates its subtitle to one line, which is right for a
// filename but cut this sentence off in every language (#1814).
renderSheet()
const hint = screen.getByText("A day's places, notes and bookings move with it.")
expect(hint).not.toHaveClass('truncate')
})
it('FE-MOB-DAYSS-004: lists the days in day_number order and numbers the rows', () => {
renderSheet()
const positions = screen.getAllByText(/^[123]$/).map(el => el.textContent)
expect(positions).toEqual(['1', '2', '3'])
const labels = ['Fri, May 1', 'Old Town', 'Sun, May 3']
for (const label of labels) expect(screen.getByText(label)).toBeInTheDocument()
})
it('FE-MOB-DAYSS-005: falls back to "Day n" when neither title nor a usable date exists', () => {
const days = [
{ id: 7, trip_id: 1, day_number: 1, date: null, title: null },
{ id: 8, trip_id: 1, day_number: 2, date: 'not-a-date', title: '' },
] as unknown as Day[]
renderSheet({ days })
expect(screen.getByText('Day 1')).toBeInTheDocument()
expect(screen.getByText('Day 2')).toBeInTheDocument()
})
it('FE-MOB-DAYSS-006: numbers a day without day_number by its list position', () => {
const days = [{ id: 9, trip_id: 1, day_number: null, date: null, title: null }] as unknown as Day[]
renderSheet({ days })
expect(screen.getByText('Day 1')).toBeInTheDocument()
})
it('FE-MOB-DAYSS-007: moving a day up sends the whole reordered id list', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getAllByRole('button', { name: 'Move up' })[1])
expect(planner.handleReorderDays).toHaveBeenCalledWith([2, 1, 3])
})
it('FE-MOB-DAYSS-008: moving a day down sends the whole reordered id list', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0])
expect(planner.handleReorderDays).toHaveBeenCalledWith([2, 1, 3])
})
it('FE-MOB-DAYSS-009: disables the moves that would leave the list', () => {
renderSheet()
const up = screen.getAllByRole('button', { name: 'Move up' })
const down = screen.getAllByRole('button', { name: 'Move down' })
expect(up[0]).toBeDisabled()
expect(up[2]).toBeEnabled()
expect(down[2]).toBeDisabled()
expect(down[0]).toBeEnabled()
})
it('FE-MOB-DAYSS-010: appends a day', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Add day' }))
expect(planner.handleAddDay).toHaveBeenCalledTimes(1)
})
it('FE-MOB-DAYSS-011: hides every edit affordance for a read-only member', () => {
const { planner } = renderSheet({ can: vi.fn(() => false) })
expect(planner.can).toHaveBeenCalledWith('day_edit', planner.trip)
expect(screen.queryByRole('button', { name: 'Move up' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
// The list itself stays readable.
expect(screen.getByText('Old Town')).toBeInTheDocument()
})
it('FE-MOB-DAYSS-012: the header close button closes the sheet', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
it('FE-MOB-DAYSS-013: each row asks the planner to delete its own day', () => {
const { planner } = renderSheet()
const buttons = screen.getAllByRole('button', { name: 'Delete day' })
expect(buttons).toHaveLength(3)
// Rows run in day_number order: the second one is day 2.
fireEvent.click(buttons[1])
expect(planner.handleDeleteDay).toHaveBeenCalledWith(2)
})
it('FE-MOB-DAYSS-014: a read-only member gets no delete buttons', () => {
renderSheet({ can: vi.fn(() => false) })
expect(screen.queryByRole('button', { name: 'Delete day' })).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-015: while the planner blocks deleting, the buttons are off and the sheet says why', () => {
const { planner } = renderSheet({ deleteDayBlocked: 'A trip needs at least one day' })
const buttons = screen.getAllByRole('button', { name: 'Delete day' })
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
fireEvent.click(buttons[0])
expect(planner.handleDeleteDay).not.toHaveBeenCalled()
expect(screen.getByText('A trip needs at least one day')).toBeInTheDocument()
})
describe('adding a day on a trip with dates', () => {
const dayAdd = (overrides: Partial<TripPlanner['dayAdd']> = {}): TripPlanner['dayAdd'] => ({
nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn(), ...overrides,
})
const datedTile = () => screen.getByRole('button', { name: /^Add .*Oct 13/ })
const undatedTile = () => screen.getByRole('button', { name: /^Add day without date/ })
it('FE-MOB-DAYSS-016: two tiles, the next date and a day without one, each with what it does', () => {
const { planner } = renderSheet({ dayAdd: dayAdd() })
expect(datedTile()).toHaveTextContent(/Extends the trip by one day, until .*Oct 13\./)
expect(undatedTile()).toHaveTextContent('The trip dates stay as they are.')
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
fireEvent.click(datedTile())
expect(planner.dayAdd.onAddDated).toHaveBeenCalledTimes(1)
expect(planner.handleAddDay).not.toHaveBeenCalled()
fireEvent.click(undatedTile())
expect(planner.handleAddDay).toHaveBeenCalledTimes(1)
})
it('FE-MOB-DAYSS-017: a read-only member gets neither tile', () => {
renderSheet({ can: vi.fn(() => false), dayAdd: dayAdd() })
expect(screen.queryByRole('button', { name: /^Add/ })).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-018: busy or offline turns both off, and the offline sentence is said once', () => {
renderSheet({ dayAdd: dayAdd({ busy: true }) })
expect(datedTile()).toBeDisabled()
expect(undatedTile()).toBeDisabled()
cleanup()
const offline = 'Changing days needs a connection'
renderSheet({ deleteDayBlocked: offline, dayAdd: dayAdd({ blocked: offline }) })
expect(datedTile()).toBeDisabled()
expect(undatedTile()).toBeDisabled()
expect(screen.getAllByText(offline)).toHaveLength(1)
})
it('FE-MOB-DAYSS-019: a trip at the day limit keeps the undated tile and says why the dated one is off', () => {
renderSheet({ dayAdd: dayAdd({ datedBlocked: 'A trip can span at most 999 days' }) })
expect(datedTile()).toBeDisabled()
expect(datedTile()).toHaveTextContent('A trip can span at most 999 days')
expect(undatedTile()).toBeEnabled()
})
})
})