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>
This commit is contained in:
commit
95756778d8
26 files changed
+4697
No files matched your search
@@ -0,0 +1,159 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user