mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
test(components): cover map, collections, settings, journey and admin
This commit is contained in:
@@ -0,0 +1,867 @@
|
||||
// FE-W5ROW-001 to FE-W5ROW-055
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { ArtikelZeile } from './PackingListPanelItemRow'
|
||||
import type { PackingBag, PackingItem } from '../../types'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
|
||||
type Props = ComponentProps<typeof ArtikelZeile>
|
||||
|
||||
const toastSpy = vi.fn((_message: string, _type?: string, _duration?: number) => 0)
|
||||
|
||||
const BAGS = [
|
||||
{ id: 7, name: 'Carry-on', color: '#10b981' },
|
||||
{ id: 8, name: 'Trolley', color: '#ec4899' },
|
||||
] as unknown as PackingBag[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'owner' },
|
||||
{ id: 2, username: 'alice' },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
const dt = () => ({ effectAllowed: '', dropEffect: '', setData: vi.fn(), getData: vi.fn(() => '') })
|
||||
|
||||
function setup(overrides: Partial<Props> = {}) {
|
||||
const props: Props = {
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' }),
|
||||
tripId: 1,
|
||||
categories: ['Gear', 'Docs'],
|
||||
onCategoryChange: () => {},
|
||||
onCreateBag: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
}
|
||||
const utils = render(<ArtikelZeile {...props} />)
|
||||
return { ...utils, props }
|
||||
}
|
||||
|
||||
/** The row's own overflow menu trigger (rendered but display:none on desktop). */
|
||||
function overflowTrigger(container: HTMLElement) {
|
||||
return container.querySelector<HTMLButtonElement>('.packing-row-overflow button')!
|
||||
}
|
||||
|
||||
/**
|
||||
* The overflow menu lives inside a display:none wrapper (it only shows on narrow
|
||||
* viewports), so every query against it has to opt into hidden elements.
|
||||
*/
|
||||
function menu(container: HTMLElement) {
|
||||
return within(container.querySelector<HTMLElement>('.trek-menu-enter')!)
|
||||
}
|
||||
|
||||
function menuButton(container: HTMLElement, name: string) {
|
||||
return menu(container).getByRole('button', { name, hidden: true })
|
||||
}
|
||||
|
||||
function fieldOf(container: HTMLElement, label: string) {
|
||||
return within(menu(container).getByText(label).parentElement!).getByRole('textbox', { hidden: true })
|
||||
}
|
||||
|
||||
function bagButton(container: HTMLElement) {
|
||||
// The bag picker trigger is the round button right after the weight field.
|
||||
return container.querySelectorAll<HTMLButtonElement>('button[style*="border-radius: 50%"]')[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
toastSpy.mockClear()
|
||||
window.__addToast = toastSpy
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — basics', () => {
|
||||
it('FE-W5ROW-001: renders the name and checks the item off', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1, checked: 1 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup()
|
||||
|
||||
expect(screen.getByText('Tent')).toBeInTheDocument()
|
||||
fireEvent.click(container.querySelector('svg.lucide-square')!.closest('button')!)
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ checked: true }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-002: a checked item is struck through and not editable by click', () => {
|
||||
setup({ item: buildPackingItem({ id: 1, name: 'Tent', checked: 1 }) })
|
||||
const label = screen.getByText('Tent')
|
||||
|
||||
expect(label).toHaveStyle({ textDecoration: 'line-through' })
|
||||
fireEvent.click(label)
|
||||
|
||||
expect(screen.queryByDisplayValue('Tent')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-003: the placeholder row edits from an empty field', () => {
|
||||
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }) })
|
||||
|
||||
fireEvent.click(screen.getByText('...'))
|
||||
|
||||
const input = screen.getByPlaceholderText('...')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-004: a read-only row hides the quantity field and the action buttons', () => {
|
||||
const { container } = setup({ canEdit: false })
|
||||
|
||||
expect(screen.queryByTitle('Rename')).toBeNull()
|
||||
expect(screen.queryByTitle('Delete')).toBeNull()
|
||||
expect(container.querySelector('.packing-row-overflow')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Tent'))
|
||||
expect(screen.queryByDisplayValue('Tent')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-005: hovering the row lifts its background and dismisses open pickers', () => {
|
||||
const { container } = setup()
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(row.style.background).toBe('var(--bg-secondary)')
|
||||
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(row.style.background).toBe('transparent')
|
||||
expect(screen.queryByRole('button', { name: 'Docs' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — renaming', () => {
|
||||
it('FE-W5ROW-006: Enter commits the new name', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tarp' }) })
|
||||
}),
|
||||
)
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Rename'))
|
||||
|
||||
const input = screen.getByDisplayValue('Tent')
|
||||
fireEvent.change(input, { target: { value: ' Tarp ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ name: 'Tarp' }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-007: blurring an emptied field restores the old name', async () => {
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Rename'))
|
||||
|
||||
const input = screen.getByDisplayValue('Tent')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Tent')).toBeNull())
|
||||
expect(screen.getByText('Tent')).toBeInTheDocument()
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-008: Escape discards the edit', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByText('Tent'))
|
||||
|
||||
const input = screen.getByDisplayValue('Tent')
|
||||
fireEvent.change(input, { target: { value: 'Tarp' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByDisplayValue('Tarp')).toBeNull()
|
||||
expect(screen.getByText('Tent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Rename'))
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Tent'), { target: { value: 'Tarp' } })
|
||||
fireEvent.keyDown(screen.getByDisplayValue('Tarp'), { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — deleting', () => {
|
||||
it('FE-W5ROW-010: the panel handler takes over when provided', async () => {
|
||||
const onDelete = vi.fn(async () => {})
|
||||
const { props } = setup({ onDelete })
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'))
|
||||
|
||||
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(props.item))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-011: a standalone row deletes through the store', async () => {
|
||||
let deleted = false
|
||||
server.use(http.delete('/api/trips/1/packing/1', () => { deleted = true; return HttpResponse.json({ success: true }) }))
|
||||
setup()
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'))
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-012: a failing standalone delete surfaces a delete error', async () => {
|
||||
server.use(http.delete('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
setup()
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-013: the rename and delete buttons highlight on hover', () => {
|
||||
setup()
|
||||
const rename = screen.getByTitle('Rename')
|
||||
const remove = screen.getByTitle('Delete')
|
||||
|
||||
fireEvent.mouseEnter(rename)
|
||||
expect(rename.style.color).toBe('var(--text-secondary)')
|
||||
fireEvent.mouseLeave(rename)
|
||||
expect(rename.style.color).toBe('var(--text-faint)')
|
||||
|
||||
fireEvent.mouseEnter(remove)
|
||||
expect(remove.style.color).toBe('rgb(239, 68, 68)')
|
||||
fireEvent.mouseLeave(remove)
|
||||
expect(remove.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — category picker', () => {
|
||||
it('FE-W5ROW-014: picking another list moves the item', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1, category: 'Docs' }) })
|
||||
}),
|
||||
)
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Docs' }))
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ category: 'Docs' }))
|
||||
expect(screen.queryByRole('button', { name: 'Docs' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-015: picking the current list closes the picker without a request', async () => {
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Gear' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Docs' })).toBeNull())
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-016: a failing move surfaces a generic error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
setup()
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Docs' }))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-017: an uncategorized item marks the default list as current', () => {
|
||||
setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Soap', category: null }),
|
||||
categories: ['Other', 'Gear'],
|
||||
})
|
||||
fireEvent.click(screen.getByTitle('Move to List'))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Other' })).toHaveStyle({ background: 'var(--bg-tertiary)' })
|
||||
expect(screen.getByRole('button', { name: 'Gear' })).not.toHaveStyle({ background: 'var(--bg-tertiary)' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — sharing badges', () => {
|
||||
it('FE-W5ROW-018: an item somebody else brings shows their name', () => {
|
||||
setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Stove', is_private: 1, owner_id: 2, owner_username: 'Bob' } as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByText('by Bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-019: a nameless bringer degrades to an empty name', () => {
|
||||
setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Stove', is_private: 1, owner_id: 2, owner_username: null } as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-020: an item shared by me counts its recipients', () => {
|
||||
setup({
|
||||
item: buildPackingItem({
|
||||
id: 1, name: 'Stove', is_private: 1, owner_id: 1,
|
||||
recipients: [{ user_id: 2, username: 'alice' }, { user_id: 3, username: 'bob' }],
|
||||
} as unknown as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByText('shared with 2')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('alice, bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', () => {
|
||||
setup({
|
||||
item: buildPackingItem({
|
||||
id: 1, name: 'Stove', is_private: 0, owner_username: 'Bob',
|
||||
contributors: [{ user_id: 3, username: 'cleo' }],
|
||||
} as unknown as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('Bob +1')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
|
||||
setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Stove', is_private: 0, owner_username: 'Bob' } as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^Bob$/)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-023: the placeholder row carries no badges and no share control', () => {
|
||||
setup({
|
||||
item: buildPackingItem({ id: 1, name: '...', is_private: 0, owner_username: 'Bob' } as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
onSetSharing: () => {},
|
||||
onClone: () => {},
|
||||
onJoin: () => {},
|
||||
onLeave: () => {},
|
||||
})
|
||||
|
||||
expect(screen.queryByTitle('brought by Bob')).toBeNull()
|
||||
expect(screen.queryByTitle('Sharing')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-024: the share control is wired up for an editable item', () => {
|
||||
const onSetSharing = vi.fn()
|
||||
setup({
|
||||
currentUserId: 1,
|
||||
tripMembers: MEMBERS,
|
||||
onSetSharing,
|
||||
onClone: () => {},
|
||||
onJoin: () => {},
|
||||
onLeave: () => {},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTitle('Sharing'))
|
||||
fireEvent.click(screen.getByText(/^Personal$/))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(1, 'personal', [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — drag handle', () => {
|
||||
const drag = () => ({
|
||||
isDragging: false,
|
||||
isOver: false,
|
||||
onStart: vi.fn((_id: number) => {}),
|
||||
onOver: vi.fn((_id: number) => {}),
|
||||
onEnd: vi.fn(() => {}),
|
||||
onDrop: vi.fn((_id: number) => {}),
|
||||
})
|
||||
|
||||
it('FE-W5ROW-025: starting and ending a drag reports the item id', () => {
|
||||
const d = drag()
|
||||
const { container } = setup({ drag: d })
|
||||
const handle = container.querySelector<HTMLElement>('div[draggable="true"]')!
|
||||
|
||||
fireEvent.dragStart(handle, { dataTransfer: dt() })
|
||||
expect(d.onStart).toHaveBeenCalledWith(1)
|
||||
|
||||
fireEvent.dragEnd(handle)
|
||||
expect(d.onEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-026: dragging over the row marks it as the drop target', () => {
|
||||
const d = drag()
|
||||
const { container } = setup({ drag: d })
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
|
||||
fireEvent.dragOver(row, { dataTransfer: dt() })
|
||||
|
||||
expect(d.onOver).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-027: leaving the row clears the drop target, but moving inside it does not', () => {
|
||||
const d = drag()
|
||||
const { container } = setup({ drag: d })
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
const leave = (relatedTarget: Node) =>
|
||||
fireEvent(row, new MouseEvent('dragleave', { bubbles: true, relatedTarget }))
|
||||
|
||||
leave(document.body)
|
||||
expect(d.onOver).toHaveBeenCalledWith(-1)
|
||||
|
||||
d.onOver.mockClear()
|
||||
leave(screen.getByText('Tent'))
|
||||
expect(d.onOver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-028: dropping on the row reports the target id', () => {
|
||||
const d = drag()
|
||||
const { container } = setup({ drag: d })
|
||||
|
||||
fireEvent.drop(container.querySelector<HTMLElement>('.packing-item-row')!, { dataTransfer: dt() })
|
||||
|
||||
expect(d.onDrop).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-029: the dragged row fades and the hovered row shows an edge', () => {
|
||||
const { container } = setup({ drag: { ...drag(), isDragging: true, isOver: true } })
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
|
||||
expect(row.style.opacity).toBe('0.4')
|
||||
expect(row.style.boxShadow).toBe('inset 3px 0 0 0 var(--accent)')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-030: hovering the row brings the grip to full opacity', () => {
|
||||
const { container } = setup({ drag: drag() })
|
||||
const row = container.querySelector<HTMLElement>('.packing-item-row')!
|
||||
const handle = container.querySelector<HTMLElement>('div[draggable="true"]')!
|
||||
|
||||
expect(handle.style.opacity).toBe('0.35')
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(handle.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-031: a placeholder row is not draggable', () => {
|
||||
const { container } = setup({ item: buildPackingItem({ id: 1, name: '...' }), drag: drag() })
|
||||
|
||||
expect(container.querySelector('div[draggable="true"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — weight and bag', () => {
|
||||
it('FE-W5ROW-032: entering a weight saves it in grams', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
|
||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-033: clearing the weight stores null', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', weight_grams: 300 } as Partial<PackingItem>),
|
||||
bagTrackingEnabled: true,
|
||||
})
|
||||
|
||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '' } })
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-034: a failing weight save surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
const { container } = setup({ bagTrackingEnabled: true })
|
||||
|
||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-035: a read-only row ignores weight edits and never opens the bag picker', async () => {
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, canEdit: false })
|
||||
|
||||
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
|
||||
expect(weight.readOnly).toBe(true)
|
||||
fireEvent.change(weight, { target: { value: '450' } })
|
||||
fireEvent.click(bagButton(container))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-036: picking a bag assigns it', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(bagButton(container))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trolley' }))
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ bag_id: 8 }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-037: a failing bag assign surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(bagButton(container))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trolley' }))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-038: only an assigned item offers the Unassigned entry, which clears the bag', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
|
||||
}),
|
||||
)
|
||||
const unassigned = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(bagButton(unassigned.container))
|
||||
expect(screen.queryByRole('button', { name: 'Unassigned' })).toBeNull()
|
||||
unassigned.unmount()
|
||||
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 7 } as Partial<PackingItem>),
|
||||
bagTrackingEnabled: true,
|
||||
bags: BAGS,
|
||||
})
|
||||
fireEvent.click(bagButton(container))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unassigned' }))
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ bag_id: null }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-039: a failing clear surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 7 } as Partial<PackingItem>),
|
||||
bagTrackingEnabled: true,
|
||||
bags: BAGS,
|
||||
})
|
||||
fireEvent.click(bagButton(container))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unassigned' }))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-040: only the unselected bag entries react to hover', () => {
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 7 } as Partial<PackingItem>),
|
||||
bagTrackingEnabled: true,
|
||||
bags: BAGS,
|
||||
})
|
||||
fireEvent.click(bagButton(container))
|
||||
const selected = screen.getByRole('button', { name: 'Carry-on' })
|
||||
const other = screen.getByRole('button', { name: 'Trolley' })
|
||||
|
||||
fireEvent.mouseEnter(other)
|
||||
expect(other.style.background).toBe('var(--bg-tertiary)')
|
||||
fireEvent.mouseLeave(other)
|
||||
expect(other.style.background).toBe('none')
|
||||
|
||||
fireEvent.mouseEnter(selected)
|
||||
fireEvent.mouseLeave(selected)
|
||||
expect(selected.style.background).toBe('var(--bg-tertiary)')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-041: creating a bag inline assigns it right away', async () => {
|
||||
const created = { id: 12, name: 'Duffel', color: '#f97316' } as unknown as PackingBag
|
||||
const onCreateBag = vi.fn(async () => created)
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, onCreateBag })
|
||||
fireEvent.click(bagButton(container))
|
||||
fireEvent.click(screen.getByText('Add bag'))
|
||||
|
||||
const input = screen.getByPlaceholderText('Bag name...')
|
||||
fireEvent.change(input, { target: { value: ' Duffel ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(onCreateBag).toHaveBeenCalledWith('Duffel'))
|
||||
await waitFor(() => expect(body).toMatchObject({ bag_id: 12 }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-042: Enter on a blank inline name creates nothing', () => {
|
||||
const onCreateBag = vi.fn(async () => undefined)
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, onCreateBag })
|
||||
fireEvent.click(bagButton(container))
|
||||
fireEvent.click(screen.getByText('Add bag'))
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Bag name...'), { key: 'Enter' })
|
||||
|
||||
expect(onCreateBag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-043: Escape cancels the inline bag form', () => {
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(bagButton(container))
|
||||
fireEvent.click(screen.getByText('Add bag'))
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Bag name...'), { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByPlaceholderText('Bag name...')).toBeNull()
|
||||
expect(screen.getByText('Add bag')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-044: the inline confirm button creates the bag, and is inert while blank', async () => {
|
||||
const created = { id: 13, name: 'Crate', color: '#f97316' } as unknown as PackingBag
|
||||
const onCreateBag = vi.fn(async () => created)
|
||||
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1 }) })))
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, onCreateBag })
|
||||
fireEvent.click(bagButton(container))
|
||||
fireEvent.click(screen.getByText('Add bag'))
|
||||
const confirm = screen.getByPlaceholderText('Bag name...').nextElementSibling as HTMLButtonElement
|
||||
|
||||
fireEvent.click(confirm)
|
||||
expect(onCreateBag).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Bag name...'), { target: { value: 'Crate' } })
|
||||
fireEvent.click(confirm)
|
||||
|
||||
await waitFor(() => expect(onCreateBag).toHaveBeenCalledWith('Crate'))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-045: a rejected bag creation leaves the item unassigned', async () => {
|
||||
const onCreateBag = vi.fn(async () => undefined)
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: [], onCreateBag })
|
||||
fireEvent.click(bagButton(container))
|
||||
fireEvent.click(screen.getByText('Add bag'))
|
||||
|
||||
const input = screen.getByPlaceholderText('Bag name...')
|
||||
fireEvent.change(input, { target: { value: 'Duffel' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(onCreateBag).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Bag name...')).toBeNull())
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-046: the Add bag entry highlights on hover', () => {
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(bagButton(container))
|
||||
const add = screen.getByText('Add bag').closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(add)
|
||||
expect(add.style.color).toBe('var(--text-secondary)')
|
||||
fireEvent.mouseLeave(add)
|
||||
expect(add.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — overflow menu', () => {
|
||||
it('FE-W5ROW-047: the menu renames and closes', () => {
|
||||
const { container } = setup()
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.click(screen.getByText('Rename'))
|
||||
|
||||
expect(screen.getByDisplayValue('Tent')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Delete')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-048: the menu deletes through the panel handler', async () => {
|
||||
const onDelete = vi.fn(async () => {})
|
||||
const { container, props } = setup({ onDelete })
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'))
|
||||
|
||||
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(props.item))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-049: the scrim closes the menu', () => {
|
||||
const { container, baseElement } = setup()
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
expect(screen.getByText('Qty')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(baseElement.querySelector('div[style*="z-index: 1098"]')!)
|
||||
|
||||
expect(screen.queryByText('Qty')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-050: the menu commits a new quantity', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1, quantity: 4 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({ item: buildPackingItem({ id: 1, name: 'Tent', quantity: null } as Partial<PackingItem>) })
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
const qty = fieldOf(container, 'Qty')
|
||||
fireEvent.change(qty, { target: { value: '4' } })
|
||||
fireEvent.blur(qty)
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ quantity: 4 }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-051: the menu saves a weight and reassigns the bag', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>)
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 7 } as Partial<PackingItem>),
|
||||
bagTrackingEnabled: true,
|
||||
bags: BAGS,
|
||||
})
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
const weight = fieldOf(container, 'Total weight')
|
||||
fireEvent.change(weight, { target: { value: '900' } })
|
||||
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
|
||||
|
||||
// The first entry mirrors the current bag and clears it when tapped.
|
||||
fireEvent.click(menu(container).getAllByRole('button', { name: 'Carry-on', hidden: true })[0])
|
||||
await waitFor(() => expect(bodies[1]).toMatchObject({ bag_id: null }))
|
||||
|
||||
fireEvent.click(menuButton(container, 'Trolley'))
|
||||
await waitFor(() => expect(bodies[2]).toMatchObject({ bag_id: 8 }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-052: a failing menu weight or bag change surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
|
||||
toastSpy.mockClear()
|
||||
fireEvent.click(menuButton(container, 'Trolley'))
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-053: an unassigned item shows the Unassigned row without firing a request', async () => {
|
||||
let called = false
|
||||
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.click(menuButton(container, 'Unassigned'))
|
||||
|
||||
await waitFor(() => expect(menu(container).getByText('Total weight')).toBeInTheDocument())
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-054: the menu list submenu moves the item and can be collapsed again', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/1', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 1, category: 'Docs' }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup()
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.click(menuButton(container, 'Move to List'))
|
||||
expect(menuButton(container, 'Docs')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(menuButton(container, 'Move to List'))
|
||||
expect(menu(container).queryByRole('button', { name: 'Docs', hidden: true })).toBeNull()
|
||||
|
||||
fireEvent.click(menuButton(container, 'Move to List'))
|
||||
fireEvent.click(menuButton(container, 'Docs'))
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ category: 'Docs' }))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-055: the menu carries its own share control', () => {
|
||||
const onSetSharing = vi.fn()
|
||||
const { container } = setup({
|
||||
currentUserId: 1,
|
||||
tripMembers: MEMBERS,
|
||||
onSetSharing,
|
||||
onClone: () => {},
|
||||
onJoin: () => {},
|
||||
onLeave: () => {},
|
||||
})
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
expect(screen.getByText('Sharing')).toBeInTheDocument()
|
||||
const triggers = screen.getAllByTitle('Sharing')
|
||||
fireEvent.click(triggers[triggers.length - 1])
|
||||
fireEvent.click(screen.getByText(/^Personal$/))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(1, 'personal', [])
|
||||
})
|
||||
|
||||
it('FE-W5ROW-056: menu entries highlight on hover, the active one stays put', () => {
|
||||
const { container } = setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear', bag_id: 8 } as Partial<PackingItem>),
|
||||
bagTrackingEnabled: true,
|
||||
bags: BAGS,
|
||||
})
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
const active = screen.getByRole('button', { name: 'Trolley' })
|
||||
const plain = screen.getByRole('button', { name: 'Carry-on' })
|
||||
const danger = screen.getByText('Delete').closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(plain)
|
||||
expect(plain.style.background).toBe('var(--bg-tertiary)')
|
||||
fireEvent.mouseLeave(plain)
|
||||
expect(plain.style.background).toBe('none')
|
||||
|
||||
fireEvent.mouseEnter(active)
|
||||
fireEvent.mouseLeave(active)
|
||||
expect(active.style.background).toBe('var(--bg-tertiary)')
|
||||
|
||||
fireEvent.mouseEnter(danger)
|
||||
expect(danger.style.background).toBe('rgb(254, 242, 242)')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user