diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx index e65da098f..677f4240c 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx @@ -373,4 +373,40 @@ describe('JourneySettingsDialog', () => { expect(onClose).not.toHaveBeenCalled() expect(screen.getByRole('heading', { name: 'Journey Settings' })).toBeInTheDocument() }) + + it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => { + const { onRefresh, onSaved } = mountDialog() + + const sw = screen.getByRole('switch', { name: /GPX/i }) + expect(sw).toHaveAttribute('aria-checked', 'false') + + await userEvent.click(sw) + + await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: true })) + // Refresh, never onSaved: onSaved closes the dialog, which would destroy the + // switch as it is flipped and skip the unsaved-changes guard. + await waitFor(() => expect(onRefresh).toHaveBeenCalled()) + expect(onSaved).not.toHaveBeenCalled() + }) + + it('FE-JRN-SETTINGS-024: a journey that already draws its tracks switches them back off (#2194)', async () => { + // The column is INTEGER, so what arrives over the wire is 1, not true. + mountDialog(buildJourney({ show_trip_tracks: 1 })) + + const sw = screen.getByRole('switch', { name: /GPX/i }) + expect(sw).toHaveAttribute('aria-checked', 'true') + + await userEvent.click(sw) + await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false })) + }) + + it('FE-JRN-SETTINGS-025: a failed switch write says so and leaves the dialog open (#2194)', async () => { + updateJourney.mockRejectedValueOnce(new Error('nope')) + const { onClose } = mountDialog() + + await userEvent.click(screen.getByRole('switch', { name: /GPX/i })) + + await waitFor(() => expect(toastSpy).toHaveBeenCalled()) + expect(onClose).not.toHaveBeenCalled() + }) }) diff --git a/client/src/components/Packing/PackingListPanelBags.test.tsx b/client/src/components/Packing/PackingListPanelBags.test.tsx new file mode 100644 index 000000000..913161a02 --- /dev/null +++ b/client/src/components/Packing/PackingListPanelBags.test.tsx @@ -0,0 +1,91 @@ +// FE-COMP-BAGWEIGHT-001 to FE-COMP-BAGWEIGHT-006 + +/** + * What the bag panels actually print (#2191). + * + * The numbers used to be summed in the browser over the privacy-filtered item + * list, so a bag shared with someone whose personal items you cannot see read + * lighter than it was — and that figure is what gets measured against an + * airline's weight limit. The server now sends the real total; these tests pin + * that the panels show it, that the "no bag" pile and the grand total follow + * the same rule, and that offline they fall back to the honest partial sum + * rather than a frozen absolute one. + */ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '../../../tests/helpers/render' +import { BagSidebar } from './PackingListPanelBagSidebar' +import { BagModal } from './PackingListPanelBagModal' +import type { PackingState } from './usePackingListPanel' + +/** My own 300 g item; the buddy's 700 g personal item never reaches this client. */ +const ITEMS = [ + { id: 1, name: 'Stove', weight_grams: 300, quantity: 1, bag_id: 10, is_private: 0, owner_id: 1, checked: 0, category: 'Kitchen' }, + { id: 2, name: 'Sandwich', weight_grams: 150, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' }, +] + +function buildState(over: Partial = {}): PackingState { + return { + t: (k: string) => k, + bags: [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [], total_weight_grams: 1000 }], + items: ITEMS, + tripId: 1, + tripMembers: [], + canEdit: false, + currentUserId: 1, + unassignedWeightGrams: 500, + serverWeightsFresh: true, + handleDeleteBag: vi.fn(), + handleUpdateBag: vi.fn(), + handleSetBagMembers: vi.fn(), + handleCreateBag: vi.fn(), + setShowBagModal: vi.fn(), + showAddBag: false, + setShowAddBag: vi.fn(), + newBagName: '', + setNewBagName: vi.fn(), + ...over, + } as unknown as PackingState +} + +describe('bag weights on the packing panels (#2191)', () => { + it('FE-COMP-BAGWEIGHT-001: the sidebar shows the server total, not the sum of what you can see', () => { + // Locally visible: 300 g. Actually in the bag: 1000 g. + render() + expect(screen.getByText('1.0 kg')).toBeInTheDocument() + expect(screen.queryByText('300 g')).not.toBeInTheDocument() + }) + + it('FE-COMP-BAGWEIGHT-002: the grand total adds the bags and the unassigned pile', () => { + // 1000 g in the bag + 500 g loose. + const { container } = render() + const total = within(container).getByText('packing.totalWeight').parentElement as HTMLElement + expect(total).toHaveTextContent('1.5 kg') + }) + + it('FE-COMP-BAGWEIGHT-003: the modal follows the same rule as the sidebar', () => { + render() + expect(screen.getByText('1.0 kg')).toBeInTheDocument() + }) + + it('FE-COMP-BAGWEIGHT-004: offline it falls back to summing what is visible', () => { + // The server totals are frozen at the last online read and blind to the + // mutation queue, so an honest partial number beats a stale absolute one. + render() + expect(screen.getByText('300 g')).toBeInTheDocument() + expect(screen.queryByText('1.0 kg')).not.toBeInTheDocument() + }) + + it('FE-COMP-BAGWEIGHT-005: a bag from before the field falls back to the local sum', () => { + const bags = [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [] }] + render() + expect(screen.getByText('300 g')).toBeInTheDocument() + }) + + it('FE-COMP-BAGWEIGHT-006: the unassigned row appears for weight with no visible items', () => { + // Someone else's personal item is loose in the trip: the grand total counts + // it, so a row has to account for it or the total adds up to nothing. + render() + expect(screen.getByText('packing.noBag')).toBeInTheDocument() + expect(screen.getByText('400 g')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Packing/useBagTotalsPing.test.tsx b/client/src/components/Packing/useBagTotalsPing.test.tsx new file mode 100644 index 000000000..6ca171e96 --- /dev/null +++ b/client/src/components/Packing/useBagTotalsPing.test.tsx @@ -0,0 +1,121 @@ +// FE-COMP-BAGPING-001 to FE-COMP-BAGPING-006 + +/** + * The bag-totals ping listener (#2191). + * + * Bag weights are summed server-side across every member, so the event that + * says they moved cannot carry the new numbers without leaking what produced + * them. That makes every ping a refetch, and the server deliberately sends it + * to the whole room including the writer — so the coalescing and the in-flight + * guard here are what stop a busy trip turning one write into a storm of + * listBags requests on every connected client. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useBagTotalsPing } from './useBagTotalsPing' + +const listeners = new Set<(e: Record) => void>() + +vi.mock('../../api/websocket', () => ({ + addListener: (fn: (e: Record) => void) => { listeners.add(fn) }, + removeListener: (fn: (e: Record) => void) => { listeners.delete(fn) }, +})) + +/** Deliver an event the way the websocket layer would. */ +function emit(type: string): void { + for (const fn of [...listeners]) fn({ type }) +} + +/** Past the 250ms coalescing window. */ +async function flushCoalesce(): Promise { + await act(async () => { await vi.advanceTimersByTimeAsync(300) }) +} + +beforeEach(() => { + listeners.clear() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('useBagTotalsPing', () => { + it('FE-COMP-BAGPING-001: reloads once a ping arrives', async () => { + const reload = vi.fn(async () => {}) + renderHook(() => useBagTotalsPing(true, reload)) + + expect(reload).not.toHaveBeenCalled() + act(() => emit('packing:bag-totals')) + await flushCoalesce() + + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-COMP-BAGPING-002: folds a burst of pings into a single refetch', async () => { + // Clearing twelve checked items issues twelve deletes; an import writes a + // whole list. Each one pings the room. + const reload = vi.fn(async () => {}) + renderHook(() => useBagTotalsPing(true, reload)) + + act(() => { for (let i = 0; i < 12; i++) emit('packing:bag-totals') }) + await flushCoalesce() + + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-COMP-BAGPING-003: never overlaps two refetches, and serves a ping that arrived mid-flight', async () => { + let release: (() => void) | undefined + const reload = vi.fn(() => new Promise(resolve => { release = resolve })) + renderHook(() => useBagTotalsPing(true, reload)) + + act(() => emit('packing:bag-totals')) + await flushCoalesce() + expect(reload).toHaveBeenCalledTimes(1) + + // A second burst while the first request is still open must not start a + // parallel one... + act(() => emit('packing:bag-totals')) + await flushCoalesce() + expect(reload).toHaveBeenCalledTimes(1) + + // ...but it must not be dropped either: the totals moved again. + await act(async () => { release?.(); await Promise.resolve() }) + expect(reload).toHaveBeenCalledTimes(2) + }) + + it('FE-COMP-BAGPING-004: ignores every other event type', async () => { + const reload = vi.fn(async () => {}) + renderHook(() => useBagTotalsPing(true, reload)) + + act(() => { emit('packing:created'); emit('packing:updated'); emit('place:created') }) + await flushCoalesce() + + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-COMP-BAGPING-005: subscribes to nothing while bag tracking is off', async () => { + const reload = vi.fn(async () => {}) + renderHook(() => useBagTotalsPing(false, reload)) + + expect(listeners.size).toBe(0) + act(() => emit('packing:bag-totals')) + await flushCoalesce() + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-COMP-BAGPING-006: unsubscribes on unmount and does not reload afterwards', async () => { + const reload = vi.fn(async () => {}) + const { unmount } = renderHook(() => useBagTotalsPing(true, reload)) + expect(listeners.size).toBe(1) + + // A ping already collecting when the panel closes must not land on a + // component that is gone. + act(() => emit('packing:bag-totals')) + unmount() + await flushCoalesce() + + expect(listeners.size).toBe(0) + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/server/tests/unit/mcp/tools-packing-advanced.test.ts b/server/tests/unit/mcp/tools-packing-advanced.test.ts index 8fa1cf15d..a107e3b49 100644 --- a/server/tests/unit/mcp/tools-packing-advanced.test.ts +++ b/server/tests/unit/mcp/tools-packing-advanced.test.ts @@ -381,6 +381,26 @@ describe('Tool: set_packing_category_assignees', () => { // --------------------------------------------------------------------------- describe('Tool: apply_packing_template', () => { + it('applies a template and pings the bag weights once (#2191)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const template = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Hiking', ?)").run(user.id); + const category = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Gear', 0)") + .run(template.lastInsertRowid); + testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Boots', 0)") + .run(category.lastInsertRowid); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'apply_packing_template', + arguments: { tripId: trip.id, templateId: Number(template.lastInsertRowid) }, + }); + expect(result.isError).toBeFalsy(); + // Applied items can carry weights, so the room has to re-read the totals. + expect(broadcastMock).toHaveBeenCalledWith(String(trip.id), 'packing:bag-totals', {}, undefined); + }); + }); + it('returns error for non-existent template', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/packing/packing.rpc.test.ts b/server/tests/unit/packing/packing.rpc.test.ts index f94b7d3fa..5630cc150 100644 --- a/server/tests/unit/packing/packing.rpc.test.ts +++ b/server/tests/unit/packing/packing.rpc.test.ts @@ -144,6 +144,17 @@ describe('PackingRpc through the router', () => { } }); + it('PACKING-RPC-007f deleting a bag also pings the weights (#2191)', async () => { + // packing_items.bag_id is ON DELETE SET NULL, so the bag's contents land in + // the unassigned pile: both the bag figure and the loose figure move. + const f = build(); + await f.host('db:write:packing').dispatch(req('packing.deleteBag', { tripId: 1, bagId: 80 }), 42); + expect(fanout(f.realtime)).toEqual([ + ['packing:bag-deleted', undefined], + ['packing:bag-totals', undefined], + ]); + }); + it('PACKING-RPC-007e an invalid item payload is BAD_PARAMS on create and update alike', async () => { const f = build(); const host = f.host('db:write:packing');