test(variety): extract and test sub-score/warnings pure functions

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 11:32:20 +02:00
parent 1bf929280b
commit 9adf786b8f
2 changed files with 211 additions and 0 deletions

View 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);
});
});

View 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;
}