Files
TREK/client/tests/unit/mobile/trip/MDaysSheet.test.tsx
T
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00

147 lines
6.5 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 { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-015
//
// 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()
})
})