/** * Test Suite for Dienstplan Bonusrechner */ class TestRunner { constructor() { this.tests = []; this.passed = 0; this.failed = 0; } /** * Add a test case */ test(name, testFn) { this.tests.push({ name, testFn }); } /** * Assert equality */ assertEqual(actual, expected, message = '') { if (actual !== expected) { throw new Error(`${message}\nErwartet: ${expected}\nErhalten: ${actual}`); } } /** * Assert approximate equality (for floating point) */ assertAlmostEqual(actual, expected, tolerance = 0.01, message = '') { if (Math.abs(actual - expected) > tolerance) { throw new Error(`${message}\nErwartet: ${expected} (±${tolerance})\nErhalten: ${actual}`); } } /** * Assert true */ assertTrue(value, message = '') { if (!value) { throw new Error(`${message}\nErwartet: true\nErhalten: ${value}`); } } /** * Assert false */ assertFalse(value, message = '') { if (value) { throw new Error(`${message}\nErwartet: false\nErhalten: ${value}`); } } /** * Run all tests */ async runAll() { this.passed = 0; this.failed = 0; const results = []; for (const test of this.tests) { try { await test.testFn(this); this.passed++; results.push({ name: test.name, passed: true, error: null }); } catch (error) { this.failed++; results.push({ name: test.name, passed: false, error: error.message }); } } return results; } /** * Get summary */ getSummary() { return { total: this.tests.length, passed: this.passed, failed: this.failed }; } } // Create test runner instance const runner = new TestRunner(); // ============================================================================ // Holiday Provider Tests // ============================================================================ runner.test('HolidayProvider: Neujahr 2025 wird erkannt', (t) => { const holidays = new HolidayProvider(); const date = new Date('2025-01-01T12:00:00'); t.assertTrue(holidays.isHoliday(date), 'Neujahr sollte als Feiertag erkannt werden'); }); runner.test('HolidayProvider: Normaler Tag wird nicht als Feiertag erkannt', (t) => { const holidays = new HolidayProvider(); const date = new Date('2025-01-15T12:00:00'); // Mittwoch t.assertFalse(holidays.isHoliday(date), 'Normaler Tag sollte nicht als Feiertag erkannt werden'); }); runner.test('HolidayProvider: Tag vor Feiertag wird erkannt', (t) => { const holidays = new HolidayProvider(); const date = new Date('2024-12-31T12:00:00'); // Tag vor Neujahr t.assertTrue(holidays.isDayBeforeHoliday(date), 'Tag vor Feiertag sollte erkannt werden'); }); runner.test('HolidayProvider: Fronleichnam 2025 korrekt', (t) => { const holidays = new HolidayProvider(); const date = new Date('2025-06-19T12:00:00'); t.assertTrue(holidays.isHoliday(date), 'Fronleichnam sollte als Feiertag erkannt werden'); t.assertEqual(holidays.getHolidayName(date), 'Fronleichnam', 'Feiertagsname sollte korrekt sein'); }); // ============================================================================ // Calculator Tests - Day Classification // ============================================================================ runner.test('Calculator: Freitag ist qualifizierender Tag', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const friday = new Date('2025-11-21T12:00:00'); // Freitag t.assertTrue(calculator.isQualifyingDay(friday), 'Freitag sollte qualifizierend sein'); }); runner.test('Calculator: Samstag ist qualifizierender Tag', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const saturday = new Date('2025-11-22T12:00:00'); // Samstag t.assertTrue(calculator.isQualifyingDay(saturday), 'Samstag sollte qualifizierend sein'); }); runner.test('Calculator: Sonntag ist qualifizierender Tag', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const sunday = new Date('2025-11-23T12:00:00'); // Sonntag t.assertTrue(calculator.isQualifyingDay(sunday), 'Sonntag sollte qualifizierend sein'); }); runner.test('Calculator: Montag ist kein qualifizierender Tag', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const monday = new Date('2025-11-24T12:00:00'); // Montag (kein Feiertag) t.assertFalse(calculator.isQualifyingDay(monday), 'Normaler Montag sollte nicht qualifizierend sein'); }); runner.test('Calculator: Feiertag ist qualifizierender Tag', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const holiday = new Date('2025-05-01T12:00:00'); // Tag der Arbeit t.assertTrue(calculator.isQualifyingDay(holiday), 'Feiertag sollte qualifizierend sein'); }); runner.test('Calculator: Tag vor Feiertag ist qualifizierender Tag', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const dayBefore = new Date('2025-04-30T12:00:00'); // Tag vor 1. Mai t.assertTrue(calculator.isQualifyingDay(dayBefore), 'Tag vor Feiertag sollte qualifizierend sein'); }); // ============================================================================ // Calculator Tests - Bonus Calculation (new variants shape) // ============================================================================ runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0 EUR', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 } // 1x Samstag ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertEqual(result.classified.sa, 1.0, 'sa=1.0'); t.assertFalse(result.winner.eligible, 'Kein eligible Variant'); t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: Genau 2.0 WE-Tage (Sa+So) -> V3 trigger, bonus 0', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertTrue(result.winner.eligible, 'V3 eligible'); t.assertEqual(result.winner.paidShares.sa + result.winner.paidShares.so, 0, '0 paid (alle abgezogen)'); t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: 4x halbe Sa+So Dienste (Schwelle 2.0) -> bonus 0', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, { date: new Date('2025-11-23T12:00:00'), share: 0.5 }, { date: new Date('2025-11-23T12:00:00'), share: 0.5 } ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertAlmostEqual(result.classified.sa + result.classified.so, 2.0, 0.0001, '2.0 total'); t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: 3 WE-Tage (Fr+Sa+So) -> V3 winner, bonus 450 EUR', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Freitag { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag ]; const result = calculator.calculateMonthlyBonus(duties, false); // V3: pool=3, abzug 2 (fr=1, so=1) -> paid sa=1 -> 450 t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertEqual(result.totalBonus, 450, 'bonus 450'); }); runner.test('Berechnung: Normale Tage + WE-Tage gemischt (Mo+Di+Sa+So) -> V3, bonus 500', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Montag { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Dienstag { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag ]; const result = calculator.calculateMonthlyBonus(duties, false); // V1: fr+so=1, weekday=2 < 3 -> not eligible // V2: sa=1, weekday=2 -> eligible, abzug 1 sa, 2 weekday -> 0 -> bonus 0 // V3: pool=2 -> eligible, abzug 2 (so=1, sa=1) -> 0 sa/so paid + 2 weekday paid = 500 t.assertEqual(result.winner.variantId, 3, 'V3 winner with weekday-pay'); t.assertEqual(result.winner.paidShares.weekday, 2, '2 weekday paid'); t.assertEqual(result.totalBonus, 500, '2 * 250 = 500'); }); runner.test('Berechnung: Halbe Dienste korrekt im neuen Shape', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-24T12:00:00'), share: 0.5 }, // halber Mo (weekday) { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, // halber Sa { date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // ganzer So { date: new Date('2025-11-21T12:00:00'), share: 1.0 } // ganzer Fr ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertAlmostEqual(result.classified.weekday, 0.5, 0.0001, 'weekday=0.5'); t.assertAlmostEqual(result.classified.fr + result.classified.sa + result.classified.so, 2.5, 0.0001, 'WE-Pool=2.5'); // V3: pool=2.5, abzug 2 (fr=1, so=1) -> paid sa=0.5, weekday=0.5 -> 0.5*450 + 0.5*250 = 350 t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertEqual(result.totalBonus, 350, 'bonus 350'); }); runner.test('Berechnung: Feiertag (1. Mai 2025 = Do) + Vortag (Mi)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-04-30T12:00:00'), share: 1.0 }, // Mi vor 1. Mai -> fr { date: new Date('2025-05-01T12:00:00'), share: 1.0 } // 1. Mai (Do-Feiertag) -> so ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertAlmostEqual(result.classified.fr, 1.0, 0.0001, 'fr=1.0'); t.assertAlmostEqual(result.classified.so, 1.0, 0.0001, 'so=1.0'); // V3: pool=2, abzug 2 (fr=1, so=1) -> 0 paid -> bonus 0 t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: Keine Dienste = 0 EUR', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const result = calculator.calculateMonthlyBonus([], false); t.assertEqual(result.totalDuties, 0, '0 duties'); t.assertEqual(result.totalBonus, 0, '0 bonus'); t.assertEqual(result.dutyDetails.length, 0, '0 dutyDetails'); }); // ============================================================================ // Storage Tests // ============================================================================ runner.test('Storage: Mitarbeiter hinzufügen', (t) => { const storage = new DataStorage(); storage.clearAll(); const success = storage.addEmployee('Max Mustermann'); t.assertTrue(success, 'Mitarbeiter sollte hinzugefügt werden'); const employees = storage.getEmployees(); t.assertEqual(employees.length, 1, 'Sollte 1 Mitarbeiter haben'); t.assertTrue(employees.includes('Max Mustermann'), 'Mitarbeiter sollte in Liste sein'); }); runner.test('Storage: Doppelter Mitarbeiter wird abgelehnt', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); const success = storage.addEmployee('Max Mustermann'); t.assertFalse(success, 'Doppelter Mitarbeiter sollte abgelehnt werden'); const employees = storage.getEmployees(); t.assertEqual(employees.length, 1, 'Sollte nur 1 Mitarbeiter haben'); }); runner.test('Storage: Mitarbeiter entfernen', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); storage.removeEmployee('Max Mustermann'); const employees = storage.getEmployees(); t.assertEqual(employees.length, 0, 'Sollte 0 Mitarbeiter haben'); }); runner.test('Storage: Dienst hinzufügen und abrufen', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); const date = new Date('2025-11-22T12:00:00'); storage.addDuty('Max Mustermann', 2025, 11, date, 1.0); const duties = storage.getDutiesForMonth('Max Mustermann', 2025, 11); t.assertEqual(duties.length, 1, 'Sollte 1 Dienst haben'); t.assertEqual(duties[0].share, 1.0, 'Dienst sollte share 1.0 haben'); }); runner.test('Storage: Dienst aktualisieren (gleicher Tag)', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); const date = new Date('2025-11-22T12:00:00'); storage.addDuty('Max Mustermann', 2025, 11, date, 1.0); storage.addDuty('Max Mustermann', 2025, 11, date, 0.5); // Update const duties = storage.getDutiesForMonth('Max Mustermann', 2025, 11); t.assertEqual(duties.length, 1, 'Sollte nur 1 Dienst haben (aktualisiert)'); t.assertEqual(duties[0].share, 0.5, 'Share sollte aktualisiert sein'); }); runner.test('Storage: Mehrere Mitarbeiter', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); storage.addEmployee('Anna Schmidt'); storage.addEmployee('Peter Müller'); const employees = storage.getEmployees(); t.assertEqual(employees.length, 3, 'Sollte 3 Mitarbeiter haben'); t.assertTrue(employees.includes('Anna Schmidt'), 'Anna Schmidt sollte vorhanden sein'); }); runner.test('Storage: Export und Import', (t) => { const storage1 = new DataStorage(); storage1.clearAll(); storage1.addEmployee('Max Mustermann'); const date = new Date('2025-11-22T12:00:00'); storage1.addDuty('Max Mustermann', 2025, 11, date, 1.0); const exported = storage1.exportData(); const storage2 = new DataStorage(); storage2.clearAll(); const success = storage2.importData(exported); t.assertTrue(success, 'Import sollte erfolgreich sein'); const employees = storage2.getEmployees(); t.assertEqual(employees.length, 1, 'Sollte 1 Mitarbeiter haben'); const duties = storage2.getDutiesForMonth('Max Mustermann', 2025, 11); t.assertEqual(duties.length, 1, 'Sollte 1 Dienst haben'); }); // ============================================================================ // Storage - Vacation Mode // ============================================================================ runner.test('Storage: getVacationMode fuer unbekannten MA -> false', (t) => { const storage = new DataStorage(); storage.clearAll(); t.assertFalse(storage.getVacationMode('Niemand', '2025-11'), 'leerer Default false'); }); runner.test('Storage: setVacationMode -> getVacationMode round-trip', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.setVacationMode('Max Mustermann', '2025-11', true); t.assertTrue(storage.getVacationMode('Max Mustermann', '2025-11'), 'true round-trip'); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-12'), 'anderer Monat = false'); t.assertFalse(storage.getVacationMode('Anna Schmidt', '2025-11'), 'anderer MA = false'); }); runner.test('Storage: setVacationMode kann zurueckgesetzt werden', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.setVacationMode('Max Mustermann', '2025-11', true); storage.setVacationMode('Max Mustermann', '2025-11', false); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'wieder false'); }); runner.test('Storage: Export enthaelt dienstplan_vacation', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); storage.setVacationMode('Max Mustermann', '2025-11', true); const exported = storage.exportData(); const parsed = JSON.parse(exported); t.assertTrue('vacation' in parsed, 'vacation key im Export'); t.assertEqual(parsed.vacation['Max Mustermann']['2025-11'], true, 'Wert exportiert'); }); runner.test('Storage: Import restauriert vacation', (t) => { const storage1 = new DataStorage(); storage1.clearAll(); storage1.addEmployee('Max Mustermann'); storage1.setVacationMode('Max Mustermann', '2025-11', true); const exported = storage1.exportData(); const storage2 = new DataStorage(); storage2.clearAll(); const ok = storage2.importData(exported); t.assertTrue(ok, 'Import success'); t.assertTrue(storage2.getVacationMode('Max Mustermann', '2025-11'), 'vacation restauriert'); }); runner.test('Storage: Import ohne vacation-Feld bleibt fehlerfrei', (t) => { const storage = new DataStorage(); storage.clearAll(); const legacyJson = JSON.stringify({ employees: ['Max Mustermann'], duties: {} }); const ok = storage.importData(legacyJson); t.assertTrue(ok, 'Legacy import erfolgreich'); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'Default false'); }); runner.test('Storage: clearAll entfernt auch vacation', (t) => { const storage = new DataStorage(); storage.setVacationMode('Max Mustermann', '2025-11', true); storage.clearAll(); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'nach clearAll false'); }); // ============================================================================ // Edge Cases & Regression Tests // ============================================================================ runner.test('Edge Case: Exakt Schwellenwert mit Rundungsfehler (1.9999)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 0.66666 }, // Sa { date: new Date('2025-11-23T12:00:00'), share: 0.66666 }, // So { date: new Date('2025-11-21T12:00:00'), share: 0.66666 } // Fr ]; const result = calculator.calculateMonthlyBonus(duties, false); const pool = result.classified.fr + result.classified.sa + result.classified.so; // 0.66666 x 3 ~ 1.99998 - wegen 1e-9 Toleranz triggert V3 t.assertTrue(result.winner.variantId === 3 || pool < 2.0, 'Rundung korrekt behandelt'); }); runner.test('Edge Case: Sehr viele Dienste (Performance)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = []; for (let i = 1; i <= 30; i++) { duties.push({ date: new Date(`2025-11-${String(i).padStart(2, '0')}T12:00:00`), share: i % 2 === 0 ? 1.0 : 0.5 }); } const start = Date.now(); const result = calculator.calculateMonthlyBonus(duties, false); const duration = Date.now() - start; t.assertTrue(duration < 100, `Berechnung schnell (${duration}ms)`); t.assertTrue(result.totalBonus > 0, 'Bonus > 0'); }); runner.test('Edge Case: Dienst am 29. Februar (Schaltjahr)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2028-02-29T12:00:00'), share: 1.0 } // Dienstag -> weekday ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertEqual(result.classified.weekday, 1.0, '29.02. (Di) = weekday'); }); // ============================================================================ // Variants - classify() // ============================================================================ runner.test('classify: Karfreitag 2025 (Fr-Feiertag) -> fr', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-04-18T12:00:00'); t.assertEqual(classify(date, hp), 'fr', 'Karfreitag (Fr) muss fr sein'); }); runner.test('classify: Ostermontag 2025 (Mo-Feiertag) -> so', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-04-21T12:00:00'); t.assertEqual(classify(date, hp), 'so', 'Ostermontag (Mo-Feiertag) muss so sein'); }); runner.test('classify: Christi Himmelfahrt 2025 (Do-Feiertag) -> so', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-05-29T12:00:00'); t.assertEqual(classify(date, hp), 'so', 'Do-Feiertag ohne Fr-Feiertag muss so sein'); }); runner.test('classify: Mi vor Christi Himmelfahrt 2025 -> fr', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-05-28T12:00:00'); t.assertEqual(classify(date, hp), 'fr', 'Tag vor Mo-Do-Feiertag muss fr sein'); }); runner.test('classify: Tag der Deutschen Einheit 2025 (Fr-Feiertag) -> fr', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-10-03T12:00:00'); t.assertEqual(classify(date, hp), 'fr', 'Fr-Feiertag muss fr sein'); }); runner.test('classify: Sandwich Do+Fr Feiertag -> Do=sa, Fr=fr', (t) => { // Use a fake HolidayProvider that flags Do AND Fr as Feiertag. const fakeHp = { isHoliday(date) { const day = date.getDay(); return day === 4 || day === 5; // Thu or Fri }, isDayBeforeHoliday(date) { const next = new Date(date); next.setDate(next.getDate() + 1); return this.isHoliday(next); } }; const thursday = new Date('2025-11-20T12:00:00'); // Donnerstag const friday = new Date('2025-11-21T12:00:00'); // Freitag t.assertEqual(classify(thursday, fakeHp), 'sa', 'Do Feiertag + Tag vor Fr Feiertag -> sa (Sandwich)'); t.assertEqual(classify(friday, fakeHp), 'fr', 'Fr Feiertag bleibt fr (Wochentag gewinnt)'); }); runner.test('classify: Sandwich Mo+Di Feiertag -> Mo=sa, Di=so', (t) => { const fakeHp = { isHoliday(date) { const day = date.getDay(); return day === 1 || day === 2; // Mon or Tue }, isDayBeforeHoliday(date) { const next = new Date(date); next.setDate(next.getDate() + 1); return this.isHoliday(next); } }; const monday = new Date('2025-11-24T12:00:00'); // Montag const tuesday = new Date('2025-11-25T12:00:00'); // Dienstag t.assertEqual(classify(monday, fakeHp), 'sa', 'Mo Feiertag + Tag vor Di Feiertag -> sa'); t.assertEqual(classify(tuesday, fakeHp), 'so', 'Di Feiertag (kein Sandwich, kein Tag-vor) -> so'); }); runner.test('classifyDuties: leeres Array -> alle Slots 0', (t) => { const hp = new HolidayProvider(); const result = classifyDuties([], hp); t.assertEqual(result.fr, 0, 'fr=0'); t.assertEqual(result.sa, 0, 'sa=0'); t.assertEqual(result.so, 0, 'so=0'); t.assertEqual(result.weekday, 0, 'weekday=0'); }); runner.test('classifyDuties: halbe Schicht auf Freitag zaehlt 0.5', (t) => { const hp = new HolidayProvider(); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 0.5 } // Fr ]; const result = classifyDuties(duties, hp); t.assertAlmostEqual(result.fr, 0.5, 0.0001, 'fr=0.5'); t.assertEqual(result.sa, 0, 'sa=0'); t.assertEqual(result.so, 0, 'so=0'); t.assertEqual(result.weekday, 0, 'weekday=0'); }); runner.test('classifyDuties: mehrere Dienste pro Slot summieren', (t) => { const hp = new HolidayProvider(); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa { date: new Date('2025-11-23T12:00:00'), share: 0.5 }, // So { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo (weekday) { date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di (weekday) ]; const result = classifyDuties(duties, hp); t.assertAlmostEqual(result.fr, 1.0, 0.0001, 'fr=1.0'); t.assertAlmostEqual(result.sa, 1.0, 0.0001, 'sa=1.0'); t.assertAlmostEqual(result.so, 0.5, 0.0001, 'so=0.5'); t.assertAlmostEqual(result.weekday, 1.5, 0.0001, 'weekday=1.5'); }); runner.test('classifyDuties: Tag vor Feiertag (Mi vor Christi Himmelfahrt) zaehlt in fr', (t) => { const hp = new HolidayProvider(); const duties = [ { date: new Date('2025-05-28T12:00:00'), share: 1.0 } // Mi vor Christi Himmelfahrt ]; const result = classifyDuties(duties, hp); t.assertAlmostEqual(result.fr, 1.0, 0.0001, 'Mi-vor-Do-Feiertag -> fr'); t.assertEqual(result.weekday, 0, 'weekday=0'); }); // ============================================================================ // Variants - variant3 (loose: 2 qualifying days, pool fr+sa+so) // ============================================================================ runner.test('variant3: unter Schwelle (1 sa) -> not eligible, bonus 0', (t) => { const classified = { fr: 0, sa: 1, so: 0, weekday: 4 }; const r = variant3(classified, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); t.assertEqual(r.variantId, 3, 'variantId=3'); }); runner.test('variant3: 2x sa -> eligible, beide abgezogen, bonus 0', (t) => { const classified = { fr: 0, sa: 2, so: 0, weekday: 0 }; const r = variant3(classified, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.sa, 2, 'sa-deduction=2'); t.assertEqual(r.paidShares.sa, 0, 'sa-paid=0'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant3: Friday priority fr->so->sa', (t) => { // fr=2, sa=1, so=1, weekday=0 -> 2 von fr abgezogen, sa+so voll bezahlt const classified = { fr: 2, sa: 1, so: 1, weekday: 0 }; const r = variant3(classified, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 2, 'fr-deduction=2'); t.assertEqual(r.deduction.so, 0, 'so-deduction=0'); t.assertEqual(r.deduction.sa, 0, 'sa-deduction=0'); t.assertEqual(r.paidShares.fr, 0, 'fr-paid=0'); t.assertEqual(r.paidShares.so, 1, 'so-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.bonus, 2 * 450, 'bonus = 2 * 450 = 900'); }); runner.test('variant3: fr=1, sa=1, so=0 -> fr+sa abgezogen', (t) => { const classified = { fr: 1, sa: 1, so: 0, weekday: 0 }; const r = variant3(classified, false); t.assertEqual(r.deduction.fr, 1, 'fr=1'); t.assertEqual(r.deduction.so, 0, 'so=0'); t.assertEqual(r.deduction.sa, 1, 'sa=1'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant3: weekday wird voll bezahlt, nicht abgezogen', (t) => { const classified = { fr: 1, sa: 1, so: 0, weekday: 3 }; const r = variant3(classified, false); t.assertEqual(r.paidShares.weekday, 3, 'weekday-paid=3'); t.assertEqual(r.deduction.weekday, 0, 'weekday-deduction=0'); t.assertEqual(r.bonus, 3 * 250, 'bonus = 3 * 250 = 750'); }); runner.test('variant3: Urlaubsmodus halbiert Schwelle auf 1', (t) => { const classified = { fr: 0, sa: 0.5, so: 0.5, weekday: 0 }; const r = variant3(classified, true); t.assertTrue(r.eligible, 'eligible=true (Schwelle 1)'); // Abzug 1 aus Pool, fr-Prio -> so zuerst (fr=0), dann sa t.assertEqual(r.deduction.fr, 0, 'fr=0'); t.assertEqual(r.deduction.so, 0.5, 'so=0.5'); t.assertEqual(r.deduction.sa, 0.5, 'sa=0.5'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant3: Urlaubsmodus, halbe sa und 1 fr -> fr-Prio frisst 1', (t) => { const classified = { fr: 1, sa: 0.5, so: 0, weekday: 0 }; const r = variant3(classified, true); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 1, 'fr=1'); t.assertEqual(r.deduction.sa, 0, 'sa unangetastet'); t.assertEqual(r.paidShares.sa, 0.5, 'sa-paid=0.5'); t.assertEqual(r.bonus, 0.5 * 450, 'bonus = 0.5 * 450 = 225'); }); runner.test('variant3: threshold-Shape ist {pool: 2} normal, {pool: 1} im Urlaub', (t) => { const r1 = variant3({ fr: 0, sa: 2, so: 0, weekday: 0 }, false); const r2 = variant3({ fr: 0, sa: 1, so: 0, weekday: 0 }, true); t.assertEqual(r1.threshold.pool, 2, 'normal pool=2'); t.assertEqual(r2.threshold.pool, 1, 'vacation pool=1'); }); // ============================================================================ // Variants - variant1 (1 fr+so + 3 weekday) // ============================================================================ runner.test('variant1: Schwelle nicht erreicht (fr+so=0)', (t) => { const r = variant1({ fr: 0, sa: 5, so: 0, weekday: 3 }, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: Schwelle nicht erreicht (weekday<3)', (t) => { const r = variant1({ fr: 1, sa: 5, so: 0, weekday: 2 }, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: Spec-Beispiel fr=2,sa=1,so=0,weekday=4 -> 1150', (t) => { const r = variant1({ fr: 2, sa: 1, so: 0, weekday: 4 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 1, 'fr-deduction=1 (Fr-Prio)'); t.assertEqual(r.deduction.so, 0, 'so-deduction=0'); t.assertEqual(r.deduction.sa, 0, 'sa nicht abgezogen'); t.assertEqual(r.deduction.weekday, 3, 'weekday-deduction=3'); t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.paidShares.so, 0, 'so-paid=0'); t.assertEqual(r.paidShares.weekday, 1, 'weekday-paid=1'); t.assertEqual(r.bonus, 1150, 'bonus = (1+1+0)*450 + 1*250 = 1150'); }); runner.test('variant1: nur so vorhanden -> 1 von so abgezogen', (t) => { const r = variant1({ fr: 0, sa: 0, so: 1, weekday: 3 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 0, 'fr-deduction=0'); t.assertEqual(r.deduction.so, 1, 'so-deduction=1'); t.assertEqual(r.deduction.weekday, 3, 'weekday-deduction=3'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: sa wird voll bezahlt, nicht abgezogen', (t) => { const r = variant1({ fr: 1, sa: 2, so: 0, weekday: 3 }, false); t.assertEqual(r.deduction.sa, 0, 'sa-deduction=0'); t.assertEqual(r.paidShares.sa, 2, 'sa-paid=2'); // bonus = (0+2+0)*450 + 0*250 = 900 t.assertEqual(r.bonus, 900, 'bonus=900'); }); runner.test('variant1: Urlaubsmodus halbiert Schwellen (0.5 + 1.5)', (t) => { const r = variant1({ fr: 0.5, sa: 0, so: 0, weekday: 1.5 }, true); t.assertTrue(r.eligible, 'eligible=true im Urlaub'); t.assertEqual(r.threshold.frSo, 0.5, 'threshold.frSo=0.5'); t.assertEqual(r.threshold.weekday, 1.5, 'threshold.weekday=1.5'); t.assertEqual(r.deduction.fr, 0.5, 'fr-deduction=0.5'); t.assertEqual(r.deduction.weekday, 1.5, 'weekday-deduction=1.5'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: threshold-Shape normal {frSo:1, weekday:3}', (t) => { const r = variant1({ fr: 1, sa: 0, so: 0, weekday: 3 }, false); t.assertEqual(r.threshold.frSo, 1, 'threshold.frSo=1'); t.assertEqual(r.threshold.weekday, 3, 'threshold.weekday=3'); }); // ============================================================================ // Variants - variant2 (1 sa + 2 weekday) // ============================================================================ runner.test('variant2: Schwelle nicht erreicht (sa=0)', (t) => { const r = variant2({ fr: 5, sa: 0, so: 5, weekday: 3 }, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant2: Schwelle nicht erreicht (weekday<2)', (t) => { const r = variant2({ fr: 0, sa: 2, so: 0, weekday: 1 }, false); t.assertFalse(r.eligible, 'eligible=false'); }); runner.test('variant2: Spec-Beispiel fr=1,sa=2,so=0,weekday=3 -> 1150', (t) => { const r = variant2({ fr: 1, sa: 2, so: 0, weekday: 3 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.sa, 1, 'sa-deduction=1'); t.assertEqual(r.deduction.weekday, 2, 'weekday-deduction=2'); t.assertEqual(r.deduction.fr, 0, 'fr nicht abgezogen'); t.assertEqual(r.deduction.so, 0, 'so nicht abgezogen'); t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.paidShares.weekday, 1, 'weekday-paid=1'); t.assertEqual(r.bonus, 1150, 'bonus = (1+1+0)*450 + 1*250 = 1150'); }); runner.test('variant2: sa=1,weekday=2 -> alles weg, bonus 0', (t) => { const r = variant2({ fr: 0, sa: 1, so: 0, weekday: 2 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant2: sa=2,weekday=2,fr=1,so=1 -> fr/so voll bezahlt', (t) => { const r = variant2({ fr: 1, sa: 2, so: 1, weekday: 2 }, false); t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.paidShares.so, 1, 'so-paid=1'); t.assertEqual(r.paidShares.weekday, 0, 'weekday-paid=0'); t.assertEqual(r.bonus, 3 * 450, 'bonus = 3*450 = 1350'); }); runner.test('variant2: Urlaubsmodus halbiert (0.5 sa + 1 weekday)', (t) => { const r = variant2({ fr: 0, sa: 0.5, so: 0, weekday: 1 }, true); t.assertTrue(r.eligible, 'eligible=true im Urlaub'); t.assertEqual(r.threshold.sa, 0.5, 'threshold.sa=0.5'); t.assertEqual(r.threshold.weekday, 1, 'threshold.weekday=1'); t.assertEqual(r.deduction.sa, 0.5, 'sa-deduction=0.5'); t.assertEqual(r.deduction.weekday, 1, 'weekday-deduction=1'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant2: threshold-Shape normal {sa:1, weekday:2}', (t) => { const r = variant2({ fr: 0, sa: 1, so: 0, weekday: 2 }, false); t.assertEqual(r.threshold.sa, 1, 'threshold.sa=1'); t.assertEqual(r.threshold.weekday, 2, 'threshold.weekday=2'); }); // ============================================================================ // BonusCalculator - Winner Selection (new shape) // ============================================================================ runner.test('Winner: klarer Sieger mit weekdays + 1 Fr', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di { date: new Date('2025-11-26T12:00:00'), share: 1.0 }, // Mi { date: new Date('2025-11-27T12:00:00'), share: 1.0 }, // Do { date: new Date('2025-11-04T12:00:00'), share: 1.0 } // Di ]; const result = calc.calculateMonthlyBonus(duties, false); t.assertTrue(result.winner.isWinner, 'winner.isWinner=true'); t.assertEqual(result.allResults.length, 3, '3 Varianten im allResults'); t.assertTrue(result.totalBonus > 0, 'Bonus > 0'); }); runner.test('Winner: klarer V3-Sieger (nur WE-Dienste)', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa { date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // So { date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa ]; const result = calc.calculateMonthlyBonus(duties, false); // V1: fr+so=1, weekday=0 -> not eligible // V2: sa=2, weekday=0 -> not eligible // V3: pool=3 -> eligible, deduction 2 (fr=0,so=1 abgezogen, sa=1 abgezogen) -> 1 sa paid -> 450 t.assertEqual(result.winner.variantId, 3, 'V3 muss Sieger sein'); t.assertEqual(result.totalBonus, 450, 'bonus=450'); }); runner.test('Winner: Tie-Breaker - alle three not eligible -> V1 nominal winner, totalBonus 0', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); // fr=1, sa=0, so=0, weekday=3: // V1: fr+so=1 ok, weekday=3 ok -> eligible. Abzug fr=1, weekday=3 -> alles weg, bonus 0. // V2: sa=0 -> not eligible (0). // V3: pool=1 < 2 -> not eligible (0). // -> tie at 0; V1 has eligible=true so its result is still 0. Strict > keeps v1 as winner. const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di { date: new Date('2025-11-26T12:00:00'), share: 1.0 } // Mi ]; const result = calc.calculateMonthlyBonus(duties, false); t.assertEqual(result.winner.variantId, 1, 'V1 wins tie (lowest variantId)'); t.assertEqual(result.totalBonus, 0, 'totalBonus=0 (all-zero tie)'); }); runner.test('Winner: nur V3 produziert positive bonus -> V3 winner', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); // Three Saturdays: V1 not eligible, V2 not eligible (weekday=0), V3 eligible with positive bonus. const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, { date: new Date('2025-11-29T12:00:00'), share: 1.0 }, { date: new Date('2025-11-15T12:00:00'), share: 1.0 } ]; const result = calc.calculateMonthlyBonus(duties, false); // V3: pool=3, abzug 2 (so=0, fr=0, sa=2 abgezogen) -> 1 sa paid -> 450 t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertEqual(result.totalBonus, 450, 'bonus=450'); }); runner.test('Winner: result-Shape enthaelt classified, isVacation, dutyDetails', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, { date: new Date('2025-11-23T12:00:00'), share: 1.0 } ]; const result = calc.calculateMonthlyBonus(duties, false); t.assertTrue('classified' in result, 'classified field exists'); t.assertTrue('isVacation' in result, 'isVacation field exists'); t.assertTrue('dutyDetails' in result, 'dutyDetails field exists'); t.assertEqual(result.dutyDetails.length, 2, 'dutyDetails has 2 entries'); t.assertEqual(result.isVacation, false, 'isVacation=false'); }); runner.test('Winner: Urlaubsmodus halbiert alle Schwellen', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); // fr=0.5, weekday=1.5 -> V1 eligible im Urlaub (0.5 >= 0.5, 1.5 >= 1.5) const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 0.5 }, // Fr { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo { date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di ]; const result = calc.calculateMonthlyBonus(duties, true); t.assertEqual(result.isVacation, true, 'isVacation propagated'); t.assertEqual(result.winner.variantId, 1, 'V1 wins under vacation'); }); // ============================================================================ // Storage Tests - API Key / Model (Feature A) // ============================================================================ runner.test('Storage API Key: setApiKey/getApiKey round-trip', (t) => { const storage = new DataStorage(); storage.clearApiKey(); storage.setApiKey('sk-or-test-12345'); t.assertEqual(storage.getApiKey(), 'sk-or-test-12345', 'Key sollte gespeichert sein'); storage.clearApiKey(); }); runner.test('Storage API Key: getApiKey ohne gesetzten Wert liefert null', (t) => { const storage = new DataStorage(); storage.clearApiKey(); t.assertEqual(storage.getApiKey(), null, 'Sollte null sein'); }); runner.test('Storage API Key: clearApiKey entfernt den Key', (t) => { const storage = new DataStorage(); storage.setApiKey('sk-or-test'); storage.clearApiKey(); t.assertEqual(storage.getApiKey(), null, 'Key sollte gelöscht sein'); }); runner.test('Storage API Model: Default ist anthropic/claude-sonnet-4.6', (t) => { const storage = new DataStorage(); localStorage.removeItem('dienstplan_openrouter_model'); t.assertEqual(storage.getApiModel(), 'anthropic/claude-sonnet-4.6', 'Default-Modell'); }); runner.test('Storage API Model: setApiModel/getApiModel round-trip', (t) => { const storage = new DataStorage(); storage.setApiModel('google/gemini-2.5-pro'); t.assertEqual(storage.getApiModel(), 'google/gemini-2.5-pro', 'Modell sollte gespeichert sein'); localStorage.removeItem('dienstplan_openrouter_model'); }); runner.test('Storage API Key: exportData enthält keinen API-Key', (t) => { const storage = new DataStorage(); storage.setApiKey('sk-or-secret'); const exported = storage.exportData(); t.assertFalse(exported.includes('sk-or-secret'), 'Key darf nicht im Export sein'); storage.clearApiKey(); }); runner.test('Storage API Key: clearAll laesst API-Key unberuehrt', (t) => { const storage = new DataStorage(); storage.setApiKey('sk-or-keep'); storage.clearAll(); t.assertEqual(storage.getApiKey(), 'sk-or-keep', 'Key sollte clearAll ueberleben'); storage.clearApiKey(); }); // ============================================================================ // ImageImporter Tests - Preprocessing (Feature A) // ============================================================================ /** * Helper: build a synthetic image File from a canvas. */ async function makeTestImageFile(width, height, mime = 'image/png') { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#3366cc'; ctx.fillRect(0, 0, width, height); ctx.fillStyle = '#ffffff'; ctx.font = '32px sans-serif'; ctx.fillText('TEST', 20, 50); const blob = await new Promise(res => canvas.toBlob(res, mime)); return new File([blob], 'test.png', { type: mime }); } runner.test('Preprocess: 4000x3000 wird auf laengste Kante 2048 skaliert', async (t) => { const importer = new ImageImporter(null); const file = await makeTestImageFile(4000, 3000); const result = await importer.compressImage(file); t.assertEqual(result.width, 2048, 'Breite sollte 2048 sein'); t.assertEqual(result.height, 1536, 'Hoehe sollte 1536 sein (Seitenverhaeltnis erhalten)'); t.assertTrue(result.dataUrl.startsWith('data:image/jpeg;base64,'), 'dataUrl-Prefix korrekt'); }); runner.test('Preprocess: 800x600 bleibt unveraendert (kein Upscale)', async (t) => { const importer = new ImageImporter(null); const file = await makeTestImageFile(800, 600); const result = await importer.compressImage(file); t.assertEqual(result.width, 800, 'Breite unveraendert'); t.assertEqual(result.height, 600, 'Hoehe unveraendert'); }); runner.test('Preprocess: Output ist immer JPEG', async (t) => { const importer = new ImageImporter(null); const file = await makeTestImageFile(500, 500, 'image/png'); const result = await importer.compressImage(file); t.assertTrue(result.dataUrl.startsWith('data:image/jpeg;base64,'), 'Output ist JPEG'); t.assertTrue(result.dataUrl.length > 1000, 'Output-Laenge > 1KB'); }); // ============================================================================ // ImageImporter Tests - callVisionAPI (Feature A) // ============================================================================ /** * Helper to mock fetch and restore it. */ function withMockedFetch(mockFn, fn) { const originalFetch = globalThis.fetch; globalThis.fetch = mockFn; const restore = () => { globalThis.fetch = originalFetch; }; return Promise.resolve(fn()).finally(restore); } runner.test('CallVisionAPI: erfolgreicher 200-Response', async (t) => { const importer = new ImageImporter(null); let capturedUrl = null; let capturedInit = null; const mockFetch = async (url, init) => { capturedUrl = url; capturedInit = init; return new Response(JSON.stringify({ choices: [{ message: { content: '{"entries":[]}' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; await withMockedFetch(mockFetch, async () => { const content = await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'sk-test', 'anthropic/claude-sonnet-4.6'); t.assertEqual(content, '{"entries":[]}', 'Content extrahiert'); t.assertEqual(capturedUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Endpoint korrekt'); t.assertTrue(capturedInit.headers['Authorization'] === 'Bearer sk-test', 'Auth-Header korrekt'); }); }); runner.test('CallVisionAPI: 401 wirft mit Status', async (t) => { const importer = new ImageImporter(null); const mockFetch = async () => new Response('', { status: 401 }); await withMockedFetch(mockFetch, async () => { try { await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'bad', 'anthropic/claude-sonnet-4.6'); t.assertTrue(false, 'Sollte werfen'); } catch (e) { t.assertEqual(e.status, 401, 'Status auf Error'); t.assertEqual(e.name, 'OpenRouterError', 'Typisierter Fehler'); } }); }); runner.test('CallVisionAPI: 429 wirft mit Status', async (t) => { const importer = new ImageImporter(null); const mockFetch = async () => new Response('', { status: 429 }); await withMockedFetch(mockFetch, async () => { try { await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'k', 'm'); t.assertTrue(false, 'Sollte werfen'); } catch (e) { t.assertEqual(e.status, 429, '429 wird durchgereicht'); } }); }); runner.test('CallVisionAPI: 503 wirft mit Status', async (t) => { const importer = new ImageImporter(null); const mockFetch = async () => new Response('', { status: 503 }); await withMockedFetch(mockFetch, async () => { try { await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'k', 'm'); t.assertTrue(false, 'Sollte werfen'); } catch (e) { t.assertEqual(e.status, 503, '503 wird durchgereicht'); } }); }); // ============================================================================ // ImageImporter Tests - parseResponse (Feature A) // ============================================================================ runner.test('Parse: cleanes JSON wird geparst', (t) => { const importer = new ImageImporter(null); const raw = '{"month":11,"year":2025,"entries":[{"name":"Max","date":"2025-11-22","share":1.0}],"notes":[]}'; const result = importer.parseResponse(raw); t.assertEqual(result.entries.length, 1, '1 Eintrag'); t.assertEqual(result.entries[0].name, 'Max', 'Name korrekt'); t.assertEqual(result.month, 11, 'Monat korrekt'); }); runner.test('Parse: JSON in Markdown-Fence wird gestrippt', (t) => { const importer = new ImageImporter(null); const raw = '```json\n{"entries":[{"name":"A","date":"2025-11-22","share":0.5}],"notes":[]}\n```'; const result = importer.parseResponse(raw); t.assertEqual(result.entries.length, 1, 'Fence wurde entfernt'); }); runner.test('Parse: JSON mit Vortext wird per Brace-Slicing extrahiert', (t) => { const importer = new ImageImporter(null); const raw = 'Hier das Ergebnis:\n{"entries":[{"name":"A","date":"2025-11-22","share":1.0}],"notes":[]}'; const result = importer.parseResponse(raw); t.assertEqual(result.entries.length, 1, 'Vortext ignoriert'); }); runner.test('Parse: Malformed JSON wirft SyntaxError', (t) => { const importer = new ImageImporter(null); try { importer.parseResponse('das ist kein JSON'); t.assertTrue(false, 'Sollte werfen'); } catch (e) { t.assertTrue(e instanceof SyntaxError || e.name === 'SyntaxError' || /JSON|Parse/i.test(e.message), 'SyntaxError erwartet'); } }); runner.test('Parse: fehlendes entries-Feld wirft', (t) => { const importer = new ImageImporter(null); try { importer.parseResponse('{"month":11,"year":2025,"notes":[]}'); t.assertTrue(false, 'Sollte werfen'); } catch (e) { t.assertTrue(/entries/i.test(e.message), 'Fehlermeldung erwaehnt entries'); } }); runner.test('Parse: share=0.75 verwirft den Eintrag', (t) => { const importer = new ImageImporter(null); const raw = '{"entries":[{"name":"A","date":"2025-11-22","share":0.75},{"name":"B","date":"2025-11-23","share":1.0}],"notes":[]}'; const result = importer.parseResponse(raw); t.assertEqual(result.entries.length, 1, 'Nur gueltiger Eintrag bleibt'); t.assertEqual(result.entries[0].name, 'B', 'B uebrig'); }); runner.test('Parse: invalides Datum verwirft den Eintrag', (t) => { const importer = new ImageImporter(null); const raw = '{"entries":[{"name":"A","date":"31.11.2025","share":1.0},{"name":"B","date":"2025-11-22","share":1.0}],"notes":[]}'; const result = importer.parseResponse(raw); t.assertEqual(result.entries.length, 1, 'Nur ISO-Datum bleibt'); t.assertEqual(result.entries[0].name, 'B', 'B uebrig'); }); runner.test('Parse: leerer Name wird verworfen', (t) => { const importer = new ImageImporter(null); const raw = '{"entries":[{"name":" ","date":"2025-11-22","share":1.0},{"name":"B","date":"2025-11-22","share":1.0}],"notes":[]}'; const result = importer.parseResponse(raw); t.assertEqual(result.entries.length, 1, 'Nur gueltiger Name bleibt'); }); // ============================================================================ // ImageImporter Tests - Levenshtein (Feature A) // ============================================================================ runner.test('Levenshtein: identische Strings = 0', (t) => { const importer = new ImageImporter(null); t.assertEqual(importer.levenshtein('max mustermann', 'max mustermann'), 0, 'Identisch'); }); runner.test('Levenshtein: leerer String', (t) => { const importer = new ImageImporter(null); t.assertEqual(importer.levenshtein('', 'abc'), 3, '0 vs 3 Zeichen'); t.assertEqual(importer.levenshtein('abc', ''), 3, '3 vs 0 Zeichen'); t.assertEqual(importer.levenshtein('', ''), 0, 'Beide leer'); }); runner.test('Levenshtein: 1 Substitution', (t) => { const importer = new ImageImporter(null); t.assertEqual(importer.levenshtein('abc', 'abd'), 1, '1 Subst'); }); runner.test('Levenshtein: 1 Insertion', (t) => { const importer = new ImageImporter(null); t.assertEqual(importer.levenshtein('max mustermann', 'max mustermannn'), 1, '1 zusaetzliches n'); }); runner.test('Levenshtein: 2 Distanz', (t) => { const importer = new ImageImporter(null); t.assertEqual(importer.levenshtein('mueller', 'mueler'), 1, 'ein l weniger'); }); // ============================================================================ // ImageImporter Tests - normalizeName, matchNames, classify (Feature A) // ============================================================================ runner.test('Match: exakter Match (case + whitespace identisch)', (t) => { const importer = new ImageImporter(null); const r = importer.matchNames( [{ name: 'Max Mustermann', date: '2025-11-22', share: 1.0 }], ['Max Mustermann'] ); t.assertEqual(r.matched.length, 1, '1 zugeordnet'); t.assertEqual(r.matched[0].resolvedName, 'Max Mustermann', 'Direkt aufgeloest'); t.assertEqual(r.unknowns.length, 0, 'Keine Unknowns'); }); runner.test('Match: normalisierter Match (Whitespace + Case)', (t) => { const importer = new ImageImporter(null); const r = importer.matchNames( [{ name: ' MAX mustermann ', date: '2025-11-22', share: 1.0 }], ['Max Mustermann'] ); t.assertEqual(r.matched.length, 1, '1 zugeordnet'); t.assertEqual(r.matched[0].resolvedName, 'Max Mustermann', 'Normalisiert aufgeloest'); }); runner.test('Match: Fuzzy mit Distance 1', (t) => { const importer = new ImageImporter(null); const r = importer.matchNames( [{ name: 'Max Mustermannn', date: '2025-11-22', share: 1.0 }], ['Max Mustermann'] ); t.assertEqual(r.matched.length, 0, 'Nicht automatisch gematcht'); t.assertEqual(r.unknowns.length, 1, '1 Unknown'); t.assertEqual(r.unknowns[0].suggested, 'Max Mustermann', 'Vorschlag = naechster'); t.assertEqual(r.unknowns[0].candidate, 'Max Mustermannn', 'Original-Kandidat'); }); runner.test('Match: Distance > 2 ohne Vorschlag', (t) => { const importer = new ImageImporter(null); const r = importer.matchNames( [{ name: 'Egon Olsen', date: '2025-11-22', share: 1.0 }], ['Max Mustermann'] ); t.assertEqual(r.unknowns.length, 1, '1 Unknown'); t.assertEqual(r.unknowns[0].suggested, null, 'Kein Vorschlag'); }); runner.test('Match: leere Employee-Liste alle Unknowns', (t) => { const importer = new ImageImporter(null); const r = importer.matchNames( [{ name: 'Max', date: '2025-11-22', share: 1.0 }], [] ); t.assertEqual(r.unknowns.length, 1, 'Unknown'); t.assertEqual(r.unknowns[0].suggested, null, 'Kein Vorschlag moeglich'); }); runner.test('Match: mehrere Fuzzy-Treffer gleiche Distanz alphabetisch erster', (t) => { const importer = new ImageImporter(null); const r = importer.matchNames( [{ name: 'Anne', date: '2025-11-22', share: 1.0 }], ['Anna', 'Anni'] ); t.assertEqual(r.unknowns[0].suggested, 'Anna', 'Alphabetisch erster'); }); runner.test('Classify: Freitag = fr', (t) => { const importer = new ImageImporter(null); importer.holidayProvider = new HolidayProvider(); const fri = new Date('2025-11-21T12:00:00'); t.assertEqual(importer.classify(fri), 'fr', 'Freitag'); }); runner.test('Classify: Samstag = sa', (t) => { const importer = new ImageImporter(null); importer.holidayProvider = new HolidayProvider(); const sat = new Date('2025-11-22T12:00:00'); t.assertEqual(importer.classify(sat), 'sa', 'Samstag'); }); runner.test('Classify: Feiertag (Werktag) = so', (t) => { const importer = new ImageImporter(null); importer.holidayProvider = new HolidayProvider(); const may1 = new Date('2025-05-01T12:00:00'); t.assertEqual(importer.classify(may1), 'so', 'Feiertag = so'); }); runner.test('Classify: Tag vor Feiertag (Werktag) = fr', (t) => { const importer = new ImageImporter(null); importer.holidayProvider = new HolidayProvider(); const apr30 = new Date('2025-04-30T12:00:00'); t.assertEqual(importer.classify(apr30), 'fr', 'Tag vor Feiertag'); }); runner.test('Classify: Werktag = weekday', (t) => { const importer = new ImageImporter(null); importer.holidayProvider = new HolidayProvider(); const mon = new Date('2025-11-24T12:00:00'); t.assertEqual(importer.classify(mon), 'weekday', 'Werktag'); }); // ============================================================================ // ImageImporter Tests - resolveImports (pure) (Feature A) // ============================================================================ runner.test('Resolve: gemischte unknowns (new + assign + ignore)', (t) => { const importer = new ImageImporter(null); const session = { entries: [ { name: 'Max Mustermann', date: new Date('2025-11-22T12:00:00'), dateStr: '2025-11-22', share: 1.0 }, { name: 'Max Mustermannn', date: new Date('2025-11-23T12:00:00'), dateStr: '2025-11-23', share: 0.5 }, { name: 'Egon Olsen', date: new Date('2025-11-28T12:00:00'), dateStr: '2025-11-28', share: 1.0 }, { name: 'Hugo Ignored', date: new Date('2025-11-29T12:00:00'), dateStr: '2025-11-29', share: 1.0 } ], unknowns: [ { candidate: 'Max Mustermannn', suggested: 'Max Mustermann', choice: 'assign:Max Mustermann' }, { candidate: 'Egon Olsen', suggested: null, choice: 'new' }, { candidate: 'Hugo Ignored', suggested: null, choice: 'ignore' } ], resolvedNames: new Map([['Max Mustermann', 'Max Mustermann']]), targetYear: 2025, targetMonth: 11 }; const plan = importer.resolveImports(session); t.assertEqual(plan.newEmployees.length, 1, '1 neuer MA'); t.assertEqual(plan.newEmployees[0], 'Egon Olsen', 'Egon ist neu'); t.assertEqual(plan.commits.length, 3, '3 Commits (Hugo ignoriert)'); t.assertEqual(plan.skippedOutsideMonth, 0, 'Keine ausserhalb Monat'); const max22 = plan.commits.find(c => c.employeeName === 'Max Mustermann' && c.dateStr === '2025-11-22'); t.assertTrue(!!max22, 'Max am 22.11 vorhanden'); t.assertEqual(max22.share, 1.0, 'Share 1.0'); const maxFromFuzzy = plan.commits.find(c => c.employeeName === 'Max Mustermann' && c.dateStr === '2025-11-23'); t.assertTrue(!!maxFromFuzzy, 'Fuzzy-Match wurde aufgeloest'); t.assertEqual(maxFromFuzzy.share, 0.5, 'Share 0.5'); const egon = plan.commits.find(c => c.employeeName === 'Egon Olsen'); t.assertTrue(!!egon, 'Egon committed'); }); runner.test('Resolve: ausserhalb Monat wird uebersprungen', (t) => { const importer = new ImageImporter(null); const session = { entries: [ { name: 'A', date: new Date('2025-11-22T12:00:00'), dateStr: '2025-11-22', share: 1.0 }, { name: 'A', date: new Date('2025-12-01T12:00:00'), dateStr: '2025-12-01', share: 1.0 } ], unknowns: [{ candidate: 'A', suggested: null, choice: 'new' }], resolvedNames: new Map(), targetYear: 2025, targetMonth: 11 }; const plan = importer.resolveImports(session); t.assertEqual(plan.commits.length, 1, 'Nur November-Eintrag bleibt'); t.assertEqual(plan.skippedOutsideMonth, 1, '1 uebersprungen'); }); // ============================================================================ // ImageImporter Tests - Error toasts (Feature A) // ============================================================================ runner.test('ImageImporter Error: 401 = "API-Key ungueltig"', (t) => { let capturedMsg = null; let capturedType = null; const fakeApp = { showToast: (m, type) => { capturedMsg = m; capturedType = type; }, currentYear: 2025, currentMonth: 11, storage: { getEmployees: () => [] }, holidayProvider: new HolidayProvider() }; const importer = new ImageImporter(fakeApp); const err = Object.assign(new Error('x'), { name: 'OpenRouterError', status: 401 }); importer.showStage = () => {}; importer.handleRecognitionError(err); t.assertEqual(capturedMsg, 'API-Key ungueltig', 'Exakte Meldung'); t.assertEqual(capturedType, 'error', 'Typ error'); }); runner.test('ImageImporter Error: 402 = "Limit erreicht oder Guthaben aufgebraucht"', (t) => { let capturedMsg = null; const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() }; const importer = new ImageImporter(fakeApp); importer.showStage = () => {}; importer.handleRecognitionError(Object.assign(new Error('x'), { name: 'OpenRouterError', status: 402 })); t.assertEqual(capturedMsg, 'Limit erreicht oder Guthaben aufgebraucht', 'Exakte Meldung'); }); runner.test('ImageImporter Error: 429 = "Limit erreicht oder Guthaben aufgebraucht"', (t) => { let capturedMsg = null; const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() }; const importer = new ImageImporter(fakeApp); importer.showStage = () => {}; importer.handleRecognitionError(Object.assign(new Error('x'), { name: 'OpenRouterError', status: 429 })); t.assertEqual(capturedMsg, 'Limit erreicht oder Guthaben aufgebraucht', 'Exakte Meldung'); }); runner.test('ImageImporter Error: 503 = Server-Fehler', (t) => { let capturedMsg = null; const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() }; const importer = new ImageImporter(fakeApp); importer.showStage = () => {}; importer.handleRecognitionError(Object.assign(new Error('x'), { name: 'OpenRouterError', status: 503 })); t.assertTrue(capturedMsg.includes('Server-Fehler'), 'Enthaelt Server-Fehler'); t.assertTrue(capturedMsg.includes('503'), 'Enthaelt Status'); }); runner.test('ImageImporter Error: TypeError (Offline) = "Keine Verbindung"', (t) => { let capturedMsg = null; const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() }; const importer = new ImageImporter(fakeApp); importer.showStage = () => {}; importer.handleRecognitionError(new TypeError('Failed to fetch')); t.assertTrue(capturedMsg.includes('Keine Verbindung'), 'Offline-Meldung'); }); runner.test('ImageImporter Error: SyntaxError = "Erkennung fehlgeschlagen"', (t) => { let capturedMsg = null; const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() }; const importer = new ImageImporter(fakeApp); importer.showStage = () => {}; importer.handleRecognitionError(new SyntaxError('Unexpected token')); t.assertTrue(capturedMsg.includes('Erkennung fehlgeschlagen'), 'Parse-Fehlermeldung'); }); // ============================================================================ // Display Functions // ============================================================================ async function runAllTests() { const resultsContainer = document.getElementById('test-results'); const summaryDiv = document.getElementById('summary'); const runButton = document.getElementById('run-tests'); // Clear previous results resultsContainer.innerHTML = '

