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

This commit is contained in:
Maurice
2026-11-08 19:44:50 +01:00
parent 6cc3d235b1
commit b529cf4e57
8 changed files with 36 additions and 38 deletions
+1 -1
View File
@@ -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,
@@ -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(<CollectionsWidget onOpen={() => {}} />);
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
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 () => {
@@ -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);
});
@@ -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<string, number> } | 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<string | null>(null);
const inFlight = useRef<AbortController | null>(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<string, number> = { [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);
@@ -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<string | null>(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<string | null>(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 (
<NumericInput
value={draft}
value={shown}
readOnly={readOnly}
onFocus={() => {
editing.current = true;
}}
onFocus={() => setDraft(shown)}
onValueChange={setDraft}
onBlur={commit}
onKeyDown={(e) => {
@@ -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(<SpotlightSkeleton />)
expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3)
@@ -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]);
@@ -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([]);
});