Conflicts resolved: - sw.js: bumped CACHE_NAME to dienstplan-pro-v4 (was v3 + v2). Both variants.js and image-import.js are in ASSETS. - storage.js: kept STORAGE_KEY_DUTIES + STORAGE_KEY_VACATION (Feature B) alongside STORAGE_KEY_OPENROUTER_KEY/MODEL + DEFAULT_MODEL (Feature A). - styles.css: appended Feature B variants/vacation/date-stepper rules after Feature A modal/key rules; both blocks coexist.
1527 lines
65 KiB
JavaScript
1527 lines
65 KiB
JavaScript
/**
|
|
* 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 = '<p>Tests laufen...</p>';
|
|
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);
|