Files
TREK/client/tests/unit/mobile/trip/MNoteSheet.test.tsx
T
2026-07-29 11:55:31 +02:00

246 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import MNoteSheet, { type MNoteSheetPayload } from '../../../../src/mobile/screens/trip/sheets/MNoteSheet'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Assignment, DayNote } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-NOTESH-001 to FE-MOB-NOTESH-018
// planner.t echoes the key, so every assertion here is against keys.
const NOTE = {
id: 88,
day_id: 7,
text: 'Buy museum tickets',
time: '09:30 at the kiosk',
icon: 'Ticket',
sort_order: 2,
} as unknown as DayNote
function renderSheet(overrides: {
planner?: ReturnType<typeof buildPlanner>
open?: boolean
payload?: MNoteSheetPayload
} = {}) {
const planner = overrides.planner ?? buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const payload = 'payload' in overrides ? overrides.payload : { dayId: 7 }
const view = render(
<MNoteSheet planner={planner} open={overrides.open ?? true} payload={payload} onClose={onClose} />,
)
return { ...view, planner, onClose, payload }
}
function typeTitle(value: string) {
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value } })
}
describe('MNoteSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-NOTESH-001: renders nothing while the sheet is closed', () => {
renderSheet({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-NOTESH-002: opens in create mode with the default icon and no delete action', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Train' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-003: picking an icon moves the pressed state', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Coffee' }))
expect(screen.getByRole('button', { name: 'Coffee' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-004: keeps the submit disabled until a non-blank title is typed', () => {
renderSheet()
const submit = screen.getByRole('button', { name: 'common.add' })
expect(submit).toBeDisabled()
typeTitle(' ')
expect(submit).toBeDisabled()
typeTitle('Ferry tickets')
expect(submit).toBeEnabled()
})
it('FE-MOB-NOTESH-005: appends the new note behind the last assignment and note of the day', async () => {
seedStore(useTripStore, {
assignments: { '7': [{ order_index: 3 }, { order_index: 1 }] as unknown as Assignment[] },
dayNotes: { '7': [{ sort_order: 5 }] as unknown as DayNote[] },
})
const { planner, onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Plane' }))
typeTitle(' Check in online ')
fireEvent.change(screen.getByPlaceholderText('dayplan.noteSubtitle'), { target: { value: '18:00 at the gate' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
text: 'Check in online',
time: '18:00 at the gate',
icon: 'Plane',
sort_order: 6,
})
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-006: starts at sort order 0 on an empty day and stores an empty detail as null', async () => {
const { planner } = renderSheet()
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
text: 'Sunrise walk',
time: null,
icon: 'FileText',
sort_order: 0,
})
})
it('FE-MOB-NOTESH-007: falls back to the selected day when the payload carries no dayId', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 12 })
renderSheet({ planner, payload: {} })
typeTitle('Laundry')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 12, expect.objectContaining({ text: 'Laundry' }))
})
it('FE-MOB-NOTESH-008: does not save without any day to attach the note to', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: null })
renderSheet({ planner, payload: undefined })
typeTitle('Orphan note')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-009: prefills icon, title and detail when editing an existing note', () => {
renderSheet({ payload: { dayId: 7, note: NOTE } })
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('09:30 at the kiosk')
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'common.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
})
it('FE-MOB-NOTESH-010: updates the existing note instead of creating a second one', async () => {
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
typeTitle('Buy museum tickets online')
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, {
text: 'Buy museum tickets online',
time: '09:30 at the kiosk',
icon: 'Ticket',
})
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-011: deletes the note and closes the sheet', async () => {
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.tripActions.deleteDayNote).toHaveBeenCalledWith(3, 7, 88))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-012: surfaces the save error and keeps the sheet open', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce(new Error('day is locked'))
const { onClose } = renderSheet({ planner })
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('day is locked'))
expect(onClose).not.toHaveBeenCalled()
// isSaving is released again, so a retry is possible.
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
})
it('FE-MOB-NOTESH-013: falls back to the generic message when the rejection is not an Error', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce('nope')
renderSheet({ planner })
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-NOTESH-014: surfaces a failing delete without closing', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.deleteDayNote).mockRejectedValueOnce(new Error('gone already'))
const { onClose } = renderSheet({ planner, payload: { dayId: 7, note: NOTE } })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('gone already'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-015: counts the detail characters and warns near the limit', () => {
renderSheet()
const detail = screen.getByPlaceholderText('dayplan.noteSubtitle')
expect(screen.getByText('0/250')).toBeInTheDocument()
fireEvent.change(detail, { target: { value: 'a'.repeat(100) } })
const relaxed = screen.getByText('100/250')
expect(relaxed.className).toContain('text-m-faint')
fireEvent.change(detail, { target: { value: 'a'.repeat(241) } })
const warned = screen.getByText('241/250')
expect(warned.className).toContain('--m-st-pending')
})
it('FE-MOB-NOTESH-016: cancel and the header close both report the close', () => {
const { onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-MOB-NOTESH-017: keeps the opened payload while the sheet animates out', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const payload: MNoteSheetPayload = { dayId: 7, note: NOTE }
const { rerender } = render(
<MNoteSheet planner={planner} open payload={payload} onClose={onClose} />,
)
rerender(<MNoteSheet planner={planner} open={false} payload={undefined} onClose={onClose} />)
// The parent drops the payload with the sheet id, but the exit animation
// still shows the edit chrome.
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
})
it('FE-MOB-NOTESH-018: reopening on another note replaces the form state', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const first: MNoteSheetPayload = { dayId: 7, note: NOTE }
const second: MNoteSheetPayload = {
dayId: 9,
note: { id: 90, day_id: 9, text: 'Return the car', time: '', icon: 'Car' } as unknown as DayNote,
}
const { rerender } = render(
<MNoteSheet planner={planner} open payload={first} onClose={onClose} />,
)
rerender(<MNoteSheet planner={planner} open payload={second} onClose={onClose} />)
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Return the car')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Car' })).toHaveAttribute('aria-pressed', 'true')
})
})