mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
test(components): cover map, collections, settings, journey and admin
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// FE-W4ATB-001 to FE-W4ATB-010
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
const listTemplates = vi.fn(async (_tripId: number | string) => ({ templates: [] as unknown[] }))
|
||||
const applyTemplate = vi.fn(async (_tripId: number | string, _id: number, _vis: string) => ({ items: [] as unknown[], count: 0 }))
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
packingApi: {
|
||||
listTemplates: (tripId: number | string) => listTemplates(tripId),
|
||||
applyTemplate: (tripId: number | string, id: number, vis: string) => applyTemplate(tripId, id, vis),
|
||||
},
|
||||
}))
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
|
||||
|
||||
import ApplyTemplateButton from './ApplyTemplateButton'
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 1, name: 'Beach trip', item_count: 12 },
|
||||
{ id: 2, name: 'City break', item_count: 7 },
|
||||
]
|
||||
|
||||
function renderButton(visibility: 'common' | 'personal' = 'common') {
|
||||
return render(<ApplyTemplateButton tripId={4} visibility={visibility} style={{ gap: 6 }} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listTemplates.mockReset()
|
||||
listTemplates.mockResolvedValue({ templates: TEMPLATES })
|
||||
applyTemplate.mockReset()
|
||||
applyTemplate.mockResolvedValue({ items: [], count: 0 })
|
||||
Object.values(toast).forEach(f => f.mockClear())
|
||||
useTripStore.setState({ packingItems: [] })
|
||||
})
|
||||
|
||||
describe('ApplyTemplateButton', () => {
|
||||
it('FE-W4ATB-001: renders nothing while no template exists', async () => {
|
||||
listTemplates.mockResolvedValue({ templates: [] })
|
||||
const { container } = renderButton()
|
||||
|
||||
await waitFor(() => expect(listTemplates).toHaveBeenCalledWith(4))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-002: stays hidden when the template list fails to load', async () => {
|
||||
listTemplates.mockRejectedValue(new Error('offline'))
|
||||
const { container } = renderButton()
|
||||
|
||||
await waitFor(() => expect(listTemplates).toHaveBeenCalled())
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-003: tolerates a response without a templates array', async () => {
|
||||
listTemplates.mockResolvedValue({} as { templates: unknown[] })
|
||||
const { container } = renderButton()
|
||||
|
||||
await waitFor(() => expect(listTemplates).toHaveBeenCalled())
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-004: shows the trigger once templates arrive', async () => {
|
||||
renderButton()
|
||||
|
||||
expect(await screen.findByRole('button')).toHaveTextContent(/apply template/i)
|
||||
})
|
||||
|
||||
it('FE-W4ATB-005: the trigger toggles the template menu', async () => {
|
||||
renderButton()
|
||||
const trigger = await screen.findByRole('button')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('Beach trip')).toBeInTheDocument()
|
||||
expect(screen.getByText(/^12 /)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText('Beach trip')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-006: a mousedown outside closes the menu', async () => {
|
||||
renderButton()
|
||||
fireEvent.click(await screen.findByRole('button'))
|
||||
expect(screen.getByText('City break')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.queryByText('City break')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-007: a mousedown inside keeps the menu open', async () => {
|
||||
renderButton()
|
||||
fireEvent.click(await screen.findByRole('button'))
|
||||
|
||||
fireEvent.mouseDown(screen.getByText('Beach trip'))
|
||||
|
||||
expect(screen.getByText('Beach trip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-008: applying a template appends its items to the trip store', async () => {
|
||||
applyTemplate.mockResolvedValue({ items: [{ id: 90 }, { id: 91 }], count: 2 })
|
||||
useTripStore.setState({ packingItems: [{ id: 1 }] as never })
|
||||
renderButton('personal')
|
||||
|
||||
fireEvent.click(await screen.findByRole('button'))
|
||||
fireEvent.click(screen.getByText('Beach trip'))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2')))
|
||||
expect(applyTemplate).toHaveBeenCalledWith(4, 1, 'personal')
|
||||
expect(useTripStore.getState().packingItems.map(i => i.id)).toEqual([1, 90, 91])
|
||||
expect(screen.queryByText('Beach trip')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-009: tolerates a response without an items array', async () => {
|
||||
applyTemplate.mockResolvedValue({ count: 0 } as { items: unknown[]; count: number })
|
||||
renderButton()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button'))
|
||||
fireEvent.click(screen.getByText('City break'))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled())
|
||||
expect(useTripStore.getState().packingItems).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W4ATB-010: a failing apply toasts an error and keeps the menu open', async () => {
|
||||
applyTemplate.mockRejectedValue(new Error('500'))
|
||||
renderButton()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button'))
|
||||
fireEvent.click(screen.getByText('Beach trip'))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Beach trip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4ATB-011: honours a caller className on the trigger', async () => {
|
||||
render(<ApplyTemplateButton tripId={4} visibility="common" style={{}} className="btn-ghost" />)
|
||||
|
||||
expect(await screen.findByRole('button')).toHaveClass('btn-ghost')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,602 @@
|
||||
// FE-W5CAT-001 to FE-W5CAT-034
|
||||
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 { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
|
||||
type Props = ComponentProps<typeof KategorieGruppe>
|
||||
|
||||
const toastSpy = vi.fn((_message: string, _type?: string, _duration?: number) => 0)
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'owner' },
|
||||
{ id: 2, username: 'alice', is_guest: true },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
/** A dataTransfer stand-in — jsdom drag events carry none of their own. */
|
||||
const dt = () => ({ effectAllowed: '', dropEffect: '', setData: vi.fn(), getData: vi.fn(() => '') })
|
||||
|
||||
function setup(overrides: Partial<Props> = {}) {
|
||||
const items = overrides.items ?? [buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' })]
|
||||
const props: Props = {
|
||||
kategorie: 'Gear',
|
||||
items,
|
||||
tripId: 1,
|
||||
allCategories: ['Gear', 'Docs'],
|
||||
onRename: vi.fn(async () => {}),
|
||||
onDeleteAll: vi.fn(async () => {}),
|
||||
onDeleteItem: vi.fn(async () => {}),
|
||||
onAddItem: vi.fn(async () => {}),
|
||||
assignees: [],
|
||||
tripMembers: [],
|
||||
onSetAssignees: vi.fn(async () => {}),
|
||||
onCreateBag: vi.fn(async () => undefined),
|
||||
allItems: items,
|
||||
onReorder: vi.fn((_ids: number[]) => {}),
|
||||
...overrides,
|
||||
}
|
||||
const utils = render(<KategorieGruppe {...props} />)
|
||||
return { ...utils, props }
|
||||
}
|
||||
|
||||
/** The category header owns the first MoreHorizontal; item rows render their own. */
|
||||
function openCategoryMenu(container: HTMLElement) {
|
||||
const btn = container.querySelectorAll('svg.lucide-more-horizontal')[0].closest('button')!
|
||||
fireEvent.click(btn)
|
||||
return btn
|
||||
}
|
||||
|
||||
function rows(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('.packing-item-row'))
|
||||
}
|
||||
|
||||
function grip(row: HTMLElement) {
|
||||
return row.querySelector<HTMLElement>('div[draggable="true"]')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
toastSpy.mockClear()
|
||||
window.__addToast = toastSpy
|
||||
server.use(
|
||||
http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] })),
|
||||
)
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — header', () => {
|
||||
it('FE-W5CAT-001: shows the category name and its packed count', () => {
|
||||
setup({
|
||||
items: [
|
||||
buildPackingItem({ id: 1, name: 'Tent', checked: 1 }),
|
||||
buildPackingItem({ id: 2, name: 'Rope', checked: 0 }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(screen.getByText('Gear')).toBeInTheDocument()
|
||||
expect(screen.getByText('1/2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-002: a fully packed category marks its badge green', () => {
|
||||
setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 1 })] })
|
||||
|
||||
expect(screen.getByText('1/1')).toHaveStyle({ color: '#16a34a' })
|
||||
})
|
||||
|
||||
it('FE-W5CAT-003: the chevron collapses and re-expands the item list', () => {
|
||||
const { container } = setup()
|
||||
const toggle = container.querySelector('svg.lucide-chevron-down')!.closest('button')!
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.queryByText('Tent')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('svg.lucide-chevron-right')!.closest('button')!)
|
||||
expect(screen.getByText('Tent')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — rename', () => {
|
||||
it('FE-W5CAT-004: renaming through the menu reports the new name', async () => {
|
||||
const onRename = vi.fn(async () => {})
|
||||
const { container } = setup({ onRename })
|
||||
openCategoryMenu(container)
|
||||
fireEvent.click(screen.getByText('Rename'))
|
||||
|
||||
const input = screen.getByDisplayValue('Gear')
|
||||
fireEvent.change(input, { target: { value: 'Camping' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(onRename).toHaveBeenCalledWith('Gear', 'Camping'))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-005: an unchanged name closes the editor without a request', async () => {
|
||||
const onRename = vi.fn(async () => {})
|
||||
const { container } = setup({ onRename })
|
||||
openCategoryMenu(container)
|
||||
fireEvent.click(screen.getByText('Rename'))
|
||||
|
||||
fireEvent.blur(screen.getByDisplayValue('Gear'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Gear')).toBeNull())
|
||||
expect(onRename).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-006: an emptied name is discarded', async () => {
|
||||
const onRename = vi.fn(async () => {})
|
||||
const { container } = setup({ onRename })
|
||||
openCategoryMenu(container)
|
||||
fireEvent.click(screen.getByText('Rename'))
|
||||
|
||||
const input = screen.getByDisplayValue('Gear')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
// The editor closes and the original name is restored as plain text.
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Gear')).toBeNull())
|
||||
expect(screen.getByText('Gear')).toBeInTheDocument()
|
||||
expect(onRename).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-007: Escape restores the original name', () => {
|
||||
const { container } = setup()
|
||||
openCategoryMenu(container)
|
||||
fireEvent.click(screen.getByText('Rename'))
|
||||
|
||||
const input = screen.getByDisplayValue('Gear')
|
||||
fireEvent.change(input, { target: { value: 'Camping' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByDisplayValue('Camping')).toBeNull()
|
||||
expect(screen.getByText('Gear')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-008: a failing rename surfaces a rename error', async () => {
|
||||
const onRename = vi.fn(async () => { throw new Error('nope') })
|
||||
const { container } = setup({ onRename })
|
||||
openCategoryMenu(container)
|
||||
fireEvent.click(screen.getByText('Rename'))
|
||||
|
||||
const input = screen.getByDisplayValue('Gear')
|
||||
fireEvent.change(input, { target: { value: 'Camping' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to rename', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-009: a read-only category offers no rename entry', () => {
|
||||
const { container } = setup({ canEdit: false })
|
||||
openCategoryMenu(container)
|
||||
|
||||
expect(screen.queryByText('Rename')).toBeNull()
|
||||
expect(screen.queryByText('Delete List')).toBeNull()
|
||||
expect(screen.getByText('Check All')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — bulk actions', () => {
|
||||
it('FE-W5CAT-010: Check All only touches the unchecked items', async () => {
|
||||
const puts: number[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/:itemId', ({ params }) => {
|
||||
puts.push(Number(params.itemId))
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: Number(params.itemId) }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({
|
||||
items: [
|
||||
buildPackingItem({ id: 1, name: 'Tent', checked: 1 }),
|
||||
buildPackingItem({ id: 2, name: 'Rope', checked: 0 }),
|
||||
],
|
||||
})
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(screen.getByText('Check All'))
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([2]))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-011: Uncheck All only touches the checked items', async () => {
|
||||
const puts: number[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/:itemId', ({ params }) => {
|
||||
puts.push(Number(params.itemId))
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: Number(params.itemId) }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({
|
||||
items: [
|
||||
buildPackingItem({ id: 1, name: 'Tent', checked: 1 }),
|
||||
buildPackingItem({ id: 2, name: 'Rope', checked: 0 }),
|
||||
],
|
||||
})
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(screen.getByText('Uncheck All'))
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([1]))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-012: a throwing toggle during Check All surfaces a save error', async () => {
|
||||
useTripStore.setState({ togglePackingItem: async () => { throw new Error('offline') } })
|
||||
const { container } = setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] })
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(screen.getByText('Check All'))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-013: a throwing toggle during Uncheck All surfaces a save error', async () => {
|
||||
useTripStore.setState({ togglePackingItem: async () => { throw new Error('offline') } })
|
||||
const { container } = setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 1 })] })
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(screen.getByText('Uncheck All'))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-014: Delete List hands the whole category to the panel and closes the menu', async () => {
|
||||
const onDeleteAll = vi.fn(async () => {})
|
||||
const items = [buildPackingItem({ id: 1, name: 'Tent' })]
|
||||
const { container } = setup({ items, onDeleteAll })
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(screen.getByText('Delete List'))
|
||||
|
||||
await waitFor(() => expect(onDeleteAll).toHaveBeenCalledWith(items))
|
||||
expect(screen.queryByText('Delete List')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-015: the scrim closes the category menu', () => {
|
||||
const { container, baseElement } = setup()
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(baseElement.querySelector('div[style*="z-index: 99"]')!)
|
||||
|
||||
expect(screen.queryByText('Check All')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-015a: the menu trigger highlights on hover', () => {
|
||||
const { container } = setup()
|
||||
const trigger = container.querySelectorAll('svg.lucide-more-horizontal')[0].closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(trigger)
|
||||
expect(trigger.style.color).toBe('var(--text-secondary)')
|
||||
fireEvent.mouseLeave(trigger)
|
||||
expect(trigger.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-016: a menu entry highlights on hover and resets on leave', () => {
|
||||
const { container } = setup()
|
||||
openCategoryMenu(container)
|
||||
const checkAll = screen.getByText('Check All').closest('button')!
|
||||
const deleteAll = screen.getByText('Delete List').closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(checkAll)
|
||||
expect(checkAll.style.background).toBe('var(--bg-tertiary)')
|
||||
fireEvent.mouseLeave(checkAll)
|
||||
expect(checkAll.style.background).toBe('none')
|
||||
|
||||
fireEvent.mouseEnter(deleteAll)
|
||||
expect(deleteAll.style.background).toBe('rgb(254, 242, 242)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — assignees', () => {
|
||||
it('FE-W5CAT-017: the dropdown lists every member and flags guests', () => {
|
||||
const { container } = setup({ tripMembers: MEMBERS })
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
expect(screen.getByText('owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Guest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-018: an empty trip shows the no-members hint', () => {
|
||||
const { container } = setup({ tripMembers: [] })
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
expect(screen.getByText('No trip members')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-019: picking an unassigned member adds them', () => {
|
||||
const onSetAssignees = vi.fn(async () => {})
|
||||
const { container } = setup({ tripMembers: MEMBERS, onSetAssignees })
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
fireEvent.click(screen.getByText('alice').closest('button')!)
|
||||
|
||||
expect(onSetAssignees).toHaveBeenCalledWith('Gear', [2])
|
||||
})
|
||||
|
||||
it('FE-W5CAT-019a: adding a member keeps the ones already assigned', () => {
|
||||
const onSetAssignees = vi.fn(async () => {})
|
||||
const { container } = setup({
|
||||
tripMembers: MEMBERS,
|
||||
assignees: [{ user_id: 1, username: 'owner' }],
|
||||
onSetAssignees,
|
||||
})
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /alice/ }))
|
||||
|
||||
expect(onSetAssignees).toHaveBeenCalledWith('Gear', [1, 2])
|
||||
})
|
||||
|
||||
it('FE-W5CAT-020: picking an assigned member removes them again', () => {
|
||||
const onSetAssignees = vi.fn(async () => {})
|
||||
const { container } = setup({
|
||||
tripMembers: MEMBERS,
|
||||
assignees: [{ user_id: 2, username: 'alice' }, { user_id: 1, username: 'owner' }],
|
||||
onSetAssignees,
|
||||
})
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
fireEvent.click(within(screen.getByRole('button', { name: /alice/ })).getByText('alice').closest('button')!)
|
||||
|
||||
expect(onSetAssignees).toHaveBeenCalledWith('Gear', [1])
|
||||
})
|
||||
|
||||
it('FE-W5CAT-021: only the unassigned rows react to hover', () => {
|
||||
const { container } = setup({
|
||||
tripMembers: MEMBERS,
|
||||
assignees: [{ user_id: 1, username: 'owner' }],
|
||||
})
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
const assigned = screen.getByRole('button', { name: /owner/ })
|
||||
const free = screen.getByRole('button', { name: /alice/ })
|
||||
|
||||
fireEvent.mouseEnter(free)
|
||||
expect(free.style.background).toBe('var(--bg-tertiary)')
|
||||
fireEvent.mouseLeave(free)
|
||||
expect(free.style.background).toBe('transparent')
|
||||
|
||||
fireEvent.mouseEnter(assigned)
|
||||
fireEvent.mouseLeave(assigned)
|
||||
expect(assigned.style.background).toBe('var(--bg-hover)')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-022: the add-assignee button highlights on hover', () => {
|
||||
const { container } = setup({ tripMembers: MEMBERS })
|
||||
const btn = container.querySelector('svg.lucide-user-plus')!.closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(btn)
|
||||
expect(btn.style.color).toBe('var(--text-muted)')
|
||||
fireEvent.mouseLeave(btn)
|
||||
expect(btn.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-023: a mousedown outside closes the assignee dropdown', () => {
|
||||
const { container } = setup({ tripMembers: MEMBERS })
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(screen.queryByText('alice')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-023a: a mousedown inside the assignee dropdown keeps it open', () => {
|
||||
const { container } = setup({ tripMembers: MEMBERS })
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
fireEvent.mouseDown(screen.getByText('alice'))
|
||||
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-024: clicking an assignee chip drops that member', () => {
|
||||
const onSetAssignees = vi.fn(async () => {})
|
||||
const { container } = setup({
|
||||
assignees: [{ user_id: 2, username: 'alice' }, { user_id: 1, username: 'owner' }],
|
||||
onSetAssignees,
|
||||
})
|
||||
|
||||
fireEvent.click(container.querySelector('.assignee-chip')!.parentElement!)
|
||||
|
||||
expect(onSetAssignees).toHaveBeenCalledWith('Gear', [1])
|
||||
})
|
||||
|
||||
it('FE-W5CAT-025: a read-only category ignores chip clicks and hides the add button', () => {
|
||||
const onSetAssignees = vi.fn(async () => {})
|
||||
const { container } = setup({
|
||||
canEdit: false,
|
||||
assignees: [{ user_id: 2, username: 'alice' }],
|
||||
onSetAssignees,
|
||||
})
|
||||
|
||||
fireEvent.click(container.querySelector('.assignee-chip')!.parentElement!)
|
||||
|
||||
expect(onSetAssignees).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('svg.lucide-user-plus')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — inline add item', () => {
|
||||
it('FE-W5CAT-026: Enter adds the typed item and clears the field', () => {
|
||||
const onAddItem = vi.fn(async () => {})
|
||||
setup({ onAddItem })
|
||||
fireEvent.click(screen.getByText('Add item'))
|
||||
|
||||
const input = screen.getByPlaceholderText('Item name...')
|
||||
fireEvent.change(input, { target: { value: 'Rope' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onAddItem).toHaveBeenCalledWith('Gear', 'Rope')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-027: Enter on an empty field adds nothing', () => {
|
||||
const onAddItem = vi.fn(async () => {})
|
||||
setup({ onAddItem })
|
||||
fireEvent.click(screen.getByText('Add item'))
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Item name...'), { key: 'Enter' })
|
||||
|
||||
expect(onAddItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
|
||||
const onAddItem = vi.fn(async () => {})
|
||||
const { container } = setup({ onAddItem })
|
||||
fireEvent.click(screen.getByText('Add item'))
|
||||
const confirm = container.querySelector('svg.lucide-plus')!.closest('button')!
|
||||
|
||||
fireEvent.click(confirm)
|
||||
expect(onAddItem).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Item name...'), { target: { value: 'Rope' } })
|
||||
fireEvent.click(confirm)
|
||||
expect(onAddItem).toHaveBeenCalledWith('Gear', 'Rope')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-029: Escape closes the inline form', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByText('Add item'))
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Item name...'), { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByPlaceholderText('Item name...')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-030: the X button closes the inline form', () => {
|
||||
const { container } = setup()
|
||||
fireEvent.click(screen.getByText('Add item'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Item name...'), { target: { value: 'Rope' } })
|
||||
|
||||
fireEvent.click(container.querySelector('svg.lucide-x')!.closest('button')!)
|
||||
|
||||
expect(screen.queryByPlaceholderText('Item name...')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-031: the add-item trigger highlights on hover', () => {
|
||||
setup()
|
||||
const trigger = screen.getByText('Add item').closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(trigger)
|
||||
expect(trigger.style.color).toBe('var(--text-secondary)')
|
||||
fireEvent.mouseLeave(trigger)
|
||||
expect(trigger.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-032: a read-only category hides the add-item affordance', () => {
|
||||
setup({ canEdit: false })
|
||||
|
||||
expect(screen.queryByText('Add item')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — drag to reorder', () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, name: 'Tent' }),
|
||||
buildPackingItem({ id: 2, name: 'Rope' }),
|
||||
buildPackingItem({ id: 3, name: 'Stove' }),
|
||||
]
|
||||
// The order is global: another category's item sits between ours.
|
||||
const allItems = [items[0], buildPackingItem({ id: 9, name: 'Passport', category: 'Docs' }), items[1], items[2]]
|
||||
|
||||
it('FE-W5CAT-033: dropping an item onto a later sibling reorders only this category', () => {
|
||||
const onReorder = vi.fn((_ids: number[]) => {})
|
||||
const { container } = setup({ items, allItems, onReorder })
|
||||
const row = rows(container)
|
||||
|
||||
fireEvent.dragStart(grip(row[0]), { dataTransfer: dt() })
|
||||
fireEvent.dragOver(row[2], { dataTransfer: dt() })
|
||||
fireEvent.drop(row[2], { dataTransfer: dt() })
|
||||
|
||||
expect(onReorder).toHaveBeenCalledWith([2, 9, 3, 1])
|
||||
})
|
||||
|
||||
it('FE-W5CAT-034: a drop without a drag source is ignored', () => {
|
||||
const onReorder = vi.fn((_ids: number[]) => {})
|
||||
const { container } = setup({ items, allItems, onReorder })
|
||||
|
||||
fireEvent.drop(rows(container)[1], { dataTransfer: dt() })
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-035: dropping an item onto itself is ignored', () => {
|
||||
const onReorder = vi.fn((_ids: number[]) => {})
|
||||
const { container } = setup({ items, allItems, onReorder })
|
||||
const row = rows(container)
|
||||
|
||||
fireEvent.dragStart(grip(row[1]), { dataTransfer: dt() })
|
||||
fireEvent.drop(row[1], { dataTransfer: dt() })
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-036: a drop whose source has left the category is ignored', () => {
|
||||
const onReorder = vi.fn((_ids: number[]) => {})
|
||||
const { container, rerender, props } = setup({ items, allItems, onReorder })
|
||||
|
||||
fireEvent.dragStart(grip(rows(container)[0]), { dataTransfer: dt() })
|
||||
// The dragged row disappears mid-drag (another client removed it).
|
||||
const shorter = [items[1], items[2]]
|
||||
rerender(<KategorieGruppe {...props} items={shorter} allItems={shorter} />)
|
||||
fireEvent.drop(rows(container)[0], { dataTransfer: dt() })
|
||||
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-037: dragging marks the source faint and the hovered target with an edge', () => {
|
||||
const { container } = setup({ items, allItems })
|
||||
const row = rows(container)
|
||||
|
||||
fireEvent.dragStart(grip(row[0]), { dataTransfer: dt() })
|
||||
fireEvent.dragOver(row[1], { dataTransfer: dt() })
|
||||
|
||||
expect(row[0].style.opacity).toBe('0.4')
|
||||
expect(row[1].style.boxShadow).toBe('inset 3px 0 0 0 var(--accent)')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-038: ending a drag clears both markers', () => {
|
||||
const { container } = setup({ items, allItems })
|
||||
const row = rows(container)
|
||||
|
||||
fireEvent.dragStart(grip(row[0]), { dataTransfer: dt() })
|
||||
fireEvent.dragOver(row[1], { dataTransfer: dt() })
|
||||
fireEvent.dragEnd(grip(row[0]))
|
||||
|
||||
expect(row[0].style.opacity).toBe('1')
|
||||
expect(row[1].style.boxShadow).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-W5CAT-039: a read-only category renders no drag handles', () => {
|
||||
const { container } = setup({ items, allItems, canEdit: false })
|
||||
|
||||
expect(container.querySelector('div[draggable="true"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KategorieGruppe — plugin contributions', () => {
|
||||
it('FE-W5CAT-040: a plugin column is rendered below its item row', async () => {
|
||||
server.use(
|
||||
http.get('/api/view-contributions/:view/:tripId', () =>
|
||||
HttpResponse.json({
|
||||
contributions: [
|
||||
{ kind: 'column', pluginId: 'weather', entityId: 1, id: 'c1', label: 'Forecast', value: 'Sunny', tone: 'default' },
|
||||
],
|
||||
})),
|
||||
)
|
||||
setup({ items: [buildPackingItem({ id: 1, name: 'Tent' })] })
|
||||
|
||||
expect(await screen.findByText('Sunny')).toBeInTheDocument()
|
||||
expect(screen.getByText('Forecast')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
// FE-W4PSC-001 to FE-W4PSC-015, FE-W5PSC-001 to FE-W5PSC-009
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import PackingShareControl from './PackingShareControl'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'ada' },
|
||||
{ id: 2, username: 'bob' },
|
||||
{ id: 3, username: 'cleo' },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
function item(overrides: Partial<PackingItem> = {}): PackingItem {
|
||||
return { id: 5, name: 'Tent', is_private: 0, owner_id: 1, recipients: [], contributors: [], ...overrides } as unknown as PackingItem
|
||||
}
|
||||
|
||||
function setup(it_: PackingItem, currentUserId = 1) {
|
||||
const onSetSharing = vi.fn()
|
||||
const onClone = vi.fn()
|
||||
const onJoin = vi.fn()
|
||||
const onLeave = vi.fn()
|
||||
const utils = render(
|
||||
<PackingShareControl
|
||||
item={it_} tripMembers={MEMBERS} currentUserId={currentUserId}
|
||||
onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
|
||||
/>,
|
||||
)
|
||||
return { onSetSharing, onClone, onJoin, onLeave, ...utils }
|
||||
}
|
||||
|
||||
describe('PackingShareControl — owner', () => {
|
||||
it('FE-W4PSC-001: shows the share trigger for the owner of a common item', () => {
|
||||
setup(item())
|
||||
|
||||
expect(screen.getByRole('button', { name: /sharing/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-002: opening the menu lists both tiers plus every other member', () => {
|
||||
setup(item())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
expect(screen.getByText(/^Shared$/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/^Personal$/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /bob/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /cleo/ })).toBeInTheDocument()
|
||||
// Neither the owner nor the current user is offered as a recipient.
|
||||
expect(screen.queryByRole('button', { name: /ada/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-003: picking Personal reports the tier and closes the menu', () => {
|
||||
const { onSetSharing } = setup(item())
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
fireEvent.click(screen.getByText(/^Personal$/))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(5, 'personal', [])
|
||||
expect(screen.queryByText(/^Personal$/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-004: picking the common tier reports it', () => {
|
||||
const { onSetSharing } = setup(item({ is_private: 1 } as Partial<PackingItem>))
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
fireEvent.click(screen.getByText(/^Shared$/))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(5, 'common', [])
|
||||
})
|
||||
|
||||
it('FE-W4PSC-005: toggling a member on shares the item with them', () => {
|
||||
const { onSetSharing } = setup(item({ is_private: 1 } as Partial<PackingItem>))
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bob/ }))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(5, 'shared', [2])
|
||||
})
|
||||
|
||||
it('FE-W4PSC-006: toggling an existing recipient off removes them', () => {
|
||||
const { onSetSharing } = setup(item({ is_private: 1, recipients: [{ user_id: 2 }, { user_id: 3 }] } as unknown as Partial<PackingItem>))
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bob/ }))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(5, 'shared', [3])
|
||||
})
|
||||
|
||||
it('FE-W4PSC-007: an item with recipients reads as the shared tier', () => {
|
||||
setup(item({ is_private: 1, recipients: [{ user_id: 2 }] } as unknown as Partial<PackingItem>))
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
// The chosen recipient carries the check mark.
|
||||
expect(screen.getByRole('button', { name: /bob/ }).querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-008: shows a hint when there is nobody else to share with', () => {
|
||||
render(
|
||||
<PackingShareControl
|
||||
item={item()} tripMembers={[MEMBERS[0]]} currentUserId={1}
|
||||
onSetSharing={() => {}} onClone={() => {}} onJoin={() => {}} onLeave={() => {}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
expect(screen.getByText(/no one/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-009: the scrim closes the menu', () => {
|
||||
const { baseElement } = setup(item())
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
|
||||
// The scrim is the portaled full-screen layer that sits just under the menu.
|
||||
fireEvent.click(baseElement.querySelector('div[style*="z-index: 1099"]')!)
|
||||
|
||||
expect(screen.queryByText(/^Personal$/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-010: the trigger toggles the menu closed again', () => {
|
||||
setup(item())
|
||||
const trigger = screen.getByRole('button', { name: /sharing/i })
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText(/^Personal$/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText(/^Personal$/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4PSC-011: an unowned item is treated as the current user own', () => {
|
||||
setup(item({ owner_id: null } as unknown as Partial<PackingItem>), 9)
|
||||
|
||||
expect(screen.getByRole('button', { name: /sharing/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PackingShareControl — other members', () => {
|
||||
it('FE-W4PSC-012: a non-owner can pledge to co-bring a common item', () => {
|
||||
const { onJoin } = setup(item(), 2)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
expect(onJoin).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('FE-W4PSC-013: a pledged contributor can withdraw again', () => {
|
||||
const { onLeave } = setup(item({ contributors: [{ user_id: 2 }] } as unknown as Partial<PackingItem>), 2)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
|
||||
expect(onLeave).toHaveBeenCalledWith(5, 2)
|
||||
})
|
||||
|
||||
it('FE-W4PSC-014: a non-owner can clone a common item to their own list', () => {
|
||||
const { onClone } = setup(item(), 2)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
|
||||
expect(onClone).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('FE-W4PSC-015: a recipient of a shared item gets no controls', () => {
|
||||
const { container } = setup(item({ is_private: 1, recipients: [{ user_id: 2 }] } as unknown as Partial<PackingItem>), 2)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PackingShareControl — hover affordances', () => {
|
||||
it('FE-W5PSC-001: the trigger of a common item highlights on hover and resets on leave', () => {
|
||||
setup(item())
|
||||
const trigger = screen.getByRole('button', { name: /sharing/i })
|
||||
|
||||
fireEvent.mouseEnter(trigger)
|
||||
expect(trigger.style.color).toBe('var(--text-secondary)')
|
||||
|
||||
fireEvent.mouseLeave(trigger)
|
||||
expect(trigger.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
|
||||
it('FE-W5PSC-002: a non-common item keeps its accent colour while hovered', () => {
|
||||
setup(item({ is_private: 1 } as Partial<PackingItem>))
|
||||
const trigger = screen.getByRole('button', { name: /sharing/i })
|
||||
|
||||
fireEvent.mouseEnter(trigger)
|
||||
expect(trigger.style.color).toBe('var(--accent)')
|
||||
|
||||
fireEvent.mouseLeave(trigger)
|
||||
expect(trigger.style.color).toBe('var(--accent)')
|
||||
})
|
||||
|
||||
it('FE-W5PSC-003: the inactive tier row takes a hover background and drops it again', () => {
|
||||
setup(item())
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
// The item is Common, so the Personal row is the inactive one.
|
||||
const personalRow = screen.getByText(/^Personal$/).closest('button')!
|
||||
|
||||
fireEvent.mouseEnter(personalRow)
|
||||
expect(personalRow.style.background).toBe('var(--bg-tertiary)')
|
||||
|
||||
fireEvent.mouseLeave(personalRow)
|
||||
expect(personalRow.style.background).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-W5PSC-004: the active tier row ignores hover', () => {
|
||||
setup(item())
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
const commonRow = screen.getByText(/^Shared$/).closest('button')!
|
||||
const before = commonRow.style.background
|
||||
|
||||
fireEvent.mouseEnter(commonRow)
|
||||
fireEvent.mouseLeave(commonRow)
|
||||
|
||||
expect(commonRow.style.background).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-W5PSC-005: a recipient row highlights on hover', () => {
|
||||
setup(item({ is_private: 1 } as Partial<PackingItem>))
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
const bob = screen.getByRole('button', { name: /bob/ })
|
||||
|
||||
fireEvent.mouseEnter(bob)
|
||||
expect(bob.style.background).toBe('var(--bg-tertiary)')
|
||||
|
||||
fireEvent.mouseLeave(bob)
|
||||
expect(bob.style.background).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-W5PSC-006: the co-bring button of a non-contributor highlights on hover', () => {
|
||||
setup(item(), 2)
|
||||
const pledge = screen.getAllByRole('button')[0]
|
||||
|
||||
fireEvent.mouseEnter(pledge)
|
||||
expect(pledge.style.color).toBe('var(--text-secondary)')
|
||||
|
||||
fireEvent.mouseLeave(pledge)
|
||||
expect(pledge.style.color).toBe('var(--text-faint)')
|
||||
})
|
||||
|
||||
it('FE-W5PSC-007: an already pledged contributor keeps the accent colour while hovered', () => {
|
||||
setup(item({ contributors: [{ user_id: 2 }] } as unknown as Partial<PackingItem>), 2)
|
||||
const pledge = screen.getAllByRole('button')[0]
|
||||
|
||||
fireEvent.mouseEnter(pledge)
|
||||
fireEvent.mouseLeave(pledge)
|
||||
|
||||
expect(pledge.style.color).toBe('var(--accent)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PackingShareControl — optional fields', () => {
|
||||
it('FE-W5PSC-008: an item without recipients or contributors reads as the common tier', () => {
|
||||
const bare = { id: 7, name: 'Stove', is_private: 0, owner_id: 1 } as unknown as PackingItem
|
||||
const onSetSharing = vi.fn()
|
||||
render(
|
||||
<PackingShareControl
|
||||
item={bare} tripMembers={MEMBERS} currentUserId={1}
|
||||
onSetSharing={onSetSharing} onClone={() => {}} onJoin={() => {}} onLeave={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /sharing/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /bob/ }))
|
||||
|
||||
expect(onSetSharing).toHaveBeenCalledWith(7, 'shared', [2])
|
||||
})
|
||||
|
||||
it('FE-W5PSC-009: a non-owner without a contributor list can still pledge', () => {
|
||||
const bare = { id: 8, name: 'Stove', is_private: 0, owner_id: 1 } as unknown as PackingItem
|
||||
const onJoin = vi.fn()
|
||||
render(
|
||||
<PackingShareControl
|
||||
item={bare} tripMembers={MEMBERS} currentUserId={3}
|
||||
onSetSharing={() => {}} onClone={() => {}} onJoin={onJoin} onLeave={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
|
||||
expect(onJoin).toHaveBeenCalledWith(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,875 @@
|
||||
// FE-W5HOOK-001 to FE-W5HOOK-047
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { usePackingList, type PackingListPanelProps } from './usePackingListPanel'
|
||||
import type { PackingItem } from '../../types'
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>
|
||||
|
||||
const toastSpy = vi.fn((_message: string, _type?: string, _duration?: number) => 0)
|
||||
|
||||
function renderPanel(props: Partial<PackingListPanelProps> = {}) {
|
||||
return renderHook((p: PackingListPanelProps) => usePackingList(p), {
|
||||
wrapper,
|
||||
initialProps: { tripId: 1, items: [], ...props },
|
||||
})
|
||||
}
|
||||
|
||||
/** Waits for the mount effects (members, assignees, templates) to settle. */
|
||||
async function settled() {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
toastSpy.mockClear()
|
||||
window.__addToast = toastSpy
|
||||
server.use(
|
||||
http.get('/api/trips/:id/members', () =>
|
||||
HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [] })),
|
||||
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: {} })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: false, addons: [] })),
|
||||
http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [] })),
|
||||
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
|
||||
)
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('usePackingList — members & assignees', () => {
|
||||
it('FE-W5HOOK-001: merges the owner and the members into one member list', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/members', () =>
|
||||
HttpResponse.json({
|
||||
owner: { id: 1, username: 'owner', avatar_url: 'a.png' },
|
||||
members: [{ id: 2, username: 'alice', avatar_url: null, is_guest: 1 }],
|
||||
})),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
|
||||
await waitFor(() => expect(result.current.tripMembers).toHaveLength(2))
|
||||
expect(result.current.tripMembers[0]).toEqual({ id: 1, username: 'owner', avatar: 'a.png', is_guest: false })
|
||||
expect(result.current.tripMembers[1]).toEqual({ id: 2, username: 'alice', avatar: null, is_guest: true })
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-002: a response without owner or members yields no members', async () => {
|
||||
server.use(http.get('/api/trips/:id/members', () => HttpResponse.json({})))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.tripMembers).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-003: a failing members request leaves the list empty', async () => {
|
||||
server.use(http.get('/api/trips/:id/members', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.tripMembers).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-004: an assignee response without a map falls back to an empty record', async () => {
|
||||
server.use(http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({})))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.categoryAssignees).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-005: setting assignees stores the returned list for that category', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: { Docs: [] } })),
|
||||
http.put('/api/trips/1/packing/category-assignees/Gear', () =>
|
||||
HttpResponse.json({ assignees: [{ user_id: 2, username: 'alice' }] })),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await waitFor(() => expect(result.current.categoryAssignees.Docs).toEqual([]))
|
||||
|
||||
await act(async () => { await result.current.handleSetAssignees('Gear', [2]) })
|
||||
|
||||
expect(result.current.categoryAssignees.Gear).toEqual([{ user_id: 2, username: 'alice' }])
|
||||
// The existing categories survive the merge.
|
||||
expect(result.current.categoryAssignees.Docs).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-006: an assignee response without a list stores an empty array', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: { Docs: [] } })),
|
||||
http.put('/api/trips/1/packing/category-assignees/Gear', () => HttpResponse.json({})),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await waitFor(() => expect(result.current.categoryAssignees.Docs).toEqual([]))
|
||||
|
||||
await act(async () => { await result.current.handleSetAssignees('Gear', [2]) })
|
||||
|
||||
expect(result.current.categoryAssignees.Gear).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-007: a failing assignee update surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/category-assignees/Gear', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleSetAssignees('Gear', [2]) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackingList — grouping, filtering and progress', () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, name: 'Tent', category: 'Gear', checked: 1 }),
|
||||
buildPackingItem({ id: 2, name: 'Rope', category: 'Gear', checked: 0 }),
|
||||
buildPackingItem({ id: 3, name: 'Soap', category: null, checked: 0 }),
|
||||
buildPackingItem({ id: 4, name: 'Diary', category: 'Gear', is_private: 1 } as Partial<PackingItem>),
|
||||
]
|
||||
|
||||
it('FE-W5HOOK-008: the common view hides private items and buckets by category', async () => {
|
||||
const { result } = renderPanel({ items })
|
||||
await settled()
|
||||
|
||||
expect(result.current.allCategories).toEqual(['Gear', 'Other'])
|
||||
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Tent', 'Rope'])
|
||||
expect(result.current.gruppiert.Other.map(i => i.name)).toEqual(['Soap'])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-009: the personal view shows only private items', async () => {
|
||||
const { result } = renderPanel({ items })
|
||||
await settled()
|
||||
|
||||
act(() => result.current.setView('personal'))
|
||||
|
||||
expect(result.current.view).toBe('personal')
|
||||
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Diary'])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-010: the "offen" filter keeps only unchecked items', async () => {
|
||||
const { result } = renderPanel({ items })
|
||||
await settled()
|
||||
|
||||
act(() => result.current.setFilter('offen'))
|
||||
|
||||
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Rope'])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-011: the "erledigt" filter keeps only checked items', async () => {
|
||||
const { result } = renderPanel({ items })
|
||||
await settled()
|
||||
|
||||
act(() => result.current.setFilter('erledigt'))
|
||||
|
||||
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Tent'])
|
||||
expect(result.current.gruppiert.Other).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-012: progress is the checked share of the active view', async () => {
|
||||
const { result } = renderPanel({ items })
|
||||
await settled()
|
||||
|
||||
expect(result.current.abgehakt).toBe(1)
|
||||
expect(result.current.fortschritt).toBe(33)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-013: an empty view reports zero progress instead of NaN', async () => {
|
||||
const { result } = renderPanel()
|
||||
await settled()
|
||||
|
||||
expect(result.current.fortschritt).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-014: a controlled view prop drives the split and reports changes upwards', async () => {
|
||||
const onViewChange = vi.fn()
|
||||
const { result } = renderPanel({ items, view: 'personal', onViewChange })
|
||||
await settled()
|
||||
|
||||
expect(result.current.gruppiert.Gear.map(i => i.name)).toEqual(['Diary'])
|
||||
|
||||
act(() => result.current.setView('common'))
|
||||
|
||||
expect(onViewChange).toHaveBeenCalledWith('common')
|
||||
// Controlled: the hook does not flip its own state.
|
||||
expect(result.current.view).toBe('personal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackingList — item and category CRUD', () => {
|
||||
it('FE-W5HOOK-015: adding to a category reuses its placeholder row', async () => {
|
||||
const placeholder = buildPackingItem({ id: 9, name: '...', category: 'Gear' })
|
||||
seedStore(useTripStore, { packingItems: [placeholder] })
|
||||
let put: Record<string, unknown> | null = null
|
||||
let posted = false
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/9', async ({ request }) => {
|
||||
put = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: placeholder })
|
||||
}),
|
||||
http.post('/api/trips/1/packing', () => { posted = true; return HttpResponse.json({ item: placeholder }) }),
|
||||
)
|
||||
const { result } = renderPanel({ items: [placeholder] })
|
||||
|
||||
await act(async () => { await result.current.handleAddItemToCategory('Gear', 'Tent') })
|
||||
|
||||
expect(put).toMatchObject({ name: 'Tent' })
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-016: a new item in the personal view is created as personal', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 3 }) })
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel({ view: 'personal', onViewChange: () => {} })
|
||||
|
||||
await act(async () => { await result.current.handleAddItemToCategory('Gear', 'Tent') })
|
||||
|
||||
expect(body).toMatchObject({ name: 'Tent', category: 'Gear', visibility: 'personal' })
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-017: a failing add surfaces an add error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleAddItemToCategory('Gear', 'Tent') })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to add', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-018: deleting the last item of a category unchecks it and converts it to a placeholder', async () => {
|
||||
const item = buildPackingItem({ id: 11, name: 'Tent', category: 'Gear', checked: 1 })
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
let deleted = false
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/11', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>)
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
http.delete('/api/trips/1/packing/11', () => { deleted = true; return HttpResponse.json({ success: true }) }),
|
||||
)
|
||||
const { result } = renderPanel({ items: [item] })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteItem(item) })
|
||||
|
||||
expect(bodies[0]).toMatchObject({ checked: false })
|
||||
expect(bodies[1]).toMatchObject({ name: '...', weight_grams: null, bag_id: null, quantity: 1 })
|
||||
expect(deleted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-018a: the last item of a category is converted without an extra uncheck call', async () => {
|
||||
const item = buildPackingItem({ id: 14, name: 'Tent', category: 'Gear', checked: 0 })
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/14', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>)
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel({ items: [item] })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteItem(item) })
|
||||
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ name: '...' })
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-018b: an item with siblings in its category is deleted outright', async () => {
|
||||
const item = buildPackingItem({ id: 15, name: 'Tent', category: 'Gear' })
|
||||
const sibling = buildPackingItem({ id: 16, name: 'Rope', category: 'Gear' })
|
||||
let deleted = false
|
||||
let converted = false
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/15', () => { deleted = true; return HttpResponse.json({ success: true }) }),
|
||||
http.put('/api/trips/1/packing/15', () => { converted = true; return HttpResponse.json({ item }) }),
|
||||
)
|
||||
const { result } = renderPanel({ items: [item, sibling] })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteItem(item) })
|
||||
|
||||
expect(deleted).toBe(true)
|
||||
expect(converted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-019: an uncategorized item is deleted outright', async () => {
|
||||
const item = buildPackingItem({ id: 12, name: 'Soap', category: null })
|
||||
let deleted = false
|
||||
server.use(http.delete('/api/trips/1/packing/12', () => { deleted = true; return HttpResponse.json({ success: true }) }))
|
||||
const { result } = renderPanel({ items: [item] })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteItem(item) })
|
||||
|
||||
expect(deleted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-020: a failing delete surfaces a delete error', async () => {
|
||||
const item = buildPackingItem({ id: 13, name: 'Soap', category: null })
|
||||
server.use(http.delete('/api/trips/1/packing/13', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel({ items: [item] })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteItem(item) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-021: adding a category without a name does nothing', async () => {
|
||||
let posted = false
|
||||
server.use(http.post('/api/trips/1/packing', () => { posted = true; return HttpResponse.json({ item: buildPackingItem() }) }))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setNewCatName(' '))
|
||||
await act(async () => { await result.current.handleAddNewCategory() })
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-022: a duplicate category name gets a zero-width suffix so it stays distinct', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem() })
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel({ items: [buildPackingItem({ id: 20, category: 'Gear' })] })
|
||||
await settled()
|
||||
|
||||
act(() => result.current.setNewCatName('Gear'))
|
||||
await act(async () => { await result.current.handleAddNewCategory() })
|
||||
|
||||
expect(body).toMatchObject({ name: '...', category: 'Gear', visibility: 'common' })
|
||||
expect(result.current.newCatName).toBe('')
|
||||
expect(result.current.addingCategory).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-022a: a category added from the personal view is created as personal', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildPackingItem() })
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel({ view: 'personal', onViewChange: () => {} })
|
||||
|
||||
act(() => result.current.setNewCatName('Gear'))
|
||||
await act(async () => { await result.current.handleAddNewCategory() })
|
||||
|
||||
expect(body).toMatchObject({ category: 'Gear', visibility: 'personal' })
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-023: a failing category add surfaces an add error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setNewCatName('Gear'))
|
||||
await act(async () => { await result.current.handleAddNewCategory() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to add', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-024: renaming the default category moves every uncategorized item', async () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 31, category: null }),
|
||||
buildPackingItem({ id: 32, category: 'Gear' }),
|
||||
]
|
||||
const touched: number[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/:itemId', ({ params }) => {
|
||||
touched.push(Number(params.itemId))
|
||||
return HttpResponse.json({ item: items[0] })
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel({ items })
|
||||
|
||||
await act(async () => { await result.current.handleRenameCategory('Other', 'Misc') })
|
||||
|
||||
expect(touched).toEqual([31])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-025: a partly failing category delete surfaces a delete error', async () => {
|
||||
const items = [buildPackingItem({ id: 41 }), buildPackingItem({ id: 42 })]
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/41', () => HttpResponse.json({ success: true })),
|
||||
http.delete('/api/trips/1/packing/42', () => new HttpResponse(null, { status: 500 })),
|
||||
)
|
||||
const { result } = renderPanel({ items })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteCategory(items) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-025a: a clean category delete stays quiet', async () => {
|
||||
const items = [buildPackingItem({ id: 43 }), buildPackingItem({ id: 44 })]
|
||||
const deleted: number[] = []
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/:itemId', ({ params }) => {
|
||||
deleted.push(Number(params.itemId))
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel({ items })
|
||||
|
||||
await act(async () => { await result.current.handleDeleteCategory(items) })
|
||||
|
||||
expect(deleted).toEqual([43, 44])
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-026: declining the clear-checked confirmation deletes nothing', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
let deleted = false
|
||||
server.use(http.delete('/api/trips/1/packing/:itemId', () => { deleted = true; return HttpResponse.json({ success: true }) }))
|
||||
const { result } = renderPanel({ items: [buildPackingItem({ id: 51, checked: 1 })] })
|
||||
|
||||
await act(async () => { await result.current.handleClearChecked() })
|
||||
|
||||
expect(deleted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-027: a failing clear-checked delete surfaces a delete error', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
server.use(http.delete('/api/trips/1/packing/52', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel({ items: [buildPackingItem({ id: 52, checked: 1 })] })
|
||||
|
||||
await act(async () => { await result.current.handleClearChecked() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackingList — bags', () => {
|
||||
beforeEach(() => {
|
||||
seedStore(useAddonStore, { bagTracking: true, loaded: true })
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-028: a bag list response without bags falls back to an empty list', async () => {
|
||||
server.use(http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({})))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.bagTrackingEnabled).toBe(true)
|
||||
expect(result.current.bags).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-029: creating a bag without a name does nothing', async () => {
|
||||
let posted = false
|
||||
server.use(http.post('/api/trips/1/packing/bags', () => { posted = true; return HttpResponse.json({ bag: {} }) }))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setNewBagName(' '))
|
||||
await act(async () => { await result.current.handleCreateBag() })
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-030: creating a bag appends it and clears the form', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing/bags', () =>
|
||||
HttpResponse.json({ bag: { id: 3, name: 'Duffel', color: '#6366f1' } })),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await settled()
|
||||
|
||||
act(() => { result.current.setNewBagName('Duffel'); result.current.setShowAddBag(true) })
|
||||
await act(async () => { await result.current.handleCreateBag() })
|
||||
|
||||
expect(result.current.bags).toEqual([{ id: 3, name: 'Duffel', color: '#6366f1' }])
|
||||
expect(result.current.newBagName).toBe('')
|
||||
expect(result.current.showAddBag).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-031: a failing bag create surfaces a save error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/bags', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setNewBagName('Duffel'))
|
||||
await act(async () => { await result.current.handleCreateBag() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-032: creating a bag by name returns the new bag', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing/bags', () =>
|
||||
HttpResponse.json({ bag: { id: 4, name: 'Crate', color: '#6366f1' } })),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await settled()
|
||||
|
||||
let created: unknown
|
||||
await act(async () => { created = await result.current.handleCreateBagByName('Crate') })
|
||||
|
||||
expect(created).toEqual({ id: 4, name: 'Crate', color: '#6366f1' })
|
||||
expect(result.current.bags).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-033: a failing create-by-name returns undefined and warns', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/bags', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
let created: unknown = 'unset'
|
||||
await act(async () => { created = await result.current.handleCreateBagByName('Crate') })
|
||||
|
||||
expect(created).toBeUndefined()
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-034: deleting a bag drops it from the list', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 5, name: 'A' }, { id: 6, name: 'B' }] })),
|
||||
http.delete('/api/trips/1/packing/bags/5', () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(2))
|
||||
|
||||
await act(async () => { await result.current.handleDeleteBag(5) })
|
||||
|
||||
expect(result.current.bags.map(b => b.id)).toEqual([6])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-035: a failing bag delete surfaces a delete error', async () => {
|
||||
server.use(http.delete('/api/trips/1/packing/bags/5', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleDeleteBag(5) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-036: updating a bag merges the response into that bag only', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 5, name: 'A' }, { id: 6, name: 'B' }] })),
|
||||
http.put('/api/trips/1/packing/bags/6', () => HttpResponse.json({ bag: { name: 'Renamed' } })),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(2))
|
||||
|
||||
await act(async () => { await result.current.handleUpdateBag(6, { name: 'Renamed' }) })
|
||||
|
||||
expect(result.current.bags.map(b => b.name)).toEqual(['A', 'Renamed'])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-037: a failing bag update surfaces a generic error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/bags/6', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleUpdateBag(6, { name: 'X' }) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-038: setting bag members replaces that bag members', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/packing/bags', () =>
|
||||
HttpResponse.json({ bags: [{ id: 5, name: 'A', members: [] }, { id: 6, name: 'B', members: [] }] })),
|
||||
http.put('/api/trips/1/packing/bags/5/members', () =>
|
||||
HttpResponse.json({ members: [{ user_id: 2, username: 'alice' }] })),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(2))
|
||||
|
||||
await act(async () => { await result.current.handleSetBagMembers(5, [2]) })
|
||||
|
||||
expect(result.current.bags[0].members).toEqual([{ user_id: 2, username: 'alice' }])
|
||||
expect(result.current.bags[1].members).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-039: a failing member update surfaces a generic error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/bags/5/members', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleSetBagMembers(5, [2]) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackingList — templates, import and signals', () => {
|
||||
it('FE-W5HOOK-040: a template list response without templates falls back to an empty list', async () => {
|
||||
server.use(http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({})))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.availableTemplates).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-041: applying a template appends its items and reports the count', async () => {
|
||||
const fresh = buildPackingItem({ id: 61, name: 'Towel' })
|
||||
server.use(http.post('/api/trips/1/packing/apply-template/2', () => HttpResponse.json({ items: [fresh], count: 1 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setShowTemplateDropdown(true))
|
||||
await act(async () => { await result.current.handleApplyTemplate(2) })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toContainEqual(fresh)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 items added from template', 'success', undefined)
|
||||
expect(result.current.showTemplateDropdown).toBe(false)
|
||||
expect(result.current.applyingTemplate).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-041a: a template response without items leaves the store untouched', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/apply-template/2', () => HttpResponse.json({ count: 0 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleApplyTemplate(2) })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toEqual([])
|
||||
expect(toastSpy).toHaveBeenCalledWith('0 items added from template', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-042: a failing template apply surfaces a template error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/apply-template/2', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleApplyTemplate(2) })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Failed to apply template', 'error', undefined)
|
||||
expect(result.current.applyingTemplate).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-043: saving a template without a name does nothing', async () => {
|
||||
let posted = false
|
||||
server.use(http.post('/api/trips/1/packing/save-as-template', () => { posted = true; return HttpResponse.json({}) }))
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleSaveAsTemplate() })
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-044: saving a template reloads the list and clears the form', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing/save-as-template', () => HttpResponse.json({ success: true })),
|
||||
// The reload response omits `templates` — the hook must fall back to [].
|
||||
http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({})),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => { result.current.setSaveTemplateName('Beach'); result.current.setShowSaveTemplate(true) })
|
||||
await act(async () => { await result.current.handleSaveAsTemplate() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Packing list saved as template', 'success', undefined)
|
||||
expect(result.current.showSaveTemplate).toBe(false)
|
||||
expect(result.current.saveTemplateName).toBe('')
|
||||
await waitFor(() => expect(result.current.availableTemplates).toEqual([]))
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-045: a failing template save surfaces a generic error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/save-as-template', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setSaveTemplateName('Beach'))
|
||||
await act(async () => { await result.current.handleSaveAsTemplate() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-046: importing nothing reports an empty import', async () => {
|
||||
const { result } = renderPanel()
|
||||
|
||||
await act(async () => { await result.current.handleBulkImport() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('No items to import', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-047: a successful import appends the items and closes the modal', async () => {
|
||||
const imported = buildPackingItem({ id: 71, name: 'Rope' })
|
||||
server.use(http.post('/api/trips/1/packing/import', () => HttpResponse.json({ items: [imported], count: 1 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => { result.current.setImportText('Gear, Rope'); result.current.setShowImportModal(true) })
|
||||
await act(async () => { await result.current.handleBulkImport() })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toContainEqual(imported)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 items imported', 'success', undefined)
|
||||
expect(result.current.importText).toBe('')
|
||||
expect(result.current.showImportModal).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-047a: an import response without items still reports success', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/import', () => HttpResponse.json({ count: 0 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setImportText('Gear, Rope'))
|
||||
await act(async () => { await result.current.handleBulkImport() })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toEqual([])
|
||||
expect(toastSpy).toHaveBeenCalledWith('0 items imported', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-048: a failing import surfaces an import error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/import', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
act(() => result.current.setImportText('Gear, Rope'))
|
||||
await act(async () => { await result.current.handleBulkImport() })
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledWith('Import failed', 'error', undefined)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-049: picking a CSV file loads its text into the import box', async () => {
|
||||
const { result } = renderPanel()
|
||||
const file = new File(['Gear, Rope'], 'list.csv', { type: 'text/plain' })
|
||||
const target = { files: [file], value: 'list.csv' } as unknown as HTMLInputElement
|
||||
|
||||
act(() => result.current.handleCsvFile({ target } as unknown as ChangeEvent<HTMLInputElement>))
|
||||
|
||||
await waitFor(() => expect(result.current.importText).toBe('Gear, Rope'))
|
||||
expect(target.value).toBe('')
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-050: an empty file picker selection is ignored', async () => {
|
||||
const { result } = renderPanel()
|
||||
const target = { files: null, value: 'x' } as unknown as HTMLInputElement
|
||||
|
||||
act(() => result.current.handleCsvFile({ target } as unknown as ChangeEvent<HTMLInputElement>))
|
||||
|
||||
await settled()
|
||||
expect(result.current.importText).toBe('')
|
||||
// The input is only reset once a file was actually taken.
|
||||
expect(target.value).toBe('x')
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-051: a non-text read result is ignored', async () => {
|
||||
class BinaryReader {
|
||||
result: unknown = new ArrayBuffer(4)
|
||||
onload: (() => void) | null = null
|
||||
readAsText() { this.onload?.() }
|
||||
}
|
||||
vi.stubGlobal('FileReader', BinaryReader)
|
||||
const { result } = renderPanel()
|
||||
const file = new File(['x'], 'list.csv')
|
||||
const target = { files: [file], value: 'list.csv' } as unknown as HTMLInputElement
|
||||
|
||||
act(() => result.current.handleCsvFile({ target } as unknown as ChangeEvent<HTMLInputElement>))
|
||||
|
||||
await settled()
|
||||
expect(result.current.importText).toBe('')
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-052: a raised import signal opens the import modal', async () => {
|
||||
const { result, rerender } = renderPanel({ openImportSignal: 0 })
|
||||
await settled()
|
||||
|
||||
rerender({ tripId: 1, items: [], openImportSignal: 1 })
|
||||
|
||||
expect(result.current.showImportModal).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-053: a raised save-template signal opens the save form', async () => {
|
||||
const { result, rerender } = renderPanel({ saveTemplateSignal: 0 })
|
||||
await settled()
|
||||
|
||||
rerender({ tripId: 1, items: [], saveTemplateSignal: 1 })
|
||||
|
||||
expect(result.current.showSaveTemplate).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-054: a raised clear-checked signal runs the bulk delete', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const items = [buildPackingItem({ id: 81, checked: 1 })]
|
||||
let deleted = false
|
||||
server.use(http.delete('/api/trips/1/packing/81', () => { deleted = true; return HttpResponse.json({ success: true }) }))
|
||||
const { rerender } = renderPanel({ items, clearCheckedSignal: 0 })
|
||||
await settled()
|
||||
|
||||
await act(async () => { rerender({ tripId: 1, items, clearCheckedSignal: 1 }) })
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true))
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-055: a mousedown outside the template dropdown closes it', async () => {
|
||||
const { result } = renderPanel()
|
||||
await settled()
|
||||
|
||||
act(() => { result.current.setShowTemplateDropdown(true) })
|
||||
act(() => { result.current.templateDropdownRef.current = document.createElement('div') })
|
||||
act(() => { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
|
||||
expect(result.current.showTemplateDropdown).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-055a: a mousedown inside the template dropdown keeps it open', async () => {
|
||||
const { result } = renderPanel()
|
||||
await settled()
|
||||
|
||||
const dropdown = document.createElement('div')
|
||||
const child = document.createElement('button')
|
||||
dropdown.appendChild(child)
|
||||
document.body.appendChild(dropdown)
|
||||
|
||||
act(() => { result.current.setShowTemplateDropdown(true) })
|
||||
act(() => { result.current.templateDropdownRef.current = dropdown })
|
||||
act(() => { child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
|
||||
expect(result.current.showTemplateDropdown).toBe(true)
|
||||
document.body.removeChild(dropdown)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackingList — permissions and sharing', () => {
|
||||
it('FE-W5HOOK-056: a restricted packing_edit permission removes edit rights', async () => {
|
||||
usePermissionsStore.setState({ permissions: { packing_edit: 'admin' } })
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 2 }), isAuthenticated: true })
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.canEdit).toBe(false)
|
||||
expect(result.current.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-057: an admin keeps edit rights and the admin flag', async () => {
|
||||
usePermissionsStore.setState({ permissions: { packing_edit: 'admin' } })
|
||||
seedStore(useAuthStore, { user: buildAdmin({ id: 3 }), isAuthenticated: true })
|
||||
const { result } = renderPanel()
|
||||
|
||||
await settled()
|
||||
expect(result.current.canEdit).toBe(true)
|
||||
expect(result.current.isAdmin).toBe(true)
|
||||
expect(result.current.currentUserId).toBe(3)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-058: the sharing handlers delegate to the packing endpoints', async () => {
|
||||
const item = buildPackingItem({ id: 91, name: 'Stove' })
|
||||
seedStore(useTripStore, { packingItems: [item] })
|
||||
let sharingBody: Record<string, unknown> | null = null
|
||||
const hits: string[] = []
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/91/sharing', async ({ request }) => {
|
||||
sharingBody = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
http.post('/api/trips/1/packing/91/clone', () => { hits.push('clone'); return HttpResponse.json({ item }) }),
|
||||
http.post('/api/trips/1/packing/91/contributors', () => { hits.push('join'); return HttpResponse.json({ item }) }),
|
||||
http.delete('/api/trips/1/packing/91/contributors/2', () => { hits.push('leave'); return HttpResponse.json({ item }) }),
|
||||
)
|
||||
const { result } = renderPanel({ items: [item] })
|
||||
|
||||
await act(async () => { await result.current.handleSetSharing(91, 'shared', [2]) })
|
||||
await act(async () => { await result.current.handleCloneItem(91) })
|
||||
await act(async () => { await result.current.handleJoinItem(91) })
|
||||
await act(async () => { await result.current.handleLeaveItem(91, 2) })
|
||||
|
||||
expect(sharingBody).toEqual({ visibility: 'shared', recipient_ids: [2] })
|
||||
expect(hits).toEqual(['clone', 'join', 'leave'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user