import { test } from 'node:test'; import assert from 'node:assert/strict'; import { workingDays, generateSchedule, isAbsent, addDays, isValidDate, countAssignments } from '../server/scheduler.js'; function seeded(seed = 42) { let s = seed; return () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; }; } const persons = (n, extra = {}) => Array.from({ length: n }, (_, i) => ({ id: `p${i}`, canMain: true, canCo: true, active: true, ...extra })); test('workingDays liefert nur Mo–Fr ohne blockierte Tage', () => { // 2026-09-14 ist ein Montag const days = workingDays('2026-09-12', '2026-09-20', new Set(['2026-09-16'])); assert.deepEqual(days, ['2026-09-14', '2026-09-15', '2026-09-17', '2026-09-18']); }); test('isValidDate lehnt Unsinn ab', () => { assert.ok(isValidDate('2026-02-28')); assert.ok(!isValidDate('2026-02-30')); assert.ok(!isValidDate('2026-2-3')); assert.ok(!isValidDate('heute')); }); test('jeder Werktag bekommt zwei verschiedene Personen', () => { const dates = workingDays('2026-10-01', '2026-10-31'); const result = generateSchedule({ dates, persons: persons(6), rng: seeded(1) }); for (const d of dates) { assert.ok(result[d].main, `main fehlt am ${d}`); assert.ok(result[d].co, `co fehlt am ${d}`); assert.notEqual(result[d].main, result[d].co); } }); test('Einsätze sind fair verteilt (Differenz höchstens 1)', () => { const dates = workingDays('2026-10-01', '2026-12-31'); const result = generateSchedule({ dates, persons: persons(7), rng: seeded(7) }); const counts = countAssignments(result, '2026-10-01', '2026-12-31'); const totals = Object.values(counts).map((c) => c.total); assert.equal(totals.length, 7); assert.ok(Math.max(...totals) - Math.min(...totals) <= 1, `Verteilung: ${totals}`); // Rollen ebenfalls ausgewogen for (const c of Object.values(counts)) assert.ok(Math.abs(c.main - c.co) <= 2, JSON.stringify(c)); }); test('Abwesende werden nicht eingeplant', () => { const dates = workingDays('2026-10-05', '2026-10-09'); const absences = [{ personId: 'p0', from: '2026-10-06', to: '2026-10-08' }]; const result = generateSchedule({ dates, persons: persons(3), absences, rng: seeded(3) }); for (const d of ['2026-10-06', '2026-10-07', '2026-10-08']) { assert.notEqual(result[d].main, 'p0'); assert.notEqual(result[d].co, 'p0'); } assert.ok(isAbsent('p0', '2026-10-07', absences)); assert.ok(!isAbsent('p0', '2026-10-09', absences)); }); test('Nicht-Moderatoren und Inaktive werden nie ausgelost, Nur-Neben nie Haupt', () => { const ps = [ ...persons(3), { id: 'none', canMain: false, canCo: false, active: true }, { id: 'gone', canMain: true, canCo: true, active: false }, { id: 'coonly', canMain: false, canCo: true, active: true }, ]; const dates = workingDays('2026-10-01', '2026-11-30'); const result = generateSchedule({ dates, persons: ps, rng: seeded(9) }); const used = new Set(); for (const a of Object.values(result)) { used.add(a.main); used.add(a.co); assert.notEqual(a.main, 'coonly'); } assert.ok(!used.has('none')); assert.ok(!used.has('gone')); assert.ok(used.has('coonly')); }); test('niemand an zwei Werktagen hintereinander, wenn genug Leute da sind', () => { const dates = workingDays('2026-10-01', '2026-10-31'); const result = generateSchedule({ dates, persons: persons(6), rng: seeded(11) }); for (let i = 1; i < dates.length; i += 1) { const prev = [result[dates[i - 1]].main, result[dates[i - 1]].co]; const cur = [result[dates[i]].main, result[dates[i]].co]; for (const id of cur) assert.ok(!prev.includes(id), `${id} am ${dates[i - 1]} und ${dates[i]}`); } }); test('fixierte und freie Tage bleiben erhalten, keep-Tage ebenfalls', () => { const dates = workingDays('2026-10-05', '2026-10-09'); const existing = { '2026-10-05': { main: 'p2', co: 'p1', locked: true }, '2026-10-06': { off: true, main: null, co: null, locked: false }, '2026-10-07': { main: 'p0', co: 'p2', locked: false, keep: true }, }; const result = generateSchedule({ dates, persons: persons(4), existing, rng: seeded(5) }); assert.deepEqual(result['2026-10-05'], { main: 'p2', co: 'p1', locked: true }); assert.equal(result['2026-10-06'].off, true); assert.deepEqual(result['2026-10-07'], { main: 'p0', co: 'p2', locked: false }); assert.ok(result['2026-10-08'].main); }); test('Historie zählt für die Fairness – wer viel dran war, kommt seltener', () => { const history = {}; // p0 und p1 waren in den letzten zwei Wochen jeden Tag dran for (const d of workingDays('2026-09-14', '2026-09-25')) history[d] = { main: 'p0', co: 'p1' }; const dates = workingDays('2026-09-28', '2026-10-09'); const result = generateSchedule({ dates, persons: persons(6), history, rng: seeded(2) }); const counts = countAssignments(result, '2026-09-28', '2026-10-09'); const heavy = (counts.p0?.total || 0) + (counts.p1?.total || 0); const light = ['p2', 'p3', 'p4', 'p5'].reduce((n, id) => n + (counts[id]?.total || 0), 0); assert.equal(heavy, 0, `p0/p1 sollten aussetzen: ${JSON.stringify(counts)}`); assert.equal(light, dates.length * 2); }); test('ohne Kandidaten bleibt der Platz leer statt zu scheitern', () => { const result = generateSchedule({ dates: ['2026-10-05'], persons: [{ id: 'a', canMain: true, canCo: false, active: true }], rng: seeded(1) }); assert.equal(result['2026-10-05'].main, 'a'); assert.equal(result['2026-10-05'].co, null); const empty = generateSchedule({ dates: ['2026-10-05'], persons: [] }); assert.equal(empty['2026-10-05'].main, null); }); test('addDays über Monatsgrenzen', () => { assert.equal(addDays('2026-01-31', 1), '2026-02-01'); assert.equal(addDays('2026-03-01', -1), '2026-02-28'); }); test('feste freie Wochentage (Teilzeit) werden respektiert', async () => { const { isAvailable, isoWeekday } = await import('../server/scheduler.js'); assert.equal(isoWeekday('2026-10-05'), 1); assert.equal(isoWeekday('2026-10-11'), 7); const ps = [...persons(4), { id: 'teilzeit', canMain: true, canCo: true, active: true, freeDays: [1, 5] }]; const dates = workingDays('2026-10-01', '2026-11-30'); const result = generateSchedule({ dates, persons: ps, rng: seeded(4) }); for (const d of dates) { const wd = isoWeekday(d); if (wd === 1 || wd === 5) assert.ok(result[d].main !== 'teilzeit' && result[d].co !== 'teilzeit', `Teilzeit am ${d}`); } assert.ok(Object.values(result).some((a) => a.main === 'teilzeit' || a.co === 'teilzeit')); assert.ok(!isAvailable({ id: 'x', freeDays: [3] }, '2026-10-07', [])); assert.ok(isAvailable({ id: 'x', freeDays: [3] }, '2026-10-08', [])); }); test('„nicht zwei Werktage in Folge" gilt auch über einen Feiertag hinweg', () => { // Mi 2026-10-07 blockiert → Di und Do gelten als aufeinanderfolgend const blocked = new Set(['2026-10-07']); const dates = workingDays('2026-10-06', '2026-10-08', blocked); assert.deepEqual(dates, ['2026-10-06', '2026-10-08']); for (let seed = 1; seed <= 20; seed += 1) { const result = generateSchedule({ dates, persons: persons(6), blocked, rng: seeded(seed) }); const tue = [result['2026-10-06'].main, result['2026-10-06'].co]; for (const id of [result['2026-10-08'].main, result['2026-10-08'].co]) assert.ok(!tue.includes(id), `seed ${seed}: ${id} Di und Do`); } }); test('fill: halb besetzte Tage werden nur aufgefüllt, der vorhandene Slot bleibt', () => { const existing = { '2026-10-05': { main: 'p2', co: null, locked: false, fill: true }, '2026-10-06': { main: null, co: 'p1', locked: false, fill: true } }; const result = generateSchedule({ dates: ['2026-10-05', '2026-10-06'], persons: persons(4), existing, rng: seeded(3) }); assert.equal(result['2026-10-05'].main, 'p2'); assert.ok(result['2026-10-05'].co && result['2026-10-05'].co !== 'p2'); assert.equal(result['2026-10-06'].co, 'p1'); assert.ok(result['2026-10-06'].main && result['2026-10-06'].main !== 'p1'); assert.equal('fill' in result['2026-10-05'], false); });