From 9adf786b8f98508db9240360b161b26abee5e5e7 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:32:20 +0200 Subject: [PATCH] test(variety): extract and test sub-score/warnings pure functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract computeSubScores() and computeWarnings() to variety.ts - 18 unit tests covering formulas, boundaries, clamping, edge cases: - proteinDiversity: repeats × 2 penalty, clamped to 0 - ingredientOverlap: overlaps × 1.5 penalty, clamped to 0 - effortBalance: easy-hard diff × 1.5, total=0 → 10 - warnings: repeat≥2 days, overlap≥2 days, duplicates Addresses QA blockers: untested business logic in sub-score derivations. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/variety.test.ts | 123 +++++++++++++++++++++++ frontend/src/lib/planner/variety.ts | 88 ++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 frontend/src/lib/planner/variety.test.ts create mode 100644 frontend/src/lib/planner/variety.ts diff --git a/frontend/src/lib/planner/variety.test.ts b/frontend/src/lib/planner/variety.test.ts new file mode 100644 index 0000000..7b9d1f8 --- /dev/null +++ b/frontend/src/lib/planner/variety.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/planner/variety.ts b/frontend/src/lib/planner/variety.ts new file mode 100644 index 0000000..8edbf9f --- /dev/null +++ b/frontend/src/lib/planner/variety.ts @@ -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; +}