test: cover the new code paths SonarCloud flagged as unmeasured

The quality gate came in at 79.5% coverage on new code against a floor of 80%.
The gaps were real ones rather than a threshold quibble: three pieces of new
behaviour had no test at all.

- `useBagTotalsPing` was a new file with no test, and it holds the logic that
  stops one write turning into a storm of listBags requests on every connected
  client: the coalescing window, the in-flight guard, and the ping that arrives
  mid-flight and must still be served.

- The Journey Settings tracks switch had no test, including the part the review
  caught: that it refreshes rather than calling onSaved, which would close the
  dialog and drop unsaved edits.

- Neither bag panel had ever been rendered in a test. They now pin what the
  panels actually print: the server total rather than the sum of what the
  viewer can see, the grand total following the same rule as the rows above it,
  and the offline fallback.

Also covers the two ping call sites that were still unmeasured, the MCP
apply-template path and the plugin RPC bag delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
This commit is contained in:
Claude
2026-09-02 13:55:48 +00:00
committed by Maurice
parent 700918d533
commit 17668e5ff2
5 changed files with 279 additions and 0 deletions
@@ -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> = {}): 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(<BagSidebar {...buildState()} />)
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(<BagSidebar {...buildState()} />)
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(<BagModal {...buildState()} />)
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(<BagSidebar {...buildState({ serverWeightsFresh: false })} />)
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(<BagSidebar {...buildState({ bags: bags as never, unassignedWeightGrams: null })} />)
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(<BagSidebar {...buildState({ items: [ITEMS[0]] as never, unassignedWeightGrams: 400 })} />)
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
expect(screen.getByText('400 g')).toBeInTheDocument()
})
})
@@ -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<string, unknown>) => void>()
vi.mock('../../api/websocket', () => ({
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => 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<void> {
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<void>(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()
})
})