// FE-COMP-PACKING-001 to FE-COMP-PACKING-020 import { vi } from 'vitest'; import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; import PackingListPanel, { itemWeight } from './PackingListPanel'; describe('itemWeight (bag total weight calc)', () => { it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => { expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360); }); it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => { expect(itemWeight({ weight_grams: 250 })).toBe(250); }); it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => { expect(itemWeight({ quantity: 5 })).toBe(0); expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0); expect(itemWeight({})).toBe(0); }); }); beforeEach(() => { resetAllStores(); // Side-effect APIs PackingListPanel calls on mount server.use( http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: null, members: [], current_user_id: 1 }) ), http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: {} }) ), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false }) ), http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [] }) ), ); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); }); describe('PackingListPanel', () => { it('FE-COMP-PACKING-001: renders Packing List title', () => { render(); expect(screen.getByText('Packing List')).toBeInTheDocument(); }); it('FE-COMP-PACKING-002: shows empty state when no items', () => { render(); // Both the subtitle and the empty content area say "Packing list is empty" const els = screen.getAllByText('Packing list is empty'); expect(els.length).toBeGreaterThan(0); }); it('FE-COMP-PACKING-003: empty state shows hint text', () => { render(); expect(screen.getByText(/Add items or use the suggestions/i)).toBeInTheDocument(); }); it('FE-COMP-PACKING-004: shows items from props grouped by category', () => { const items = [ buildPackingItem({ name: 'Passport', category: 'Documents' }), buildPackingItem({ name: 'Charger', category: 'Electronics' }), ]; render(); expect(screen.getByText('Passport')).toBeInTheDocument(); expect(screen.getByText('Charger')).toBeInTheDocument(); }); it('FE-COMP-PACKING-005: shows category group headers', () => { const items = [ buildPackingItem({ name: 'Toothbrush', category: 'Hygiene' }), ]; render(); expect(screen.getByText('Hygiene')).toBeInTheDocument(); }); it('FE-COMP-PACKING-006: shows progress count in subtitle', () => { const items = [ buildPackingItem({ name: 'Item1', checked: 1 }), buildPackingItem({ name: 'Item2', checked: 0 }), ]; render(); expect(screen.getByText(/1 of 2 packed/i)).toBeInTheDocument(); }); it('FE-COMP-PACKING-007: shows progress bar for packed items', () => { const items = [ buildPackingItem({ name: 'Item1', checked: 1 }), ]; render(); // 1/1 = 100% packed shows "All packed!" expect(screen.getByText('All packed!')).toBeInTheDocument(); }); it('FE-COMP-PACKING-008: items without category are grouped under default category', () => { const items = [ buildPackingItem({ name: 'Sunscreen', category: null }), ]; render(); expect(screen.getByText('Sunscreen')).toBeInTheDocument(); // default category is "Other" expect(screen.getByText('Other')).toBeInTheDocument(); }); it('FE-COMP-PACKING-009: clicking Add item reveals input form', async () => { const user = userEvent.setup(); const items = [buildPackingItem({ name: 'Shorts', category: 'Clothing' })]; render(); // Click "Add item" button to reveal input await user.click(screen.getByText('Add item')); expect(screen.getByPlaceholderText('Item name...')).toBeInTheDocument(); }); it('FE-COMP-PACKING-010: typing in add item input and pressing Enter calls POST', async () => { const user = userEvent.setup(); const existingItem = buildPackingItem({ name: 'Existing', category: 'Clothing' }); let postCalled = false; server.use( http.post('/api/trips/1/packing', async ({ request }) => { postCalled = true; const body = await request.json() as Record; const item = buildPackingItem({ name: String(body.name), category: String(body.category) }); return HttpResponse.json({ item }); }) ); render(); await user.click(screen.getByText('Add item')); const addInput = screen.getByPlaceholderText('Item name...'); await user.type(addInput, 'T-Shirt{Enter}'); await waitFor(() => expect(postCalled).toBe(true)); }); it('FE-COMP-PACKING-011: checked item has checked state visually (1=checked)', () => { const items = [buildPackingItem({ name: 'Packed Item', checked: 1 })]; render(); expect(screen.getByText('Packed Item')).toBeInTheDocument(); }); it('FE-COMP-PACKING-012: unchecked item renders in open state', () => { const items = [buildPackingItem({ name: 'Unpacked Item', checked: 0 })]; render(); expect(screen.getByText('Unpacked Item')).toBeInTheDocument(); }); it('FE-COMP-PACKING-013: multiple categories render independently', () => { const items = [ buildPackingItem({ name: 'Shirt', category: 'Clothing' }), buildPackingItem({ name: 'Passport', category: 'Documents' }), ]; render(); expect(screen.getByText('Clothing')).toBeInTheDocument(); expect(screen.getByText('Documents')).toBeInTheDocument(); }); it('FE-COMP-PACKING-014: Add category button is shown', () => { render(); // The "Add category" button should be present in the toolbar expect(screen.getByText('Add category')).toBeInTheDocument(); }); it('FE-COMP-PACKING-015: clicking Add Category shows the category name input', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText('Add category')); await screen.findByPlaceholderText('Category name (e.g. Clothing)'); }); it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' }); let deleteCalled = false; server.use( http.delete('/api/trips/1/packing/99', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }) ); render(); expect(screen.getByText('To Remove')).toBeInTheDocument(); // Delete button is in the DOM (opacity 0 on desktop but exists) const deleteBtn = screen.getByTitle('Delete'); await user.click(deleteBtn); await waitFor(() => expect(deleteCalled).toBe(true)); }); it('FE-COMP-PACKING-017: shows filter buttons (All, Open, Done) when items exist', () => { const items = [buildPackingItem({ name: 'Shirt', category: 'Clothing' })]; render(); expect(screen.getByText('All')).toBeInTheDocument(); expect(screen.getByText('Open')).toBeInTheDocument(); expect(screen.getByText('Done')).toBeInTheDocument(); }); it('FE-COMP-PACKING-018: filtering to Done hides unchecked items', async () => { const user = userEvent.setup(); const items = [ buildPackingItem({ name: 'Done Item', checked: 1, category: 'Test' }), buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }), ]; render(); await user.click(screen.getByText('Done')); expect(screen.getByText('Done Item')).toBeInTheDocument(); expect(screen.queryByText('Open Item')).not.toBeInTheDocument(); }); it('FE-COMP-PACKING-019: filtering to Open hides checked items', async () => { const user = userEvent.setup(); const items = [ buildPackingItem({ name: 'Done Item', checked: 1, category: 'Test' }), buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }), ]; render(); await user.click(screen.getByText('Open')); expect(screen.queryByText('Done Item')).not.toBeInTheDocument(); expect(screen.getByText('Open Item')).toBeInTheDocument(); }); it('FE-COMP-PACKING-020: renders empty filter message when filter yields nothing', async () => { const user = userEvent.setup(); const items = [ buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }), ]; render(); await user.click(screen.getByText('Done')); expect(screen.getByText('No items match this filter')).toBeInTheDocument(); }); it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 42, name: 'Sunscreen', category: 'Toiletries' }); let patchBody: Record | null = null; server.use( http.put('/api/trips/1/packing/42', async ({ request }) => { patchBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: 42, name: 'Sunblock', category: 'Toiletries' }) }); }) ); render(); // Click the rename (pencil) button await user.click(screen.getByTitle('Rename')); // Input appears pre-filled with 'Sunscreen' const input = screen.getByDisplayValue('Sunscreen'); expect(input).toBeInTheDocument(); // Clear and type new name, then press Enter await user.clear(input); await user.type(input, 'Sunblock'); await user.keyboard('{Enter}'); await waitFor(() => expect(patchBody).toMatchObject({ name: 'Sunblock' })); }); it('FE-COMP-PACKING-024: toggle item checked state calls PUT', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 50, name: 'Shorts', checked: 0, category: 'Clothing' }); let patchBody: Record | null = null; server.use( http.put('/api/trips/1/packing/50', async ({ request }) => { patchBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: 50, checked: 1 }) }); }) ); const { container } = render(); // The toggle button contains the Square icon for unchecked items const toggleBtn = container.querySelector('svg.lucide-square')?.closest('button'); expect(toggleBtn).toBeTruthy(); await user.click(toggleBtn!); await waitFor(() => expect(patchBody).toMatchObject({ checked: true })); }); it('FE-COMP-PACKING-025: "Check all" bulk action calls PUT for all unchecked items', async () => { const user = userEvent.setup(); const item1 = buildPackingItem({ id: 60, name: 'Item1', checked: 0, category: 'TestCat' }); const item2 = buildPackingItem({ id: 61, name: 'Item2', checked: 0, category: 'TestCat' }); const patchedIds: number[] = []; server.use( http.put('/api/trips/1/packing/:itemId', ({ params }) => { patchedIds.push(Number(params.itemId)); return HttpResponse.json({ item: buildPackingItem() }); }) ); const { container } = render(); // Open the MoreHorizontal context menu const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); // Click "Check All" await user.click(await screen.findByText('Check All')); await waitFor(() => { expect(patchedIds).toContain(60); expect(patchedIds).toContain(61); }); }); it('FE-COMP-PACKING-026: quantity input change calls PUT with new quantity', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 70, name: 'T-Shirts', quantity: 2, category: 'Clothing' }); let patchBody: Record | null = null; server.use( http.put('/api/trips/1/packing/70', async ({ request }) => { patchBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: 70, quantity: 5 }) }); }) ); render(); // Find the quantity input showing '2' const qtyInput = screen.getByDisplayValue('2'); await user.clear(qtyInput); await user.type(qtyInput, '5'); await user.tab(); // blur triggers commit await waitFor(() => expect(patchBody).toMatchObject({ quantity: 5 })); }); it('FE-COMP-PACKING-027: add new category via form calls POST', async () => { const user = userEvent.setup(); let postBody: Record | null = null; server.use( http.post('/api/trips/1/packing', async ({ request }) => { postBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ name: '...', category: 'Valuables' }) }); }) ); render(); await user.click(screen.getByText('Add category')); const input = await screen.findByPlaceholderText('Category name (e.g. Clothing)'); await user.type(input, 'Valuables'); await user.keyboard('{Enter}'); await waitFor(() => expect(postBody).toMatchObject({ category: 'Valuables' })); }); it('FE-COMP-PACKING-028: category group collapse hides items, expand shows them', async () => { const user = userEvent.setup(); const item = buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' }); const { container } = render(); // Item is visible initially expect(screen.getByText('Sunscreen')).toBeInTheDocument(); // Click the ChevronDown button to collapse const chevronDown = container.querySelector('svg.lucide-chevron-down')?.closest('button'); expect(chevronDown).toBeTruthy(); await user.click(chevronDown!); // Item should no longer be visible expect(screen.queryByText('Sunscreen')).not.toBeInTheDocument(); // Click the ChevronRight button to expand again const chevronRight = container.querySelector('svg.lucide-chevron-right')?.closest('button'); expect(chevronRight).toBeTruthy(); await user.click(chevronRight!); // Item visible again expect(screen.getByText('Sunscreen')).toBeInTheDocument(); }); it('FE-COMP-PACKING-029: bag tracking sidebar not shown when disabled', async () => { render(); // No "Bags" heading or luggage sidebar should appear await waitFor(() => { expect(screen.queryByText('Bags')).not.toBeInTheDocument(); }); }); it('FE-COMP-PACKING-030: packing template button present when templates available', async () => { server.use( http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] }) ) ); render(); // "Apply template" button appears when templates are available await screen.findByText('Apply template'); }); it('FE-COMP-PACKING-031: "Uncheck All" bulk action calls PUT to uncheck checked items', async () => { const user = userEvent.setup(); const item1 = buildPackingItem({ id: 80, name: 'ItemA', checked: 1, category: 'Gear' }); const item2 = buildPackingItem({ id: 81, name: 'ItemB', checked: 1, category: 'Gear' }); const patchedIds: number[] = []; server.use( http.put('/api/trips/1/packing/:itemId', ({ params }) => { patchedIds.push(Number(params.itemId)); return HttpResponse.json({ item: buildPackingItem() }); }) ); const { container } = render(); // Open the MoreHorizontal context menu const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); // Click "Uncheck All" await user.click(await screen.findByText('Uncheck All')); await waitFor(() => { expect(patchedIds).toContain(80); expect(patchedIds).toContain(81); }); }); it('FE-COMP-PACKING-032: category assignee button shown when trip members exist', async () => { server.use( http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [{ id: 2, username: 'alice', avatar_url: null }], current_user_id: 1, }) ) ); const item = buildPackingItem({ name: 'Passport', category: 'Documents' }); const { container } = render(); // UserPlus assignee button should appear in the category header await waitFor(() => { const userPlusBtn = container.querySelector('svg.lucide-user-plus'); expect(userPlusBtn).toBeTruthy(); }); }); it('FE-COMP-PACKING-033: import modal opens and closes', async () => { const user = userEvent.setup(); const { container } = render(); // Click the Import button (Upload icon in the header) const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); expect(importBtn).toBeTruthy(); await user.click(importBtn!); // Import modal title appears expect(await screen.findByText('Import Packing List')).toBeInTheDocument(); // Cancel closes modal await user.click(screen.getByText('Cancel')); await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument()); }); it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => { server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] }) ) ); const items = [buildPackingItem({ name: 'Laptop', category: 'Electronics' })]; render(); // Bags button/sidebar appears when bag tracking is enabled await waitFor(() => { const bagsEls = screen.getAllByText('Bags'); expect(bagsEls.length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-035: category rename via context menu calls PUT', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 90, name: 'Shirt', category: 'Clothing' }); let putBody: Record | null = null; server.use( http.put('/api/trips/1/packing/90', async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: 90, name: 'Shirt', category: 'Apparel' }) }); }) ); const { container } = render(); // Open the category context menu const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); // Click "Rename" in the menu await user.click(await screen.findByText('Rename')); // Category name input appears — type new name and save const catInput = screen.getByDisplayValue('Clothing'); await user.clear(catInput); await user.type(catInput, 'Apparel'); await user.keyboard('{Enter}'); await waitFor(() => expect(putBody).toMatchObject({ category: 'Apparel' })); }); it('FE-COMP-PACKING-036: assignee dropdown opens and lists members when clicked', async () => { server.use( http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [{ id: 2, username: 'alice', avatar_url: null }], current_user_id: 1, }) ) ); const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); const { container } = render(); // Wait for members to load, then click the UserPlus button await waitFor(() => { expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy(); }); const userPlusBtn = container.querySelector('svg.lucide-user-plus')?.closest('button'); await userEvent.setup().click(userPlusBtn!); // Member names appear in the dropdown await screen.findByText('owner'); expect(screen.getByText('alice')).toBeInTheDocument(); }); it('FE-COMP-PACKING-038: import modal - typing text updates import count', async () => { const user = userEvent.setup(); const { container } = render(); // Open import modal const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); await user.click(importBtn!); await screen.findByText('Import Packing List'); // Textarea is present const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); expect(textarea).toBeInTheDocument(); // "Load CSV/TXT" button is present inside the modal expect(screen.getByText('Load CSV/TXT')).toBeInTheDocument(); // Close by clicking backdrop (covers the onClick on the backdrop div) const modalTitle = screen.getByText('Import Packing List'); const modalContent = modalTitle.closest('div[style*="width: 420"]'); // Dismiss via Cancel button await user.click(screen.getByText('Cancel')); await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument()); }); it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => { const user = userEvent.setup(); server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) ) ); const items = [buildPackingItem({ name: 'Charger', category: 'Electronics' })]; const { container } = render(); // Wait for Bags button to appear await waitFor(() => { expect(screen.getAllByText('Bags').length).toBeGreaterThan(0); }); // Click the Bags button (xl:!hidden - visible in jsdom) const luggageBtn = container.querySelector('button svg.lucide-luggage')?.closest('button'); expect(luggageBtn).toBeTruthy(); await user.click(luggageBtn!); // Modal opens — "Main Bag" text appears (sidebar + modal — use getAllByText) await waitFor(() => { const bagTexts = screen.getAllByText('Main Bag'); expect(bagTexts.length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => { server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] }) ) ); const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; render(); // BagCard in sidebar shows the bag name (may appear once or more with modal) await waitFor(() => { expect(screen.getAllByText('Backpack').length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => { const user = userEvent.setup(); const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; const { container } = render(); // Save-as-template button uses FolderPlus icon and "Save as template" text const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button'); expect(folderPlusBtn).toBeTruthy(); // Click to show the name input await user.click(folderPlusBtn!); // Template name input appears expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument(); }); it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => { const user = userEvent.setup(); server.use( http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] }) ) ); render(); // Wait for template button const templateBtn = await screen.findByText('Apply template'); // Click to open dropdown await user.click(templateBtn); // Template name appears in the dropdown expect(await screen.findByText('Summer Packing')).toBeInTheDocument(); }); it('FE-COMP-PACKING-043: import modal textarea change updates text', async () => { const user = userEvent.setup(); const { container } = render(); // Open import modal const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); await user.click(importBtn!); await screen.findByText('Import Packing List'); // Type in textarea const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); await user.type(textarea, 'Clothing, T-Shirt'); // The textarea value reflects the typed text expect(textarea).toHaveValue('Clothing, T-Shirt'); // Import button count updates to 1 expect(screen.getByText(/Import 1/)).toBeInTheDocument(); }); it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => { server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] }) ) ); const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; const { container } = render(); // Wait for bag tracking to enable (weight input 'g' label appears) await waitFor(() => { expect(container.querySelector('input[placeholder="—"]')).toBeTruthy(); }); // The 'g' gram label appears next to the weight input expect(container.querySelector('span[style*="g"]')).toBeTruthy(); }); it('FE-COMP-PACKING-045: "Remove checked" button appears when checked items exist', async () => { const user = userEvent.setup(); const items = [ buildPackingItem({ name: 'Done1', checked: 1, category: 'Test' }), buildPackingItem({ name: 'Done2', checked: 1, category: 'Test' }), ]; server.use( http.delete('/api/trips/1/packing/:itemId', () => HttpResponse.json({ success: true })) ); // Mock window.confirm to return true vi.spyOn(window, 'confirm').mockReturnValue(true); render(); // The "Remove N checked" button should be visible (two spans exist - one sm:hidden, one hidden sm:inline) const removeBtns = screen.getAllByText(/Remove 2/); expect(removeBtns.length).toBeGreaterThan(0); // Click the parent button (either span's closest button) const removeBtn = removeBtns[0].closest('button')!; expect(removeBtn).toBeTruthy(); await user.click(removeBtn); // confirm was called expect(window.confirm).toHaveBeenCalled(); vi.restoreAllMocks(); }); it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => { const user = userEvent.setup(); let savedTemplateName = ''; server.use( http.post('/api/trips/1/packing/save-as-template', async ({ request }) => { const body = await request.json() as Record; savedTemplateName = String(body.name); return HttpResponse.json({ success: true }); }), http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [] }) ) ); const items = [buildPackingItem({ name: 'Item', category: 'Test' })]; const { container } = render(); // Click the FolderPlus "Save as template" button const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button'); await user.click(folderPlusBtn!); // Type template name const nameInput = await screen.findByPlaceholderText('Template name'); await user.type(nameInput, 'My Template'); await user.keyboard('{Enter}'); await waitFor(() => expect(savedTemplateName).toBe('My Template')); }); it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => { const user = userEvent.setup(); server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] }) ) ); const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; const { container } = render(); // Wait for bag tracking to enable (Package icon button in item row) await waitFor(() => { expect(container.querySelector('svg.lucide-package')).toBeTruthy(); }); // Click the bag button (Package icon) to open bag picker const packageBtn = container.querySelector('svg.lucide-package')?.closest('button'); expect(packageBtn).toBeTruthy(); await user.click(packageBtn!); // Bag picker dropdown shows the bag name (may also appear in sidebar) await waitFor(() => { expect(screen.getAllByText('Carry-on').length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => { const user = userEvent.setup(); server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) ) ); const items = [buildPackingItem({ name: 'Jacket', category: 'Clothing' })]; const { container } = render(); // Wait for Bags button await waitFor(() => { expect(screen.getAllByText('Bags').length).toBeGreaterThan(0); }); // Open bag modal const luggageBtn = container.querySelector('button svg.lucide-luggage')?.closest('button'); await user.click(luggageBtn!); // Wait for modal to show ("Add bag" button appears — may be in both sidebar and modal) await waitFor(() => { expect(screen.getAllByText('Add bag').length).toBeGreaterThan(0); }); // Click the last "Add bag" (in the modal) const addBagBtns = screen.getAllByText('Add bag'); await user.click(addBagBtns[addBagBtns.length - 1]); // Add bag name input appears (may exist in both sidebar and modal) await waitFor(() => { const bagInputs = screen.queryAllByPlaceholderText('Bag name...'); expect(bagInputs.length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-049: weight input change with bag tracking enabled calls PUT', async () => { const user = userEvent.setup(); let putBody: Record | null = null; const itemId = 120; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] }) ), http.put(`/api/trips/1/packing/${itemId}`, async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: itemId }) }); }) ); const items = [buildPackingItem({ id: itemId, name: 'Camera', category: 'Electronics' })]; const { container } = render(); // Wait for weight input to appear (bag tracking enabled) await waitFor(() => { expect(container.querySelector('input[placeholder="—"]')).toBeTruthy(); }); // Change the weight input value const weightInput = container.querySelector('input[placeholder="—"]') as HTMLInputElement; await user.clear(weightInput); await user.type(weightInput, '500'); await user.tab(); // blur to trigger change await waitFor(() => { expect(putBody).toBeTruthy(); }); }); it('FE-COMP-PACKING-050: ArtikelZeile category change picker opens on dot button click', async () => { const user = userEvent.setup(); const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); const item2 = buildPackingItem({ name: 'Passport', category: 'Documents' }); const { container } = render(); // The category change picker is triggered by a small dot button (no title) // It's rendered inside the action buttons group (sm:opacity-0 sm:group-hover:opacity-100) // In jsdom, CSS classes don't apply so the buttons are accessible // The dot button has a circle span inside with category color // Find all buttons with the 'Change Category' title const catChangeBtn = screen.getAllByTitle('Change Category'); expect(catChangeBtn.length).toBeGreaterThan(0); await user.click(catChangeBtn[0]); // Category picker shows both category names await waitFor(() => { expect(screen.getAllByText('Electronics').length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-051: bag assignment from picker calls PUT with bag_id', async () => { const user = userEvent.setup(); const itemId = 130; let putBody: Record | null = null; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] }) ), http.put(`/api/trips/1/packing/${itemId}`, async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: itemId }) }); }) ); const items = [buildPackingItem({ id: itemId, name: 'Shoes', category: 'Clothing' })]; const { container } = render(); // Wait for bag tracking to enable (Package icon appears) await waitFor(() => { expect(container.querySelector('svg.lucide-package')).toBeTruthy(); }); // Use fireEvent (no pointer events) to open the picker without triggering mouseLeave const packageBtn = container.querySelector('svg.lucide-package')?.closest('button'); fireEvent.click(packageBtn!); // Picker is open - find "Trolley" button inside the dropdown // The dropdown renders as an absolute positioned div inside the item row const trolleyBtn = await screen.findByRole('button', { name: /Trolley/ }); fireEvent.click(trolleyBtn); await waitFor(() => expect(putBody).toMatchObject({ bag_id: 7 })); }); it('FE-COMP-PACKING-052: category assignee chip renders when assignees exist', async () => { server.use( http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } }) ) ); const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); render(); // The assignee chip shows the first letter of username await waitFor(() => { // The chip shows 'A' (first letter of 'alice') const chips = document.querySelectorAll('.assignee-chip'); expect(chips.length).toBeGreaterThan(0); }); }); it('FE-COMP-PACKING-053: import modal closes when backdrop is clicked', async () => { const user = userEvent.setup(); const { container } = render(); // Open import modal const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); await user.click(importBtn!); await screen.findByText('Import Packing List'); // Click on the backdrop (the outer div that closes the modal) // The backdrop div has no specific identifier so we use the document.body portal const backdrop = document.querySelector('[style*="backdrop-filter"]') as HTMLElement; expect(backdrop).toBeTruthy(); fireEvent.click(backdrop!); await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument()); }); it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => { const itemId = 140; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] }) ), http.put(`/api/trips/1/packing/${itemId}`, async () => HttpResponse.json({ item: buildPackingItem({ id: itemId }) }) ) ); // Item that already has a bag assigned const items = [buildPackingItem({ id: itemId, name: 'Jacket', category: 'Clothing', bag_id: 5 } as any)]; const { container } = render(); // Wait for bag tracking to enable await waitFor(() => { // When bag_id is set, the bag button shows a colored dot (not Package icon) expect(container.querySelector('svg.lucide-package')).toBeFalsy(); }); // Verify the bags section renders in sidebar await screen.findByText('MyBag'); }); it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => { const user = userEvent.setup(); server.use( http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] }) ) ); render(); // Wait for and click template button const templateBtn = await screen.findByText('Apply template'); await user.click(templateBtn); // Template name appears in dropdown expect(await screen.findByText('Weekend Pack')).toBeInTheDocument(); // Item count appears too expect(screen.getByText('8 items')).toBeInTheDocument(); }); it('FE-COMP-PACKING-037: delete category via context menu calls DELETE for all items', async () => { const user = userEvent.setup(); const item1 = buildPackingItem({ id: 100, name: 'Rope', category: 'Gear' }); const item2 = buildPackingItem({ id: 101, name: 'Map', category: 'Gear' }); const deletedIds: number[] = []; server.use( http.delete('/api/trips/1/packing/:itemId', ({ params }) => { deletedIds.push(Number(params.itemId)); return HttpResponse.json({ success: true }); }) ); const { container } = render(); // Open context menu and click Delete Category const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); await user.click(moreBtn!); await user.click(await screen.findByText('Delete Category')); await waitFor(() => { expect(deletedIds).toContain(100); expect(deletedIds).toContain(101); }); }); it('FE-COMP-PACKING-056: pressing Enter in quantity input commits value', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 71, name: 'Socks', quantity: 3, category: 'Clothing' }); let putBody: Record | null = null; server.use( http.put('/api/trips/1/packing/71', async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: 71, quantity: 7 }) }); }) ); render(); const qtyInput = screen.getByDisplayValue('3'); await user.clear(qtyInput); await user.type(qtyInput, '7'); await user.keyboard('{Enter}'); await waitFor(() => expect(putBody).toMatchObject({ quantity: 7 })); }); it('FE-COMP-PACKING-057: clicking unchecked item name enters inline edit mode', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 73, name: 'Jacket', checked: 0, category: 'Clothing' }); render(); // Click the item name span (not the Rename button — the name span itself) const nameSpan = screen.getByText('Jacket'); await user.click(nameSpan); // An edit input should appear with the item's name pre-filled await waitFor(() => { const input = screen.getByDisplayValue('Jacket'); expect(input.tagName).toBe('INPUT'); }); }); it('FE-COMP-PACKING-058: selecting a different category in picker calls PUT with new category', async () => { const itemA = buildPackingItem({ id: 74, name: 'Camera', category: 'Electronics' }); const itemB = buildPackingItem({ id: 75, name: 'Passport', category: 'Documents' }); let putBody: Record | null = null; server.use( http.put('/api/trips/1/packing/74', async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ item: buildPackingItem({ id: 74, category: 'Documents' }) }); }) ); render(); // Use fireEvent (no pointer events) to open the category picker — avoids mouseLeave closing picker const catChangeBtns = screen.getAllByTitle('Change Category'); fireEvent.click(catChangeBtns[0]); // Picker shows available categories — find and click the 'Documents' button (role=button, text=Documents) const docBtn = await screen.findByRole('button', { name: 'Documents' }); fireEvent.click(docBtn); await waitFor(() => expect(putBody).toMatchObject({ category: 'Documents' })); }); it('FE-COMP-PACKING-059: clicking member in UserPlus dropdown calls setCategoryAssignees', async () => { let assignBody: Record | null = null; server.use( http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [{ id: 2, username: 'alice', avatar_url: null }], current_user_id: 1, }) ), http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => { assignBody = await request.json() as Record; return HttpResponse.json({ assignees: [{ user_id: 2, username: 'alice', avatar: null }] }); }) ); const item = buildPackingItem({ name: 'Tripod', category: 'Electronics' }); const { container } = render(); // Wait for members to load await waitFor(() => expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy()); // Click UserPlus to open assignee dropdown const userPlusBtn = container.querySelector('svg.lucide-user-plus')?.closest('button'); await userEvent.setup().click(userPlusBtn!); // Click member 'alice' in dropdown const aliceBtn = await screen.findByRole('button', { name: /alice/i }); await userEvent.setup().click(aliceBtn); await waitFor(() => expect(assignBody).toMatchObject({ user_ids: [2] })); }); it('FE-COMP-PACKING-060: clicking assignee chip removes assignee via setCategoryAssignees', async () => { let putBody: Record | null = null; server.use( http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } }) ), http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [{ id: 2, username: 'alice', avatar_url: null }], current_user_id: 1, }) ), http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ assignees: [] }); }) ); const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); render(); // Wait for the assignee chip to appear await waitFor(() => expect(document.querySelectorAll('.assignee-chip').length).toBeGreaterThan(0)); // Click the chip wrapper div to remove the assignee const chip = document.querySelector('.assignee-chip')!.parentElement!; fireEvent.click(chip); // setCategoryAssignees called with empty user_ids (removing alice) await waitFor(() => expect(putBody).toMatchObject({ user_ids: [] })); }); it('FE-COMP-PACKING-061: applying a template calls applyTemplate API', async () => { const user = userEvent.setup(); let applyCalled = false; server.use( http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] }) ), http.post('/api/trips/1/packing/apply-template/5', () => { applyCalled = true; return HttpResponse.json({ count: 12 }); }) ); // jsdom window.location.reload is not configurable; it just emits a "not implemented" warning render(); // Wait for template button and open dropdown const templateBtn = await screen.findByText('Apply template'); await user.click(templateBtn); // Click the template in the dropdown const tmplBtn = await screen.findByText('Beach Trip'); await user.click(tmplBtn); await waitFor(() => expect(applyCalled).toBe(true)); }); it('FE-COMP-PACKING-062: handleBulkImport calls import API and closes modal', async () => { const user = userEvent.setup(); let importBody: Record | null = null; server.use( http.post('/api/trips/1/packing/import', async ({ request }) => { importBody = await request.json() as Record; return HttpResponse.json({ count: 2 }); }) ); const { container } = render(); // Open import modal const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); await user.click(importBtn!); await screen.findByText('Import Packing List'); // Type two lines in the textarea const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); await user.type(textarea, 'Clothing, Shirt\nDocuments, Passport'); // Click Import button const importActionBtn = await screen.findByText(/Import 2/); await user.click(importActionBtn); await waitFor(() => expect(importBody).toBeTruthy()); }); it('FE-COMP-PACKING-063: creating a bag via sidebar form calls createBag API', async () => { const user = userEvent.setup(); let createBody: Record | null = null; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), // Start with one bag so the sidebar renders (sidebar requires bags.length > 0) http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) ), http.post('/api/trips/1/packing/bags', async ({ request }) => { createBody = await request.json() as Record; return HttpResponse.json({ bag: { id: 10, name: 'Hiking Pack', color: '#ec4899', weight_limit_grams: null, members: [] } }); }) ); const items = [buildPackingItem({ name: 'Boots', category: 'Clothing' })]; render(); // Wait for sidebar "Add bag" button (sidebar renders when bags.length > 0) await waitFor(() => expect(screen.getAllByText('Add bag').length).toBeGreaterThan(0)); const addBagBtns = screen.getAllByText('Add bag'); await user.click(addBagBtns[0]); // Bag name input appears const bagInput = await screen.findByPlaceholderText('Bag name...'); await user.type(bagInput, 'Hiking Pack'); await user.keyboard('{Enter}'); await waitFor(() => expect(createBody).toMatchObject({ name: 'Hiking Pack' })); }); it('FE-COMP-PACKING-064: deleting a bag from sidebar calls deleteBag API', async () => { const user = userEvent.setup(); let deleteCalled = false; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) ), http.delete('/api/trips/1/packing/bags/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }) ); const items = [buildPackingItem({ name: 'Shirt', category: 'Clothing' })]; const { container } = render(); // Wait for bag to appear in sidebar await waitFor(() => expect(screen.getAllByText('Old Bag').length).toBeGreaterThan(0)); // Click the X (delete) button on the BagCard in the sidebar // The X button is in BagCard: const xBtns = container.querySelectorAll('svg.lucide-x'); expect(xBtns.length).toBeGreaterThan(0); await user.click(xBtns[0].closest('button')!); await waitFor(() => expect(deleteCalled).toBe(true)); }); it('FE-COMP-PACKING-065: clicking bag name in sidebar enters edit mode and saves', async () => { const user = userEvent.setup(); let updateBody: Record | null = null; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] }) ), http.put('/api/trips/1/packing/bags/11', async ({ request }) => { updateBody = await request.json() as Record; return HttpResponse.json({ bag: { id: 11, name: 'Luggage', color: '#10b981', weight_limit_grams: null, members: [] } }); }) ); const items = [buildPackingItem({ name: 'Shoes', category: 'Clothing' })]; render(); // Wait for bag name in sidebar await waitFor(() => expect(screen.getAllByText('Carry-on').length).toBeGreaterThan(0)); // Click the bag name span to enter edit mode const bagNameSpans = screen.getAllByText('Carry-on'); await user.click(bagNameSpans[0]); // An edit input should appear const bagNameInput = await screen.findByDisplayValue('Carry-on'); await user.clear(bagNameInput); await user.type(bagNameInput, 'Luggage'); await user.keyboard('{Enter}'); await waitFor(() => expect(updateBody).toMatchObject({ name: 'Luggage' })); }); it('FE-COMP-PACKING-066: BagCard Plus button opens user picker with trip members', async () => { const user = userEvent.setup(); server.use( http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [{ id: 2, username: 'bob', avatar_url: null }], current_user_id: 1, }) ), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] }) ) ); const items = [buildPackingItem({ name: 'Camera', category: 'Electronics' })]; const { container } = render(); // Wait for the BagCard to render in the sidebar await waitFor(() => { expect(screen.getAllByText('Day Pack').length).toBeGreaterThan(0); }); // Wait for tripMembers to load — UserPlus icon appears in category header when members exist await waitFor(() => { expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy(); }); // Find BagCard Plus button by navigating from the bag name span: // bag name → header row
→ outer BagCard
→ querySelector for dashed button const bagNameEl = screen.getAllByText('Day Pack')[0]; const bagCardOuter = bagNameEl.parentElement!.parentElement!; const bagCardPlusBtn = bagCardOuter.querySelector('button[style*="dashed"]') as HTMLElement; expect(bagCardPlusBtn).toBeTruthy(); await user.click(bagCardPlusBtn); // User picker dropdown appears with member names (tripMembers already loaded) await screen.findByText('bob'); expect(screen.getByText('owner')).toBeInTheDocument(); }); it('FE-COMP-PACKING-067: BagCard user picker member click calls setBagMembers', async () => { let membersBody: Record | null = null; server.use( http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: { id: 1, username: 'owner', avatar_url: null }, members: [{ id: 3, username: 'carol', avatar_url: null }], current_user_id: 1, }) ), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] }) ), http.put('/api/trips/1/packing/bags/13/members', async ({ request }) => { membersBody = await request.json() as Record; return HttpResponse.json({ members: [{ user_id: 3, username: 'carol', avatar: null }] }); }) ); const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; const { container } = render(); // Wait for the BagCard to render and tripMembers to load await waitFor(() => { expect(screen.getAllByText('Weekend Bag').length).toBeGreaterThan(0); }); await waitFor(() => { expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy(); }); // Find BagCard Plus button within the BagCard's DOM subtree: // bag name → header row
→ outer BagCard
→ find dashed button const bagNameEl = screen.getAllByText('Weekend Bag')[0]; const bagCardOuter = bagNameEl.parentElement!.parentElement!; const bagCardPlusBtn = bagCardOuter.querySelector('button[style*="dashed"]') as HTMLElement; expect(bagCardPlusBtn).toBeTruthy(); fireEvent.click(bagCardPlusBtn); // Click 'carol' in the picker (accessible name: "C carol" from avatar initial + username) const carolBtn = await screen.findByText('carol'); fireEvent.click(carolBtn.closest('button')!); await waitFor(() => expect(membersBody).toMatchObject({ user_ids: [3] })); }); it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => { let createBody: Record | null = null; server.use( http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })), http.post('/api/trips/1/packing/bags', async ({ request }) => { createBody = await request.json() as Record; return HttpResponse.json({ bag: { id: 20, name: 'New Bag', color: '#6366f1', weight_limit_grams: null, members: [] } }); }), http.put('/api/trips/1/packing/150', async () => HttpResponse.json({ item: buildPackingItem({ id: 150 }) }) ) ); const items = [buildPackingItem({ id: 150, name: 'Sunglasses', category: 'Accessories' })]; const { container } = render(); // Wait for Package icon (bag button in item row) await waitFor(() => expect(container.querySelector('svg.lucide-package')).toBeTruthy()); // Use fireEvent to open picker (avoids mouseLeave pointer events) const packageBtn = container.querySelector('svg.lucide-package')?.closest('button'); fireEvent.click(packageBtn!); // Click "Add bag" inside picker to show inline create const addBagInPickerBtns = await screen.findAllByText('Add bag'); fireEvent.click(addBagInPickerBtns[addBagInPickerBtns.length - 1]); // Inline input appears in picker const inlineInput = await screen.findByPlaceholderText('Bag name...'); fireEvent.change(inlineInput, { target: { value: 'New Bag' } }); fireEvent.keyDown(inlineInput, { key: 'Enter' }); await waitFor(() => expect(createBody).toMatchObject({ name: 'New Bag' })); }); it('FE-COMP-PACKING-069: Load CSV/TXT button clicks the hidden file input', async () => { const user = userEvent.setup(); const { container } = render(); // Open import modal const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); await user.click(importBtn!); await screen.findByText('Import Packing List'); // Spy on the hidden file input's click method const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; expect(fileInput).toBeTruthy(); const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); // Click the "Load CSV/TXT" button await user.click(screen.getByText('Load CSV/TXT')); expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); });