Tests laufen...

'; runButton.disabled = true; // Run tests const results = await runner.runAll(); const summary = runner.getSummary(); // Update summary document.getElementById('total-tests').textContent = summary.total; document.getElementById('passed-tests').textContent = summary.passed; document.getElementById('failed-tests').textContent = summary.failed; summaryDiv.style.display = 'flex'; // Display results resultsContainer.innerHTML = ''; // Group by category const categories = { 'Holiday Provider': [], 'Calculator - Tag-Klassifizierung': [], 'Calculator - Bonusberechnung': [], 'Storage': [], 'Storage API Key': [], 'Image Importer': [], 'Edge Cases': [] }; results.forEach(result => { if (result.name.includes('HolidayProvider')) { categories['Holiday Provider'].push(result); } else if (result.name.includes('qualifizierender Tag') || result.name.includes('Feiertag ist')) { categories['Calculator - Tag-Klassifizierung'].push(result); } else if (result.name.includes('Berechnung:')) { categories['Calculator - Bonusberechnung'].push(result); } else if (result.name.startsWith('Storage API')) { categories['Storage API Key'].push(result); } else if (result.name.includes('Storage:')) { categories['Storage'].push(result); } else if (result.name.startsWith('ImageImporter') || result.name.startsWith('Levenshtein') || result.name.startsWith('Parse') || result.name.startsWith('Match') || result.name.startsWith('Preprocess') || result.name.startsWith('Resolve') || result.name.startsWith('CallVision') || result.name.startsWith('Classify')) { categories['Image Importer'].push(result); } else if (result.name.includes('Edge Case:')) { categories['Edge Cases'].push(result); } }); // Render categories for (const [category, tests] of Object.entries(categories)) { if (tests.length === 0) continue; const suiteDiv = document.createElement('div'); suiteDiv.className = 'test-suite'; const title = document.createElement('h2'); title.textContent = `${category} (${tests.filter(t => t.passed).length}/${tests.length})`; suiteDiv.appendChild(title); tests.forEach(result => { const testDiv = document.createElement('div'); testDiv.className = `test-case ${result.passed ? 'pass' : 'fail'}`; const nameDiv = document.createElement('div'); nameDiv.className = 'test-name'; nameDiv.textContent = `${result.passed ? '✅' : '❌'} ${result.name}`; testDiv.appendChild(nameDiv); if (!result.passed && result.error) { const errorDiv = document.createElement('div'); errorDiv.className = 'error-details'; errorDiv.textContent = result.error; testDiv.appendChild(errorDiv); } suiteDiv.appendChild(testDiv); }); resultsContainer.appendChild(suiteDiv); } runButton.disabled = false; // Scroll to summary summaryDiv.scrollIntoView({ behavior: 'smooth' }); } // Auto-run on load (optional) // window.addEventListener('load', runAllTests);