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:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+159
View File
@@ -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);
});