feat(planner): add AbortController to suggestion fetch $effect
Cancels the inflight request when activePickerDate changes or picker closes, preventing stale responses from overwriting suggestions. Adds page.test.ts covering fetch trigger, suggestion rendering, and AbortSignal presence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,12 +106,14 @@
|
|||||||
isLoadingSuggestions = false;
|
isLoadingSuggestions = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
isLoadingSuggestions = true;
|
isLoadingSuggestions = true;
|
||||||
fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`)
|
fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { suggestions = d.suggestions ?? []; })
|
.then((d) => { suggestions = d.suggestions ?? []; })
|
||||||
.catch(() => { suggestions = []; })
|
.catch((e) => { if (e.name !== 'AbortError') suggestions = []; })
|
||||||
.finally(() => { isLoadingSuggestions = false; });
|
.finally(() => { isLoadingSuggestions = false; });
|
||||||
|
return () => controller.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSelectDay(day: string) {
|
function handleSelectDay(day: string) {
|
||||||
|
|||||||
84
frontend/src/routes/(app)/planner/page.test.ts
Normal file
84
frontend/src/routes/(app)/planner/page.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: () => () => ({ destroy: () => {} })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001';
|
||||||
|
// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday)
|
||||||
|
const DATE = '2025-01-06'; // Monday, January 6 2025
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] },
|
||||||
|
varietyScore: null,
|
||||||
|
weekStart: DATE,
|
||||||
|
recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }],
|
||||||
|
benutzer: { rolle: 'planer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSuggestions = [
|
||||||
|
{
|
||||||
|
recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 },
|
||||||
|
scoreDelta: 1.5,
|
||||||
|
hasConflict: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('+page.svelte — $effect suggestion fetch', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls fetch when picker opens with correct planId and date', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ suggestions: mockSuggestions })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
|
||||||
|
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows suggestions in RecipePicker after fetch resolves', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ suggestions: mockSuggestions })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Lachsfilet')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve({ suggestions: [] })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
const fetchOptions = (fetch as any).mock.calls[0][1];
|
||||||
|
expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user