Files
Rotatory/test/scheduler.test.js
T
ferdi2goandClaude Fable 5.1 95756778d8 Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:37 +00:00

160 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});