Merge pull request 'feat: C3 — Variety review screen (Issue #28)' (#41) from feat/issue-28-variety-review into master
feat(variety): C3 — Variety review screen (Issue #28) (#41)
This commit was merged in pull request #41.
This commit is contained in:
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
easy,
|
||||||
|
medium,
|
||||||
|
hard
|
||||||
|
}: {
|
||||||
|
easy: number;
|
||||||
|
medium: number;
|
||||||
|
hard: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Labels below the bar -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Bar segments -->
|
||||||
|
<div class="flex h-[10px] overflow-hidden rounded-full">
|
||||||
|
{#if easy > 0}
|
||||||
|
<div
|
||||||
|
class="h-full bg-[var(--green)]"
|
||||||
|
style="flex: {easy}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if medium > 0}
|
||||||
|
<div
|
||||||
|
class="h-full bg-[var(--yellow)]"
|
||||||
|
style="flex: {medium}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if hard > 0}
|
||||||
|
<div
|
||||||
|
class="h-full bg-[var(--color-error)]"
|
||||||
|
style="flex: {hard}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Labels -->
|
||||||
|
<div class="flex gap-4">
|
||||||
|
{#if easy > 0}
|
||||||
|
<span
|
||||||
|
data-testid="effort-easy"
|
||||||
|
class="font-[var(--font-sans)] text-[12px] text-[var(--green-dark)]"
|
||||||
|
>
|
||||||
|
Einfach ×{easy}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if medium > 0}
|
||||||
|
<span
|
||||||
|
data-testid="effort-medium"
|
||||||
|
class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]"
|
||||||
|
>
|
||||||
|
Mittel ×{medium}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hard > 0}
|
||||||
|
<span
|
||||||
|
data-testid="effort-hard"
|
||||||
|
class="font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
|
||||||
|
>
|
||||||
|
Aufwändig ×{hard}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import EffortBar from './EffortBar.svelte';
|
||||||
|
|
||||||
|
describe('EffortBar', () => {
|
||||||
|
it('renders segment for easy effort', () => {
|
||||||
|
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||||
|
expect(screen.getByTestId('effort-easy').textContent).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders segment for medium effort', () => {
|
||||||
|
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||||
|
expect(screen.getByTestId('effort-medium').textContent).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders segment for hard effort', () => {
|
||||||
|
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||||
|
expect(screen.getByTestId('effort-hard').textContent).toContain('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides zero-count segments', () => {
|
||||||
|
render(EffortBar, { props: { easy: 7, medium: 0, hard: 0 } });
|
||||||
|
expect(screen.queryByTestId('effort-medium')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('effort-hard')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders label with ×N count', () => {
|
||||||
|
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||||
|
expect(screen.getByTestId('effort-easy').textContent).toContain('×3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no segments when all counts are zero', () => {
|
||||||
|
render(EffortBar, { props: { easy: 0, medium: 0, hard: 0 } });
|
||||||
|
expect(screen.queryByTestId('effort-easy')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('effort-medium')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('effort-hard')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/lib/planner/ScoreBreakdownList.svelte
Normal file
39
frontend/src/lib/planner/ScoreBreakdownList.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface SubScores {
|
||||||
|
proteinDiversity: number;
|
||||||
|
ingredientOverlap: number;
|
||||||
|
effortBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { subScores }: { subScores: SubScores } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-[var(--color-border)] rounded-[var(--radius-md)] border border-[var(--color-border)]">
|
||||||
|
<li
|
||||||
|
data-testid="sub-protein"
|
||||||
|
class="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Protein-Vielfalt</span>
|
||||||
|
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||||
|
{subScores.proteinDiversity}/10
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
data-testid="sub-ingredient"
|
||||||
|
class="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Zutaten-Überlappung</span>
|
||||||
|
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||||
|
{subScores.ingredientOverlap}/10
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
data-testid="sub-effort"
|
||||||
|
class="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Aufwandsbalance</span>
|
||||||
|
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||||
|
{subScores.effortBalance}/10
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
35
frontend/src/lib/planner/ScoreBreakdownList.test.ts
Normal file
35
frontend/src/lib/planner/ScoreBreakdownList.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import ScoreBreakdownList from './ScoreBreakdownList.svelte';
|
||||||
|
|
||||||
|
const subScores = {
|
||||||
|
proteinDiversity: 9,
|
||||||
|
ingredientOverlap: 7,
|
||||||
|
effortBalance: 8
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ScoreBreakdownList', () => {
|
||||||
|
it('renders protein diversity row', () => {
|
||||||
|
render(ScoreBreakdownList, { props: { subScores } });
|
||||||
|
expect(screen.getByTestId('sub-protein').textContent).toContain('9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ingredient overlap row', () => {
|
||||||
|
render(ScoreBreakdownList, { props: { subScores } });
|
||||||
|
expect(screen.getByTestId('sub-ingredient').textContent).toContain('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders effort balance row', () => {
|
||||||
|
render(ScoreBreakdownList, { props: { subScores } });
|
||||||
|
expect(screen.getByTestId('sub-effort').textContent).toContain('8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all rows with /10 suffix', () => {
|
||||||
|
render(ScoreBreakdownList, { props: { subScores } });
|
||||||
|
const items = screen.getAllByTestId(/^sub-/);
|
||||||
|
expect(items.length).toBe(3);
|
||||||
|
items.forEach((item) => {
|
||||||
|
expect(item.textContent).toContain('/10');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
frontend/src/lib/planner/VarietyScoreHero.svelte
Normal file
56
frontend/src/lib/planner/VarietyScoreHero.svelte
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
score
|
||||||
|
}: {
|
||||||
|
score: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let percentage = $derived(Math.round((score / 10) * 100));
|
||||||
|
|
||||||
|
let description = $derived(
|
||||||
|
score >= 9
|
||||||
|
? { label: 'Ausgezeichnet', colorClass: 'text-[var(--green-dark)]' }
|
||||||
|
: score >= 7
|
||||||
|
? { label: 'Gut', colorClass: 'text-[var(--color-text)]' }
|
||||||
|
: score >= 4
|
||||||
|
? { label: 'Verbesserbar', colorClass: 'text-[var(--yellow-text)]' }
|
||||||
|
: { label: 'Unzureichend', colorClass: 'text-[var(--color-error)]' }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<!-- Score number + out of 10 -->
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span
|
||||||
|
data-testid="score-value"
|
||||||
|
class="font-[var(--font-display)] text-[56px] font-[300] leading-none text-[var(--color-text)] lg:text-[72px]"
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="score-label"
|
||||||
|
class="font-[var(--font-sans)] text-[16px] text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
/ 10
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="score-description"
|
||||||
|
class="ml-1 font-[var(--font-sans)] text-[14px] font-medium {description.colorClass}"
|
||||||
|
>
|
||||||
|
{description.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="mt-3 h-[6px] w-[120px] overflow-hidden rounded-full bg-[var(--color-border)] lg:w-[200px]">
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={score}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={10}
|
||||||
|
aria-label="Abwechslungs-Score"
|
||||||
|
class="h-full rounded-full bg-[var(--yellow)] transition-all"
|
||||||
|
style="width: {percentage}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
74
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
74
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import VarietyScoreHero from './VarietyScoreHero.svelte';
|
||||||
|
|
||||||
|
describe('VarietyScoreHero', () => {
|
||||||
|
it('renders the score number', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 8.2 } });
|
||||||
|
expect(screen.getByTestId('score-value').textContent).toContain('8.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "out of 10" label', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 8.2 } });
|
||||||
|
expect(screen.getByTestId('score-label').textContent).toContain('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a progressbar with correct aria attributes', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 8.2 } });
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar.getAttribute('aria-valuenow')).toBe('8.2');
|
||||||
|
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
||||||
|
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Excellent variety" description for score >= 9', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 9.5 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Good variety" description for score 7-8.9', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 7.5 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Getting there" description for score 4-6.9', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 5.0 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Needs improvement" description for score < 4', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 2.1 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Unzureichend" for score = 0 (boundary)', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 0 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders score 0 in score-value for score = 0', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 0 } });
|
||||||
|
expect(screen.getByTestId('score-value').textContent).toContain('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 0-width progress bar for score = 0', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 0 } });
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar.getAttribute('aria-valuenow')).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Ausgezeichnet" for score = 10 (boundary)', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 10 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Verbesserbar" for score = 4 (boundary)', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 4 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Gut" for score = 7 (boundary)', () => {
|
||||||
|
render(VarietyScoreHero, { props: { score: 7 } });
|
||||||
|
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
|
||||||
|
});
|
||||||
|
});
|
||||||
22
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
22
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Warning {
|
||||||
|
title: string;
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { warnings }: { warnings: Warning[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each warnings as warning}
|
||||||
|
<div
|
||||||
|
data-testid="warning-card"
|
||||||
|
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
|
||||||
|
{warning.title}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||||
|
{warning.explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
32
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
32
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import VarietyWarningCards from './VarietyWarningCards.svelte';
|
||||||
|
|
||||||
|
const warnings = [
|
||||||
|
{ title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' },
|
||||||
|
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('VarietyWarningCards', () => {
|
||||||
|
it('renders one card per warning', () => {
|
||||||
|
render(VarietyWarningCards, { props: { warnings } });
|
||||||
|
const cards = screen.getAllByTestId('warning-card');
|
||||||
|
expect(cards.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning titles', () => {
|
||||||
|
render(VarietyWarningCards, { props: { warnings } });
|
||||||
|
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning explanations', () => {
|
||||||
|
render(VarietyWarningCards, { props: { warnings } });
|
||||||
|
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when warnings is empty', () => {
|
||||||
|
render(VarietyWarningCards, { props: { warnings: [] } });
|
||||||
|
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
frontend/src/lib/planner/variety.test.ts
Normal file
123
frontend/src/lib/planner/variety.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { computeSubScores, computeWarnings } from './variety';
|
||||||
|
|
||||||
|
describe('computeSubScores', () => {
|
||||||
|
it('returns proteinDiversity=10 when no protein repeats', () => {
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 2, hard: 1 });
|
||||||
|
expect(result.proteinDiversity).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces proteinDiversity by 2 per protein repeat', () => {
|
||||||
|
const tagRepeats = [
|
||||||
|
{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] },
|
||||||
|
{ tagType: 'protein', tagName: 'Beef', days: ['WED', 'THU'] }
|
||||||
|
];
|
||||||
|
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||||
|
// 2 protein repeat entries → 10 - 2*2 = 6
|
||||||
|
expect(result.proteinDiversity).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps proteinDiversity to minimum 0', () => {
|
||||||
|
const tagRepeats = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
tagType: 'protein', tagName: `P${i}`, days: ['MON', 'TUE']
|
||||||
|
}));
|
||||||
|
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||||
|
expect(result.proteinDiversity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ingredientOverlap=10 when no overlaps', () => {
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||||
|
expect(result.ingredientOverlap).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces ingredientOverlap by 1.5 per overlap (rounded)', () => {
|
||||||
|
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'TUE'] }];
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 });
|
||||||
|
// 1 overlap → 10 - 1*1.5 = 8.5 → round = 9 (Math.round rounds .5 up)
|
||||||
|
expect(result.ingredientOverlap).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps ingredientOverlap to minimum 0', () => {
|
||||||
|
const ingredientOverlaps = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
ingredientName: `Ing${i}`, days: ['MON', 'TUE']
|
||||||
|
}));
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 });
|
||||||
|
expect(result.ingredientOverlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns effortBalance=10 when no meals (total=0)', () => {
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||||
|
expect(result.effortBalance).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns effortBalance=10 when easy and hard are equal', () => {
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 3, medium: 0, hard: 3 });
|
||||||
|
// |3-3| = 0 → 10 - 0 = 10
|
||||||
|
expect(result.effortBalance).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces effortBalance by 1.5 per unit of easy-hard difference', () => {
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 0, hard: 0 });
|
||||||
|
// |4-0| = 4 → 10 - 4*1.5 = 4 → round(4) = 4
|
||||||
|
expect(result.effortBalance).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps effortBalance to minimum 0', () => {
|
||||||
|
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 10, medium: 0, hard: 0 });
|
||||||
|
// |10-0| = 10 → 10 - 10*1.5 = -5 → clamp to 0
|
||||||
|
expect(result.effortBalance).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-protein tag repeats for proteinDiversity', () => {
|
||||||
|
const tagRepeats = [{ tagType: 'category', tagName: 'Pasta', days: ['MON', 'TUE'] }];
|
||||||
|
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||||
|
expect(result.proteinDiversity).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeWarnings', () => {
|
||||||
|
it('returns empty array when no repeats or overlaps', () => {
|
||||||
|
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: [] });
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates warning for protein appearing on 2+ days', () => {
|
||||||
|
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }];
|
||||||
|
const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toContain('Chicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate warning for protein appearing on only 1 day', () => {
|
||||||
|
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON'] }];
|
||||||
|
const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] });
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates warning for ingredient overlap on 2+ days', () => {
|
||||||
|
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }];
|
||||||
|
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toContain('Rice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate warning for ingredient appearing on only 1 day', () => {
|
||||||
|
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON'] }];
|
||||||
|
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] });
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates warning for each duplicate recipe in plan', () => {
|
||||||
|
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: ['Pasta Bolognese', 'Risotto'] });
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].title).toContain('Pasta Bolognese');
|
||||||
|
expect(result[1].title).toContain('Risotto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines all warning types', () => {
|
||||||
|
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }];
|
||||||
|
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }];
|
||||||
|
const result = computeWarnings({ tagRepeats, ingredientOverlaps, duplicatesInPlan: ['Pasta'] });
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
frontend/src/lib/planner/variety.ts
Normal file
88
frontend/src/lib/planner/variety.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
interface TagRepeat {
|
||||||
|
tagType?: string;
|
||||||
|
tagName?: string;
|
||||||
|
days?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IngredientOverlap {
|
||||||
|
ingredientName?: string;
|
||||||
|
days?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubScoreInput {
|
||||||
|
tagRepeats: TagRepeat[];
|
||||||
|
ingredientOverlaps: IngredientOverlap[];
|
||||||
|
easy: number;
|
||||||
|
medium: number;
|
||||||
|
hard: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubScores {
|
||||||
|
proteinDiversity: number;
|
||||||
|
ingredientOverlap: number;
|
||||||
|
effortBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSubScores(input: SubScoreInput): SubScores {
|
||||||
|
const { tagRepeats, ingredientOverlaps, easy, medium, hard } = input;
|
||||||
|
|
||||||
|
const proteinRepeats = tagRepeats.filter((t) => t.tagType === 'protein').length;
|
||||||
|
const ingredientOverlapCount = ingredientOverlaps.length;
|
||||||
|
const total = easy + medium + hard;
|
||||||
|
|
||||||
|
const effortBalance =
|
||||||
|
total === 0
|
||||||
|
? 10
|
||||||
|
: Math.min(10, Math.round(Math.max(0, 10 - Math.abs(easy - hard) * 1.5)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
proteinDiversity: Math.max(0, Math.round(10 - proteinRepeats * 2)),
|
||||||
|
ingredientOverlap: Math.max(0, Math.round(10 - ingredientOverlapCount * 1.5)),
|
||||||
|
effortBalance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningInput {
|
||||||
|
tagRepeats: TagRepeat[];
|
||||||
|
ingredientOverlaps: IngredientOverlap[];
|
||||||
|
duplicatesInPlan: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Warning {
|
||||||
|
title: string;
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeWarnings(input: WarningInput): Warning[] {
|
||||||
|
const { tagRepeats, ingredientOverlaps, duplicatesInPlan } = input;
|
||||||
|
const result: Warning[] = [];
|
||||||
|
|
||||||
|
for (const repeat of tagRepeats) {
|
||||||
|
if ((repeat.days?.length ?? 0) > 1) {
|
||||||
|
const days = (repeat.days ?? []).join(', ');
|
||||||
|
result.push({
|
||||||
|
title: `${repeat.tagName} mehrfach diese Woche`,
|
||||||
|
explanation: `${days} — erwäge einen Tausch für mehr Protein-Abwechslung.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const overlap of ingredientOverlaps) {
|
||||||
|
if ((overlap.days?.length ?? 0) > 1) {
|
||||||
|
const days = (overlap.days ?? []).join(', ');
|
||||||
|
result.push({
|
||||||
|
title: `${overlap.ingredientName} in mehreren Gerichten`,
|
||||||
|
explanation: `${days} — sorge für Zutaten-Abwechslung.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of duplicatesInPlan) {
|
||||||
|
result.push({
|
||||||
|
title: `${name} doppelt geplant`,
|
||||||
|
explanation: 'Dasselbe Rezept erscheint mehrfach — tausche eines aus.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
28
frontend/src/routes/(app)/planner/variety/+page.server.ts
Normal file
28
frontend/src/routes/(app)/planner/variety/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
import { getWeekStart } from '$lib/planner/week';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
|
const weekParam = url.searchParams.get('week');
|
||||||
|
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
|
||||||
|
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
||||||
|
params: { query: { weekStart } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (weekPlanError || !weekPlan?.id) {
|
||||||
|
return { weekPlan: null, varietyScore: null, weekStart };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
|
||||||
|
params: { path: { id: weekPlan.id } }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekPlan,
|
||||||
|
varietyScore: varietyScore ?? null,
|
||||||
|
weekStart
|
||||||
|
};
|
||||||
|
};
|
||||||
233
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
233
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import VarietyScoreHero from '$lib/planner/VarietyScoreHero.svelte';
|
||||||
|
import ScoreBreakdownList from '$lib/planner/ScoreBreakdownList.svelte';
|
||||||
|
import VarietyWarningCards from '$lib/planner/VarietyWarningCards.svelte';
|
||||||
|
import EffortBar from '$lib/planner/EffortBar.svelte';
|
||||||
|
import { computeSubScores, computeWarnings } from '$lib/planner/variety';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let weekPlan = $derived(data.weekPlan);
|
||||||
|
let varietyScore = $derived(data.varietyScore);
|
||||||
|
let weekStart = $derived(data.weekStart);
|
||||||
|
|
||||||
|
let score = $derived(varietyScore?.score ?? 0);
|
||||||
|
|
||||||
|
// Derive effort distribution from week plan slots
|
||||||
|
let effortCounts = $derived.by(() => {
|
||||||
|
const slots = weekPlan?.slots ?? [];
|
||||||
|
let easy = 0, medium = 0, hard = 0;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const effort = slot.recipe?.effort?.toLowerCase() ?? '';
|
||||||
|
if (effort === 'easy' || effort === 'einfach') easy++;
|
||||||
|
else if (effort === 'medium' || effort === 'mittel') medium++;
|
||||||
|
else if (effort === 'hard' || effort === 'aufwändig') hard++;
|
||||||
|
}
|
||||||
|
return { easy, medium, hard };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive sub-scores from API data
|
||||||
|
// TODO: replace with API-provided sub-scores once backend supports them.
|
||||||
|
let subScores = $derived.by(() => computeSubScores({
|
||||||
|
tagRepeats: varietyScore?.tagRepeats ?? [],
|
||||||
|
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
|
||||||
|
...effortCounts
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Build warning list from API data
|
||||||
|
let warnings = $derived.by(() => computeWarnings({
|
||||||
|
tagRepeats: varietyScore?.tagRepeats ?? [],
|
||||||
|
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
|
||||||
|
duplicatesInPlan: varietyScore?.duplicatesInPlan ?? []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Protein grid: map protein tags to days of the week
|
||||||
|
let proteinByDay = $derived.by(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const repeat of varietyScore?.tagRepeats ?? []) {
|
||||||
|
if (repeat.tagType === 'protein') {
|
||||||
|
for (const day of repeat.days ?? []) {
|
||||||
|
map[day] = repeat.tagName ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Days of the week abbreviations for protein grid
|
||||||
|
const weekDayAbbrs = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
const weekDayKeys = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Abwechslung überprüfen — Mealplan</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Mobile layout -->
|
||||||
|
<div class="flex h-full flex-col lg:hidden">
|
||||||
|
<!-- 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)]">
|
||||||
|
Abwechslungs-Analyse
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 pb-8 pt-5">
|
||||||
|
{#if !varietyScore}
|
||||||
|
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||||
|
Noch keine Gerichte geplant. Plane zuerst einige Mahlzeiten.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/planner?week={weekStart}"
|
||||||
|
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||||
|
>
|
||||||
|
Zum Wochenplaner →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Big score -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<VarietyScoreHero {score} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-scores -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Bewertung im Detail
|
||||||
|
</h2>
|
||||||
|
<ScoreBreakdownList {subScores} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warnings -->
|
||||||
|
{#if warnings.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Hinweise
|
||||||
|
</h2>
|
||||||
|
<VarietyWarningCards {warnings} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop layout -->
|
||||||
|
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||||
|
<!-- Topbar with breadcrumb -->
|
||||||
|
<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}"
|
||||||
|
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
Planer
|
||||||
|
</a>
|
||||||
|
<span aria-hidden="true" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/</span>
|
||||||
|
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
||||||
|
Abwechslungs-Analyse
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Sidebar (224px) — nav placeholder for consistency with C1 -->
|
||||||
|
<aside class="hidden w-[224px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)] xl:block">
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-8 py-6">
|
||||||
|
{#if !varietyScore}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||||
|
Noch keine Gerichte geplant.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/planner?week={weekStart}"
|
||||||
|
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||||
|
>
|
||||||
|
Zum Wochenplaner →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Top section: 2 columns -->
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<!-- Left: score + sub-scores -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<VarietyScoreHero {score} />
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Bewertung im Detail
|
||||||
|
</h2>
|
||||||
|
<ScoreBreakdownList {subScores} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right (320px): protein grid + effort bar -->
|
||||||
|
<div class="w-[320px] flex-shrink-0 space-y-6">
|
||||||
|
<!-- Protein grid -->
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Protein-Verteilung
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-7 gap-[6px]">
|
||||||
|
{#each weekDayAbbrs as abbr, i (weekDayKeys[i])}
|
||||||
|
{@const key = weekDayKeys[i]}
|
||||||
|
{@const protein = proteinByDay[key]}
|
||||||
|
{@const isRepeated = protein && Object.values(proteinByDay).filter((p) => p === protein).length > 1}
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<span class="font-[var(--font-sans)] text-[10px] text-[var(--color-text-muted)]">{abbr}</span>
|
||||||
|
<div
|
||||||
|
data-testid="protein-cell"
|
||||||
|
data-protein={protein ?? 'none'}
|
||||||
|
class="flex h-[44px] w-full items-center justify-center rounded-[var(--radius-sm)] text-[10px] font-medium
|
||||||
|
{protein
|
||||||
|
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
|
||||||
|
: 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]'}
|
||||||
|
{isRepeated ? 'ring-2 ring-[var(--yellow)]' : ''}"
|
||||||
|
>
|
||||||
|
{protein ? protein.split(' ')[0].slice(0, 3).toUpperCase() : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Effort bar -->
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Aufwandsverteilung
|
||||||
|
</h2>
|
||||||
|
{#if (effortCounts.easy + effortCounts.medium + effortCounts.hard) > 0}
|
||||||
|
<EffortBar
|
||||||
|
easy={effortCounts.easy}
|
||||||
|
medium={effortCounts.medium}
|
||||||
|
hard={effortCounts.hard}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
Noch keine Gerichte geplant.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom: warnings, full width -->
|
||||||
|
{#if warnings.length > 0}
|
||||||
|
<div class="mt-8 space-y-3">
|
||||||
|
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Hinweise
|
||||||
|
</h2>
|
||||||
|
<VarietyWarningCards {warnings} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ GET: mockGet })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockVarietyScore = {
|
||||||
|
score: 8.2,
|
||||||
|
tagRepeats: [
|
||||||
|
{ tagName: 'Chicken', tagType: 'protein', days: ['MON', 'WED'] }
|
||||||
|
],
|
||||||
|
ingredientOverlaps: [
|
||||||
|
{ ingredientName: 'Tomaten', days: ['MON', 'TUE', 'WED'] }
|
||||||
|
],
|
||||||
|
recentRepeats: ['Pasta Bolognese'],
|
||||||
|
duplicatesInPlan: ['Hühnchen Curry']
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWeekPlan = {
|
||||||
|
id: 'plan-1',
|
||||||
|
weekStart: '2026-03-30',
|
||||||
|
status: 'draft',
|
||||||
|
slots: [
|
||||||
|
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 20 } },
|
||||||
|
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } },
|
||||||
|
{ id: 's3', slotDate: '2026-04-01', recipe: { id: 'r3', name: 'Steak', effort: 'Hard', cookTimeMin: 60 } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('variety page — load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches week plan and variety score', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||||
|
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||||
|
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.anything());
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/variety-score', expect.objectContaining({
|
||||||
|
params: { path: { id: 'plan-1' } }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns varietyScore and weekPlan in result', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||||
|
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||||
|
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
|
||||||
|
expect(result.varietyScore?.score).toBe(8.2);
|
||||||
|
expect(result.weekPlan?.id).toBe('plan-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns weekStart from URL param', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||||
|
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||||
|
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||||
|
expect(result.weekStart).toBe('2026-03-30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null data when week plan not found', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||||
|
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||||
|
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||||
|
expect(result.weekPlan).toBeNull();
|
||||||
|
expect(result.varietyScore).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null varietyScore when score endpoint fails', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||||
|
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||||
|
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||||
|
expect(result.weekPlan?.id).toBe('plan-1');
|
||||||
|
expect(result.varietyScore).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses current week when no week param provided', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||||
|
const url = new URL('http://localhost/planner/variety');
|
||||||
|
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||||
|
// weekStart should be a valid YYYY-MM-DD
|
||||||
|
expect(result.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user