feat(variety): implement C3 variety review screen (Issue #28)
- Add /planner/variety route with mobile stacked + desktop 2-column layout - Implement VarietyScoreHero: Fraunces score display + progress bar + color-coded description - Implement ScoreBreakdownList: 3 sub-score rows (protein diversity, ingredient overlap, effort balance) - Implement VarietyWarningCards: yellow-tint warning cards derived from API tagRepeats/ingredientOverlaps - Implement EffortBar: proportional colored segments (Easy/Medium/Hard) with ×N labels - Desktop: protein grid (7 columns, repeat highlight with yellow ring) + effort bar in right panel - Client-side sub-score derivation from VarietyScoreResponse (tagged for TODO to move to API) - 26 new tests across 5 components + server load function; 455 tests total, 0 type errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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