mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user