refactor(planner): remove C2 suggestions route, replace with callback-based DayMealCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,12 +16,14 @@
|
|||||||
slot,
|
slot,
|
||||||
isToday = false,
|
isToday = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
readonly = false
|
readonly = false,
|
||||||
|
onaddrecipe
|
||||||
}: {
|
}: {
|
||||||
slot: Slot;
|
slot: Slot;
|
||||||
isToday?: boolean;
|
isToday?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
onaddrecipe?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let metadata = $derived(
|
let metadata = $derived(
|
||||||
@@ -64,23 +66,27 @@
|
|||||||
>
|
>
|
||||||
Jetzt kochen
|
Jetzt kochen
|
||||||
</a>
|
</a>
|
||||||
<a
|
{#if onaddrecipe}
|
||||||
href="/planner/suggestions?day={slot.slotDate}"
|
<button
|
||||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
type="button"
|
||||||
>
|
onclick={onaddrecipe}
|
||||||
Tauschen
|
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||||
</a>
|
>
|
||||||
|
Tauschen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||||
{#if !readonly}
|
{#if !readonly && onaddrecipe}
|
||||||
<a
|
<button
|
||||||
href="/planner/suggestions?day={slot.slotDate}"
|
type="button"
|
||||||
|
onclick={onaddrecipe}
|
||||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||||
>
|
>
|
||||||
+ Gericht hinzufügen
|
+ Gericht hinzufügen
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
import DayMealCard from './DayMealCard.svelte';
|
import DayMealCard from './DayMealCard.svelte';
|
||||||
|
|
||||||
const slot = {
|
const slot = {
|
||||||
@@ -14,22 +15,29 @@ describe('DayMealCard', () => {
|
|||||||
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Cook now and Tauschen links when not readonly', () => {
|
it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => {
|
||||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe: vi.fn() } });
|
||||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||||
expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Tauschen link navigates to suggestions for the slot day', () => {
|
it('Tauschen button calls onaddrecipe when clicked', async () => {
|
||||||
|
const onaddrecipe = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Tauschen/i }));
|
||||||
|
expect(onaddrecipe).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Tauschen button when onaddrecipe not provided', () => {
|
||||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||||
const link = screen.getByRole('link', { name: /Tauschen/i });
|
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||||
expect(link.getAttribute('href')).toContain('2026-03-30');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides action links when readonly', () => {
|
it('hides action links when readonly', () => {
|
||||||
render(DayMealCard, { props: { slot, isToday: false, readonly: true } });
|
render(DayMealCard, { props: { slot, isToday: false, readonly: true, onaddrecipe: vi.fn() } });
|
||||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||||
expect(screen.queryByRole('link', { name: /Tauschen/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies today styling when isToday is true', () => {
|
it('applies today styling when isToday is true', () => {
|
||||||
@@ -55,9 +63,22 @@ describe('DayMealCard', () => {
|
|||||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty state shows add link with suggestions href', () => {
|
it('empty state shows add button when onaddrecipe provided', () => {
|
||||||
|
const onaddrecipe = vi.fn();
|
||||||
|
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } });
|
||||||
|
expect(screen.getByRole('button', { name: /Gericht hinzufügen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add button calls onaddrecipe when clicked', async () => {
|
||||||
|
const onaddrecipe = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Gericht hinzufügen/i }));
|
||||||
|
expect(onaddrecipe).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty state hides add button when onaddrecipe not provided', () => {
|
||||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||||
const link = screen.getByRole('link', { name: /Gericht hinzufügen/i });
|
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
|
||||||
expect(link.getAttribute('href')).toContain('2026-03-31');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
isToday={selectedDay === today}
|
isToday={selectedDay === today}
|
||||||
isSelected={true}
|
isSelected={true}
|
||||||
readonly={!isPlanner}
|
readonly={!isPlanner}
|
||||||
|
onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { apiClient } from '$lib/server/api';
|
|
||||||
import { getWeekStart } from '$lib/planner/week';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => {
|
|
||||||
const weekParam = url.searchParams.get('week');
|
|
||||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
|
||||||
const selectedDay = url.searchParams.get('day') ?? weekStart;
|
|
||||||
|
|
||||||
const api = apiClient(fetch);
|
|
||||||
|
|
||||||
// Load the week plan for context (week-so-far display)
|
|
||||||
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
|
||||||
params: { query: { weekStart } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (weekPlanError || !weekPlan?.id) {
|
|
||||||
return { weekPlan: null, suggestions: [], selectedDay, weekStart };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load variety-aware suggestions for the selected day
|
|
||||||
const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
|
||||||
params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by simulatedScore descending (highest = best variety fit)
|
|
||||||
const suggestions = (suggestionsData?.suggestions ?? []).sort(
|
|
||||||
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { weekPlan, suggestions, selectedDay, weekStart };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
pickSuggestion: async ({ fetch, request, locals }) => {
|
|
||||||
// Role guard: only planners may assign meals
|
|
||||||
if (locals.benutzer?.rolle !== 'planer') {
|
|
||||||
return { success: false, error: 'Keine Berechtigung.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
|
||||||
const planId = formData.get('planId') as string;
|
|
||||||
const recipeId = formData.get('recipeId') as string;
|
|
||||||
const slotDate = formData.get('slotDate') as string;
|
|
||||||
const weekStart = formData.get('weekStart') as string;
|
|
||||||
|
|
||||||
// Validate slotDate format
|
|
||||||
if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) {
|
|
||||||
return { success: false, error: 'Ungültiges Datum.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate planId is non-empty
|
|
||||||
if (!planId) {
|
|
||||||
return { success: false, error: 'Fehlende Plan-ID.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate recipeId is UUID-like format
|
|
||||||
if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) {
|
|
||||||
return { success: false, error: 'Ungültige Rezept-ID.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = apiClient(fetch);
|
|
||||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
|
||||||
params: { path: { id: planId } },
|
|
||||||
body: { slotDate, recipeId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect back to the planner after successful pick (spec: "returns to C1")
|
|
||||||
redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import SuggestionCard from '$lib/planner/SuggestionCard.svelte';
|
|
||||||
import SuggestionContextBanner from '$lib/planner/SuggestionContextBanner.svelte';
|
|
||||||
import { formatDayLabel } from '$lib/planner/week';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
let weekPlan = $derived(data.weekPlan);
|
|
||||||
let suggestions = $derived(data.suggestions ?? []);
|
|
||||||
let selectedDay = $derived(data.selectedDay);
|
|
||||||
let weekStart = $derived(data.weekStart);
|
|
||||||
|
|
||||||
// Add rank and derive reasoning from simulatedScore for display.
|
|
||||||
// TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it.
|
|
||||||
let rankedSuggestions = $derived(
|
|
||||||
suggestions.map((s: any, i: number) => ({
|
|
||||||
...s,
|
|
||||||
reasoningType: (s.simulatedScore ?? 0) >= 7.5 ? 'good' : 'warning',
|
|
||||||
reasoningLabel:
|
|
||||||
(s.simulatedScore ?? 0) >= 7.5
|
|
||||||
? 'Passt gut zur Woche'
|
|
||||||
: 'Wiederholung möglich'
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Gerichtsvorschläge — Mealplan</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<!-- Mobile layout: full-width list with context banner -->
|
|
||||||
<div class="flex h-full flex-col lg:hidden">
|
|
||||||
<!-- Mobile topbar -->
|
|
||||||
<header class="sticky top-0 z-10 flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
|
||||||
<a
|
|
||||||
href="/planner?week={weekStart}"
|
|
||||||
aria-label="Zurück zum Planer"
|
|
||||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</a>
|
|
||||||
<h1 class="font-[var(--font-display)] text-[18px] font-[300] text-[var(--color-text)]">
|
|
||||||
Vorschläge für {formatDayLabel(selectedDay)}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Context banner -->
|
|
||||||
<div class="px-4 pt-3">
|
|
||||||
<SuggestionContextBanner {selectedDay} {weekPlan} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Suggestion list -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-6">
|
|
||||||
{#if rankedSuggestions.length === 0}
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
|
||||||
Keine Vorschläge verfügbar.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Rezeptbibliothek durchsuchen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each rankedSuggestions as suggestion, i}
|
|
||||||
<SuggestionCard
|
|
||||||
{suggestion}
|
|
||||||
rank={i + 1}
|
|
||||||
planId={weekPlan?.id ?? ''}
|
|
||||||
slotDate={selectedDay}
|
|
||||||
{weekStart}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browse full library fallback -->
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Rezeptbibliothek durchsuchen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop: 2-panel layout -->
|
|
||||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
|
||||||
<!-- Topbar -->
|
|
||||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
|
||||||
<a
|
|
||||||
href="/planner?week={weekStart}"
|
|
||||||
aria-label="Zurück zum Planer"
|
|
||||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</a>
|
|
||||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
|
||||||
Vorschläge für {formatDayLabel(selectedDay)}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
|
||||||
<!-- Left context panel (280px) -->
|
|
||||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-5 overflow-y-auto">
|
|
||||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
||||||
Diese Woche bisher
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if weekPlan?.slots?.length}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{#each (weekPlan.slots ?? []).filter((s: any) => s.slotDate !== selectedDay) as slot}
|
|
||||||
<li class="flex items-baseline gap-2">
|
|
||||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
{formatDayLabel(slot.slotDate ?? '').split(',')[0]}
|
|
||||||
</span>
|
|
||||||
{#if slot.recipe}
|
|
||||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
|
||||||
{slot.recipe.name}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">— Nicht geplant</span>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
|
||||||
Noch keine Gerichte diese Woche geplant.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Filter reasons -->
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
||||||
Filterkriterien
|
|
||||||
</h3>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
· Keine Zutatenwiederholungen (3 Tage)
|
|
||||||
</li>
|
|
||||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
· Protein-Abwechslung beachten
|
|
||||||
</li>
|
|
||||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
· Aufwandsbalance
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browse library link in desktop panel footer -->
|
|
||||||
<div class="mt-auto pt-6">
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Bibliothek →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Right suggestions panel -->
|
|
||||||
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-6 py-5">
|
|
||||||
{#if rankedSuggestions.length === 0}
|
|
||||||
<div class="flex h-full flex-col items-center justify-center">
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
|
||||||
Keine Vorschläge verfügbar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each rankedSuggestions as suggestion, i}
|
|
||||||
<SuggestionCard
|
|
||||||
{suggestion}
|
|
||||||
rank={i + 1}
|
|
||||||
planId={weekPlan?.id ?? ''}
|
|
||||||
slotDate={selectedDay}
|
|
||||||
{weekStart}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Rezeptbibliothek durchsuchen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('$env/dynamic/private', () => ({
|
|
||||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
|
||||||
const mockPost = vi.fn();
|
|
||||||
vi.mock('$lib/server/api', () => ({
|
|
||||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('suggestions page — load', () => {
|
|
||||||
let load: any;
|
|
||||||
|
|
||||||
const mockSuggestions = {
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
|
||||||
simulatedScore: 9.2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
|
||||||
simulatedScore: 6.1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockWeekPlan = {
|
|
||||||
id: 'plan-1',
|
|
||||||
weekStart: '2026-03-30',
|
|
||||||
status: 'draft',
|
|
||||||
slots: [
|
|
||||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
vi.resetModules();
|
|
||||||
const mod = await import('./+page.server');
|
|
||||||
load = mod.load;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches suggestions for the given plan and day', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
|
|
||||||
params: expect.objectContaining({ path: { id: 'plan-1' } })
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns suggestions list sorted by simulatedScore descending', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone');
|
|
||||||
expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the selectedDay from URL params', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.selectedDay).toBe('2026-04-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty suggestions when API fails', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.suggestions).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns week plan slots for context display', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.weekPlan).toBeDefined();
|
|
||||||
expect(result.weekPlan.slots).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null weekPlan and empty suggestions when week plan not found', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.weekPlan).toBeNull();
|
|
||||||
expect(result.suggestions).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults day to weekStart when no day param provided', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.selectedDay).toBe('2026-03-30');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('suggestions page — pickSuggestion action', () => {
|
|
||||||
let actions: any;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
vi.resetModules();
|
|
||||||
const mod = await import('./+page.server');
|
|
||||||
actions = mod.actions;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds a slot to the week plan via POST and redirects to planner', async () => {
|
|
||||||
mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
try {
|
|
||||||
await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect.unreachable();
|
|
||||||
} catch (e: any) {
|
|
||||||
expect(e.status).toBe(303);
|
|
||||||
expect(e.location).toBe('/planner?week=2026-03-30');
|
|
||||||
}
|
|
||||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
|
|
||||||
params: { path: { id: 'plan-1' } },
|
|
||||||
body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' }
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error when planId is missing', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', '');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for invalid recipeId format', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', 'not-a-uuid');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error when API fails', async () => {
|
|
||||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns permission error for member role', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for invalid slotDate format', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', 'not-a-date');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user