From b529cf4e576da6badf84aebe3638277f79bf9c0b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 19:44:50 +0100 Subject: [PATCH] fix: the checks the layout and weight fixes broke: a quote in a test name, state set inside effects, the misused-promise baseline, and tests that render the converter before settings load --- client/scripts/eslint-baseline.json | 2 +- .../Dashboard/CollectionsWidget.test.tsx | 10 +++--- .../Dashboard/useCurrencyConverter.test.ts | 12 +------ .../Dashboard/useCurrencyConverter.ts | 11 ++++--- .../Packing/PackingListPanelWeightInput.tsx | 33 ++++++++++--------- .../src/components/shared/Skeleton.test.tsx | 2 +- .../src/pages/DashboardPage.desktop.test.tsx | 2 ++ .../mobile/dashboard/MDashWidgets.test.tsx | 2 ++ 8 files changed, 36 insertions(+), 38 deletions(-) diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 94c8138df..7c8eef181 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,7 +1,7 @@ { "src": { "@typescript-eslint/no-non-null-assertion": 375, - "@typescript-eslint/no-misused-promises": 845, + "@typescript-eslint/no-misused-promises": 843, "no-console": 88, "complexity": 218, "@typescript-eslint/no-explicit-any": 144, diff --git a/client/src/components/Dashboard/CollectionsWidget.test.tsx b/client/src/components/Dashboard/CollectionsWidget.test.tsx index cb492c122..1b036c7cc 100644 --- a/client/src/components/Dashboard/CollectionsWidget.test.tsx +++ b/client/src/components/Dashboard/CollectionsWidget.test.tsx @@ -77,11 +77,13 @@ describe('CollectionsWidget', () => { expect(await screen.findByText('No saved places yet')).toBeInTheDocument(); }); - it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => { - render( {}} />); + it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => { + const { container } = render( {}} />); - expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument(); - await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument()); + // Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place. + expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true'); + await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull()); + expect(screen.getByText('No saved places yet')).toBeInTheDocument(); }); it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => { diff --git a/client/src/components/Dashboard/useCurrencyConverter.test.ts b/client/src/components/Dashboard/useCurrencyConverter.test.ts index a18c2163a..b867b67f9 100644 --- a/client/src/components/Dashboard/useCurrencyConverter.test.ts +++ b/client/src/components/Dashboard/useCurrencyConverter.test.ts @@ -147,13 +147,8 @@ describe('useCurrencyConverter', () => { }); it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => { - let resolveJpy: (body: unknown) => void = () => {}; fetchMock.mockImplementation((url: string) => - url.endsWith('base=JPY') - ? new Promise((resolve) => { - resolveJpy = (body) => resolve({ json: () => Promise.resolve(body) } as Response); - }) - : respond([{ quote: 'USD', rate: 1.1 }]) + respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }]) ); const { result } = renderHook(() => useCurrencyConverter()); await waitFor(() => expect(result.current.rate).toBe(1.1)); @@ -163,11 +158,6 @@ describe('useCurrencyConverter', () => { expect(result.current.rate).toBeNull(); expect(result.current.ratePending).toBe(true); - await act(async () => { - resolveJpy([{ quote: 'EUR', rate: 0.0056 }]); - await Promise.resolve(); - await Promise.resolve(); - }); await waitFor(() => expect(result.current.rate).toBe(0.0056)); expect(result.current.ratePending).toBe(false); }); diff --git a/client/src/components/Dashboard/useCurrencyConverter.ts b/client/src/components/Dashboard/useCurrencyConverter.ts index ec41f13b4..7444238f3 100644 --- a/client/src/components/Dashboard/useCurrencyConverter.ts +++ b/client/src/components/Dashboard/useCurrencyConverter.ts @@ -27,14 +27,14 @@ export function useCurrencyConverter() { // the new base's rates arrive, and the old map priced the new pair wrongly // (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009). const [rates, setRates] = useState<{ base: string; map: Record } | null>(null); - const [failed, setFailed] = useState(false); + // The base whose rates could not be had, so a failure belongs to its pair only. + const [failedBase, setFailedBase] = useState(null); const inFlight = useRef(null); const fetchRates = useCallback(() => { // Not before the user's pair is known: the defaults' rates were fetched and // shown first, then replaced by the saved pair (#3009). if (!isLoaded) return; - setFailed(false); inFlight.current?.abort(); const controller = new AbortController(); inFlight.current = controller; @@ -45,19 +45,20 @@ export function useCurrencyConverter() { if (signal.aborted) return; if (!Array.isArray(d)) { setRates(null); - setFailed(true); + setFailedBase(from); return; } // Frankfurter omits the base's own self-rate; seed it so `from` stays selectable. const map: Record = { [from]: 1 }; for (const r of d) map[r.quote] = r.rate; + setFailedBase(null); setRates({ base: from, map }); }) .catch(() => { // An abort is not a failure: it means nobody is waiting for the answer. if (signal.aborted) return; setRates(null); - setFailed(true); + setFailedBase(from); }); }, [from, isLoaded]); @@ -95,7 +96,7 @@ export function useCurrencyConverter() { // The pair is not known yet, or its rates are still on their way: neither is // "rate unavailable", and neither may show a rate. const pairPending = !isLoaded; - const ratePending = isLoaded && !current && !failed; + const ratePending = isLoaded && !current && failedBase !== from; const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null; const swap = () => { setFrom(to); diff --git a/client/src/components/Packing/PackingListPanelWeightInput.tsx b/client/src/components/Packing/PackingListPanelWeightInput.tsx index 1152488d3..f031ca306 100644 --- a/client/src/components/Packing/PackingListPanelWeightInput.tsx +++ b/client/src/components/Packing/PackingListPanelWeightInput.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useState, type CSSProperties } from 'react'; import { NumericInput } from '../shared/NumericInput'; interface Props { @@ -16,21 +16,24 @@ interface Props { */ export function WeightInput({ value, onSave, readOnly, width }: Props) { const stored = value == null ? '' : String(value); - const [draft, setDraft] = useState(stored); - const editing = useRef(false); - // A change from elsewhere (another member, the server's answer) shows only - // while the field is not being typed in. - useEffect(() => { - if (!editing.current) setDraft(stored); - }, [stored]); + // What is being typed, while the field has it; null shows the stored weight, + // so a change from elsewhere (another member) shows whenever nobody types. + const [draft, setDraft] = useState(null); + // A saved weight until the store has it, so the field does not fall back to + // the old number for the length of the request. + const [pending, setPending] = useState(null); + const shown = draft ?? pending ?? stored; const commit = () => { - editing.current = false; + if (draft === null) return; + const typed = draft; + setDraft(null); if (readOnly) return; - const grams = draft.trim() === '' ? null : Math.max(0, Number.parseInt(draft, 10) || 0); + const grams = typed.trim() === '' ? null : Math.max(0, Number.parseInt(typed, 10) || 0); const next = grams == null ? '' : String(grams); - setDraft(next); - if (next !== stored) void onSave(grams); + if (next === stored) return; + setPending(next); + void Promise.resolve(onSave(grams)).finally(() => setPending(null)); }; const style: CSSProperties = { @@ -47,11 +50,9 @@ export function WeightInput({ value, onSave, readOnly, width }: Props) { return ( { - editing.current = true; - }} + onFocus={() => setDraft(shown)} onValueChange={setDraft} onBlur={commit} onKeyDown={(e) => { diff --git a/client/src/components/shared/Skeleton.test.tsx b/client/src/components/shared/Skeleton.test.tsx index 822f05eab..7a9f207f6 100644 --- a/client/src/components/shared/Skeleton.test.tsx +++ b/client/src/components/shared/Skeleton.test.tsx @@ -44,7 +44,7 @@ describe('Skeleton', () => { }) describe('composed skeletons', () => { - it('FE-W4SKEL-006: SpotlightSkeleton takes the hero's own box and renders the backdrop plus two bars', () => { + it('FE-W4SKEL-006: SpotlightSkeleton takes the box of the hero and renders the backdrop plus two bars', () => { const { container } = render() expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3) diff --git a/client/src/pages/DashboardPage.desktop.test.tsx b/client/src/pages/DashboardPage.desktop.test.tsx index c0e0fe149..33b347987 100644 --- a/client/src/pages/DashboardPage.desktop.test.tsx +++ b/client/src/pages/DashboardPage.desktop.test.tsx @@ -67,6 +67,8 @@ beforeEach(() => { vi.setSystemTime(new Date('2026-07-05T12:00:00Z')); installMatchMedia(); resetAllStores(); + // The currency tool waits for the saved pair (#3009); the page renders with settings in place. + useSettingsStore.setState({ isLoaded: true }); seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); usePluginStore.setState({ plugins: [], loaded: true }); onlyTrips([TRIP]); diff --git a/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx b/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx index 6b8b97f24..723e74ad6 100644 --- a/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx +++ b/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx @@ -43,6 +43,8 @@ function enableCollectionsAddon(enabled: boolean) { beforeEach(() => { resetAllStores(); + // The converter waits for the saved pair (#3009); these widgets render with settings in place. + useSettingsStore.setState({ isLoaded: true }); fxHandler(); collectionsHandler([]); });