mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
246 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|