Öffnungszeiten: Editor für Pläne, Feiertage und Ausnahmen

Speichern über PUT /api/zeiten (Version, 409, Feldfehler per ID-Pfad), Zeitpläne mit
Wochentagen, Hauptplan wechseln und Löschen (R10, R33), Feiertage je Bundesland,
Betriebsurlaub und Sondertage mit Hinweis-Leisten-Frage. Die Hülle behält den Editor
bei einem neuen Stand und zeichnet nur den Kopf neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:02:31 +00:00
1 parent cee44d6852
commit 04a3525f79
8 files changed
+1974 -21

No files matched your search

+961
View File
@@ -0,0 +1,961 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { config } from '../server/config.js'
import { oeffentlicheAnsicht } from '../server/services/featureRegistry.js'
import { h } from '../public/termine-dom.js'
import { heuteBerlin, tagPlus } from '../public/termine-view.js'
import { ZEITEN_LIMITS, hinweisVorschlag } from '../public/zeiten-view.js'
import { initZeiten } from '../public/zeiten.js'
// Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht
import * as editor from '../public/zeiten-editor.js'
import * as speichern from '../public/zeiten-speichern.js'
// Editor der Öffnungszeiten live, Teil I (Task 29): Speichern, Zeitpläne, Feiertage, Ausnahmen (public/zeiten-editor.js, zeiten-speichern.js,
// zeiten-plaene.js, zeiten-ausnahmen.js; Spec 3.4 Bereiche 1 bis 3 und der Absatz „Speichern“). Fake-DOM und Fake-fetch aus
// test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach aus fx.antworten.
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
// ---------------------------------------------------------------------------------------------------------------- Fixtures
const HEUTE = heuteBerlin()
const P1 = 'p_haupt001'
const P2 = 'p_kueche02'
const WOCHE = ['1', '2', '3', '4', '5', '6', '7']
const wochenplan = (extra = {}) => Object.fromEntries(WOCHE.map((nr) => [nr, extra[nr] || { modus: 'zu', fenster: [] }]))
const werktags = () => wochenplan({
1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] },
2: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
3: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
4: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
5: { modus: 'zeiten', fenster: [['09:00', '16:00']] },
6: { modus: 'rund', fenster: [] },
})
const plan = (id, extra = {}) => ({ id, kurz: id === P1 ? 'oeffnungszeiten' : 'kueche', name: id === P1 ? 'Öffnungszeiten' : 'Küche', haupt: id === P1, tage: werktags(), ...extra })
const KONTAKT = { an: true, telefon: '496301123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', whatsapp: { nummer: '4917112345678', starttext: 'Hallo!', bestaetigtAt: '2026-10-01T08:00:00.000Z' } }
const daten = (extra = {}) => ({ land: 'RP', plaene: [plan(P1)], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: KONTAKT, ...extra })
const FEIERTAGE = [
{ tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' },
{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' },
{ tag: '2026-12-31', name: 'Silvester', art: 'pruefen' },
{ tag: '2027-01-01', name: 'Neujahr', art: 'feiertag' },
]
const stand = (d, extra = {}) => ({ state: 'active', daten: d, version: d ? 3 : 0, kundenansicht: false, vorschau: null, feiertage: FEIERTAGE, googleText: '', ...extra })
const antwort = (d, version = 4, extra = {}) => ({ status: 200, body: stand(d, { version, ...extra }) })
const fehler = (status, error, extra = {}) => ({ status, body: { error, ...extra } })
// ---------------------------------------------------------------------------------------------------------------- Hilfen
/** Den Editor in einen Platz mit data-state hängen und laden; die Antwort des GET steht als erste in der Warteschlange. */
async function oeffne(fx, { start = stand(daten()), state = 'active', kundenansicht, ctx = {} } = {}) {
const root = h('div', { class: 'zt-editor', 'data-state': state })
document.body.append(root)
fx.antworten.unshift({ status: 200, body: start })
const neuGezeichnet = []
await editor.renderEditor(root, { projectId: 'p1', kundenansicht, neuZeichnen: async (optionen) => { neuGezeichnet.push(optionen) }, ...ctx })
return { root, neuGezeichnet }
}
const puts = (fx) => fx.fetchLog.filter((a) => a.method === 'PUT')
const gets = (fx) => fx.fetchLog.filter((a) => a.method === 'GET')
const planKarte = (fx, root, id) => fx.finde(root, `details[data-plan="${id}"]`)
const tagZeile = (fx, root, id, nr) => fx.finde(planKarte(fx, root, id), `[data-tag="${nr}"]`)
const zeitFelder = (fx, zeile) => fx.alle(zeile, 'input[type="time"]')
const haken = (fx, zeile) => fx.finde(zeile, 'input[type="checkbox"]')
const art = (fx, zeile) => fx.finde(zeile, 'select')
const speichernKnopf = (fx, root, id) => fx.finde(planKarte(fx, root, id), '[data-aktion="speichern"]')
const abschnitt = (fx, root, key) => fx.finde(root, `[data-abschnitt="${key}"]`)
const meldung = (fx, root) => fx.text(fx.finde(root, '[data-meldung="editor"]'))
const fehlerAn = (fx, root, pfad) => fx.finde(root, `[data-fehler="${pfad}"]`)
const lesen = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const optionen = (fx, select) => fx.alle(select, 'option').map((o) => o.value)
// ---------------------------------------------------------------------------------------------------------------- Leerer Start
test('leerer Start: „Trag deine Öffnungszeiten ein“ und ein leerer Hauptplan „Öffnungszeiten“ (Kurzname oeffnungszeiten)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
assert.deepEqual(gets(fx).map((a) => a.url), ['/api/zeiten/p1'])
assert.match(fx.text(fx.finde(root, '[data-hinweis="leer"]')), /Trag deine Öffnungszeiten ein/)
const karten = fx.alle(root, 'details[data-plan]')
assert.equal(karten.length, 1, 'genau ein Plan')
const kopf = fx.finde(karten[0], 'summary')
assert.match(fx.text(kopf), /Öffnungszeiten/)
assert.match(fx.text(kopf), /Hauptplan/)
assert.equal(karten[0].hasAttribute('open'), true, 'der Hauptplan steht offen')
assert.equal(fx.text(fx.finde(karten[0], '[data-kurz]')), 'oeffnungszeiten')
const id = karten[0].getAttribute('data-plan')
assert.match(id, /^p_[a-z0-9]{8}$/)
assert.equal(fx.finde(karten[0], `[data-feld="plaene.${id}.name"]`).value, 'Öffnungszeiten')
for (const nr of WOCHE) {
const zeile = tagZeile(fx, root, id, nr)
assert.equal(haken(fx, zeile).checked, false, `Tag ${nr} ist geschlossen`)
}
assert.equal(fx.finde(karten[0], '[data-aktion="entfernen"]'), null, 'der einzige Plan lässt sich nie löschen')
assert.equal(fx.finde(root, '[data-werkzeug="plan-neu"]'), null, 'weitere Pläne erst nach dem ersten Speichern')
assert.equal(abschnitt(fx, root, 'feiertage'), null, 'Feiertage und Ausnahmen erst nach dem ersten Speichern')
assert.equal(abschnitt(fx, root, 'ausnahmen'), null)
assert.match(fx.text(root), /sobald deine Öffnungszeiten gespeichert sind/)
}))
test('erstes Speichern: PUT mit version 0 und dem Plan, danach erscheinen Feiertage und Ausnahmen und die Hülle wird neu gezeichnet', mitDom({}, async (fx) => {
const { root, neuGezeichnet } = await oeffne(fx, { start: stand(null) })
const id = fx.finde(root, 'details[data-plan]').getAttribute('data-plan')
await fx.klick(haken(fx, tagZeile(fx, root, id, '1')))
const [von1, bis1, von2, bis2] = zeitFelder(fx, tagZeile(fx, root, id, '1'))
await fx.setze(von1, '09:00')
await fx.setze(bis1, '12:30')
await fx.setze(von2, '13:30')
await fx.setze(bis2, '18:00')
const gespeichert = daten({ plaene: [plan(id, { tage: wochenplan({ 1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] } }) })], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
fx.antworten.push(antwort(gespeichert, 1))
await fx.klick(speichernKnopf(fx, root, id))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].url, '/api/zeiten/p1')
const body = puts(fx)[0].body
assert.equal(body.version, 0)
assert.deepEqual(Object.keys(body).sort(), ['daten', 'version'])
assert.equal(body.daten.land, 'RP')
assert.equal(body.daten.plaene.length, 1)
assert.deepEqual(body.daten.plaene[0], {
id, kurz: 'oeffnungszeiten', name: 'Öffnungszeiten', haupt: true,
tage: wochenplan({ 1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] } }),
})
assert.deepEqual([body.daten.feiertage, body.daten.ausnahmen, body.daten.hinweise], [{}, [], []])
assert.match(meldung(fx, root), /gespeichert/)
assert.equal(fx.finde(root, '[data-hinweis="leer"]'), null, 'nach dem Speichern kein „Trag ein“ mehr')
assert.ok(abschnitt(fx, root, 'feiertage'))
assert.ok(abschnitt(fx, root, 'ausnahmen'))
assert.ok(fx.finde(root, '[data-werkzeug="plan-neu"]'))
assert.equal(neuGezeichnet.length, 1, 'die Hülle holt die Liste neu (zeitenLeer wechselt)')
// der zweite Speichervorgang beruht auf der neuen Version
fx.antworten.push(antwort(gespeichert, 2))
await fx.klick(speichernKnopf(fx, root, id))
assert.equal(puts(fx)[1].body.version, 1)
assert.equal(neuGezeichnet.length, 1, 'nur das erste Speichern wechselt den Stand der Hülle')
}))
test('leerer Start: der Kurzname folgt dem Namen bis zum ersten Speichern (so bildet ihn der Server) und geht dann mit dem Plan hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
const id = fx.finde(root, 'details[data-plan]').getAttribute('data-plan')
assert.match(fx.text(planKarte(fx, root, id)), /beim ersten Speichern aus dem Namen gebildet/)
await fx.setze(fx.finde(planKarte(fx, root, id), `[data-feld="plaene.${id}.name"]`), 'Praxis Dr. Müller')
assert.equal(fx.text(fx.finde(planKarte(fx, root, id), '[data-kurz]')), 'praxis-dr-mueller')
fx.antworten.push(antwort(daten({ plaene: [plan(id, { name: 'Praxis Dr. Müller', kurz: 'praxis-dr-mueller' })] }), 1))
await fx.klick(speichernKnopf(fx, root, id))
assert.deepEqual([puts(fx)[0].body.daten.plaene[0].name, puts(fx)[0].body.daten.plaene[0].kurz], ['Praxis Dr. Müller', 'praxis-dr-mueller'])
assert.match(fx.text(planKarte(fx, root, id)), /Der Kurzname bleibt gleich, auch wenn du den Namen änderst/, 'danach ist er unveränderlich')
assert.equal(fx.text(fx.finde(planKarte(fx, root, id), '[data-kurz]')), 'praxis-dr-mueller')
}))
// ---------------------------------------------------------------------------------------------------------------- Wochentage
test('Wochentag: Haken geöffnet, zwei Zeitpaare (type time, step 300), Auswahl rund um die Uhr / nach Vereinbarung', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const montag = tagZeile(fx, root, P1, '1')
assert.match(fx.text(montag), /Montag/)
assert.match(fx.text(montag), /geöffnet/)
assert.equal(haken(fx, montag).checked, true)
const felder = zeitFelder(fx, montag)
assert.equal(felder.length, 4, 'zwei Fenster, je von und bis')
for (const feld of felder) {
assert.equal(feld.getAttribute('type'), 'time')
assert.equal(feld.getAttribute('step'), '300')
}
assert.deepEqual(felder.map((f) => f.value), ['09:00', '12:30', '13:30', '18:00'])
assert.deepEqual(optionen(fx, art(fx, montag)), ['zeiten', 'rund', 'vereinbarung'])
assert.match(fx.text(art(fx, montag)), /rund um die Uhr/)
assert.match(fx.text(art(fx, montag)), /nach Vereinbarung/)
// Dienstag hat nur ein Fenster, das zweite Paar ist leer; Samstag „rund“; Sonntag zu
assert.deepEqual(zeitFelder(fx, tagZeile(fx, root, P1, '2')).map((f) => f.value), ['09:00', '18:00', '', ''])
const samstag = tagZeile(fx, root, P1, '6')
assert.equal(art(fx, samstag).value, 'rund')
assert.equal(fx.finde(samstag, '.zt-zeiten').hidden, true, 'rund: keine Zeitfelder')
const sonntag = tagZeile(fx, root, P1, '7')
assert.equal(haken(fx, sonntag).checked, false)
assert.equal(fx.finde(sonntag, '.zt-tag-felder').hidden, true, 'geschlossen: keine Auswahl')
await fx.klick(haken(fx, sonntag))
assert.equal(fx.finde(sonntag, '.zt-tag-felder').hidden, false)
assert.equal(fx.finde(sonntag, '.zt-zeiten').hidden, false, 'geöffnet zu festen Zeiten zeigt die Zeitfelder')
await fx.setze(art(fx, sonntag), 'vereinbarung')
assert.equal(fx.finde(sonntag, '.zt-zeiten').hidden, true)
}))
test('Wochentag speichern: geschlossen, rund, nach Vereinbarung und feste Zeiten gehen als modus/fenster hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(haken(fx, tagZeile(fx, root, P1, '2'))) // Dienstag zu
await fx.setze(art(fx, tagZeile(fx, root, P1, '3')), 'vereinbarung')
await fx.klick(haken(fx, tagZeile(fx, root, P1, '7')))
await fx.setze(art(fx, tagZeile(fx, root, P1, '7')), 'rund')
const [von1] = zeitFelder(fx, tagZeile(fx, root, P1, '5'))
await fx.setze(von1, '08:30')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
const tage = puts(fx)[0].body.daten.plaene[0].tage
assert.deepEqual(tage['2'], { modus: 'zu', fenster: [] })
assert.deepEqual(tage['3'], { modus: 'vereinbarung', fenster: [] })
assert.deepEqual(tage['7'], { modus: 'rund', fenster: [] })
assert.deepEqual(tage['5'], { modus: 'zeiten', fenster: [['08:30', '16:00']] })
assert.deepEqual(tage['1'], { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] }, 'Unverändertes bleibt')
assert.deepEqual(Object.keys(tage).sort(), WOCHE)
}))
test('Wochentag: Fehler stehen vor dem Senden am Tag (Überschneidung, halbes Fenster, kein Fenster), nichts geht hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const fensterMitLuecke = tagZeile(fx, root, P1, '1')
await fx.setze(zeitFelder(fx, fensterMitLuecke)[2], '12:00') // 2. Fenster beginnt vor dem Ende des ersten
const halb = tagZeile(fx, root, P1, '2')
await fx.setze(zeitFelder(fx, halb)[2], '19:00') // zweites Paar nur halb
const leer = tagZeile(fx, root, P1, '3')
await fx.setze(zeitFelder(fx, leer)[0], '')
await fx.setze(zeitFelder(fx, leer)[1], '')
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.1`)), /überschneiden/)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.2`)), /Beginn und Ende/)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.3`)), /mindestens ein Zeitfenster/)
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.tage.1"]`).getAttribute('aria-invalid'), 'true')
// ein leerer Name ist ein Fehler am Namen
await fx.setze(fx.finde(planKarte(fx, root, P1), `[data-feld="plaene.${P1}.name"]`), ' ')
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.name`)), /Namen/)
}))
test('Zeitfelder zeigen ein gespeichertes „24:00“ als 00:00 (type time kennt kein 24:00)', mitDom({}, async (fx) => {
const d = daten({ plaene: [plan(P1, { tage: wochenplan({ 1: { modus: 'zeiten', fenster: [['18:00', '24:00']] } }) })] })
const { root } = await oeffne(fx, { start: stand(d) })
assert.deepEqual(zeitFelder(fx, tagZeile(fx, root, P1, '1')).map((f) => f.value), ['18:00', '00:00', '', ''])
}))
// ---------------------------------------------------------------------------------------------------------------- Pläne
test('Plan hinzufügen: Name, Kurzname-Vorschau, PUT mit leerem Plan; nach dem Speichern gilt der Kurzname der Antwort (R33)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const neu = fx.finde(root, '[data-werkzeug="plan-neu"]')
assert.match(fx.text(neu), /Plan hinzufügen/)
await fx.klick(neu)
const form = fx.finde(root, 'form[aria-label="Plan hinzufügen"]')
const name = fx.finde(form, 'input[type="text"]')
await fx.setze(name, 'Küche & Lieferdienst')
assert.match(fx.text(fx.finde(form, '[data-kurz-vorschau]')), /kueche-lieferdienst/)
await fx.setze(name, 'Küche')
assert.equal(fx.text(fx.finde(form, '[data-kurz-vorschau]')), 'kueche')
// der Server vergibt den Kurzname endgültig (reserviert die Namen gelöschter Pläne): hier „kueche-2“
// die Antwort trägt die ID des gesendeten Plans (der Server behält gültige IDs) mit dem Kurznamen des Servers
fx.antworten.push((aufruf) => antwort(daten({ plaene: [plan(P1), plan(aufruf.body.daten.plaene[1].id, { name: 'Küche', kurz: 'kueche-2', haupt: false, tage: wochenplan() })] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const neuerPlan = puts(fx)[0].body.daten.plaene[1]
assert.match(neuerPlan.id, /^p_[a-z0-9]{8}$/)
assert.deepEqual({ ...neuerPlan, id: 'x' }, { id: 'x', kurz: 'kueche', name: 'Küche', haupt: false, tage: wochenplan() })
assert.equal(puts(fx)[0].body.daten.plaene[0].haupt, true)
assert.match(meldung(fx, root), /Küche/)
assert.match(meldung(fx, root), /kueche-2/, 'die Meldung nennt den Kurznamen der Antwort')
const karte = planKarte(fx, root, neuerPlan.id)
assert.equal(fx.text(fx.finde(karte, '[data-kurz]')), 'kueche-2', 'angezeigt wird der Kurzname des Servers')
assert.equal(karte.hasAttribute('open'), true, 'der neue Plan steht zum Ausfüllen offen')
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), null, 'das Formular ist zu')
}))
test('Plan hinzufügen: ohne Namen kein Senden; bei fünf Plänen gesperrt mit Hinweis', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="plan-neu"]'))
const form = fx.finde(root, 'form[aria-label="Plan hinzufügen"]')
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, form, 'name')), /Namen/)
const fuenf = daten({ plaene: ['p_aaaaaaa1', 'p_aaaaaaa2', 'p_aaaaaaa3', 'p_aaaaaaa4', 'p_aaaaaaa5'].map((id, i) => plan(id, { haupt: i === 0, kurz: `plan-${i}`, name: `Plan ${i}` })) })
const voll = await oeffne(fx, { start: stand(fuenf) })
const knopf = fx.finde(voll.root, '[data-werkzeug="plan-neu"]')
assert.equal(knopf.disabled, true)
assert.match(fx.text(abschnitt(fx, voll.root, 'plaene')), new RegExp(`Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich`))
}))
test('Hauptplan wechseln: Knopf nur an den anderen Plänen, PUT mit getauschtem Haupt', mitDom({}, async (fx) => {
const zwei = daten({ plaene: [plan(P1), plan(P2)] })
const { root } = await oeffne(fx, { start: stand(zwei) })
assert.equal(fx.finde(planKarte(fx, root, P1), '[data-aktion="haupt"]'), null, 'der Hauptplan hat den Knopf nicht')
assert.match(fx.text(fx.finde(planKarte(fx, root, P1), 'summary')), /Hauptplan/)
assert.doesNotMatch(fx.text(fx.finde(planKarte(fx, root, P2), 'summary')), /Hauptplan/)
const knopf = fx.finde(planKarte(fx, root, P2), '[data-aktion="haupt"]')
assert.match(fx.text(knopf), /Hauptplan/)
fx.antworten.push(antwort(daten({ plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true })] }), 4))
await fx.klick(knopf)
assert.deepEqual(puts(fx)[0].body.daten.plaene.map((p) => [p.id, p.haupt]), [[P1, false], [P2, true]])
assert.match(meldung(fx, root), /Küche.*Hauptplan/)
assert.match(fx.text(fx.finde(planKarte(fx, root, P2), 'summary')), /Hauptplan/)
assert.equal(fx.finde(planKarte(fx, root, P2), '[data-aktion="haupt"]'), null)
assert.ok(fx.finde(planKarte(fx, root, P1), '[data-aktion="haupt"]'))
}))
test('Plan löschen warnt („Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.“) und entfernt auch die Ausnahmen des Plans (R10)', mitDom({}, async (fx) => {
const von = tagPlus(HEUTE, 20)
const ausnahmen = [
{ id: 'x_alle0001', art: 'urlaub', von, bis: von, text: 'Alle', modus: 'zu', fenster: [], plan: null },
{ id: 'x_kueche01', art: 'urlaub', von, bis: von, text: 'Nur Küche', modus: 'zu', fenster: [], plan: P2 },
{ id: 'x_kueche02', art: 'sondertag', von, bis: von, text: 'Küche offen', modus: 'rund', fenster: [], plan: P2 },
]
const d = daten({ plaene: [plan(P1), plan(P2)], ausnahmen })
const { root } = await oeffne(fx, { start: stand(d) })
assert.equal(fx.finde(planKarte(fx, root, P1), '[data-aktion="entfernen"]'), null, 'der Hauptplan lässt sich nicht löschen')
assert.match(fx.text(planKarte(fx, root, P1)), /Erst einen anderen Plan zum Hauptplan machen/)
const knopf = fx.finde(planKarte(fx, root, P2), '[data-aktion="entfernen"]')
assert.match(fx.text(knopf), /löschen/)
await fx.klick(knopf)
const frage = fx.finde(planKarte(fx, root, P2), '.tm-frage-box')
assert.ok(frage, 'die Rückfrage steht in der Seite')
assert.ok(fx.text(frage).includes('Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.'))
assert.match(fx.text(frage), /Die 2 Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht/)
assert.equal(puts(fx).length, 0, 'ohne Bestätigung passiert nichts')
fx.antworten.push(antwort(daten({ plaene: [plan(P1)], ausnahmen: [ausnahmen[0]] }), 4))
await fx.klick(fx.finde(frage, 'button[type="submit"]'))
const gesendet = puts(fx)[0].body.daten
assert.deepEqual(gesendet.plaene.map((p) => p.id), [P1])
assert.deepEqual(gesendet.ausnahmen.map((a) => a.id), ['x_alle0001'], 'die Ausnahmen des Plans gehen mit, die übrigen bleiben')
assert.equal(planKarte(fx, root, P2), null)
assert.match(meldung(fx, root), /gelöscht/)
}))
test('Plan löschen: Abbrechen schließt die Rückfrage ohne zu senden; ein einzelner Plan zeigt den Grund „letzter Zeitplan“', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
await fx.klick(fx.finde(planKarte(fx, root, P2), '[data-aktion="entfernen"]'))
const frage = fx.finde(planKarte(fx, root, P2), '.tm-frage-box')
assert.doesNotMatch(fx.text(frage), /Ausnahmen, die nur für diesen Plan/, 'ohne Ausnahmen des Plans kein Zusatz')
await fx.klick(fx.finde(frage, 'button[type="button"]'))
assert.equal(fx.finde(planKarte(fx, root, P2), '.tm-frage-box'), null)
assert.equal(puts(fx).length, 0)
const allein = await oeffne(fx)
assert.match(fx.text(planKarte(fx, allein.root, P1)), /Der letzte Zeitplan kann nicht gelöscht werden/)
}))
test('Plan speichern: Name ändern lässt den Kurznamen stehen; die Auswahl „offen“ bleibt nach dem Neuzeichnen', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
const karte = planKarte(fx, root, P2)
assert.equal(karte.hasAttribute('open'), false, 'weitere Pläne sind zugeklappt')
karte.setAttribute('open', '')
await fx.sende(karte, 'toggle', { bubbles: false })
await fx.setze(fx.finde(karte, `[data-feld="plaene.${P2}.name"]`), 'Küche neu')
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2, { name: 'Küche neu' })] }), 4))
await fx.klick(speichernKnopf(fx, root, P2))
const gesendet = puts(fx)[0].body.daten.plaene[1]
assert.equal(gesendet.name, 'Küche neu')
assert.equal(gesendet.kurz, 'kueche', 'der Kurzname ändert sich nicht')
assert.equal(planKarte(fx, root, P2).hasAttribute('open'), true, 'der Plan bleibt aufgeklappt')
}))
// ---------------------------------------------------------------------------------------------------------------- Feiertage
test('Feiertage: Bundesland, Liste mit Namen, „bitte prüfen“ markiert, Hinweis auf Gemeinde-Feiertage', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const bereich = abschnitt(fx, root, 'feiertage')
assert.ok(bereich)
const land = fx.finde(bereich, 'select[data-feld="land"]')
assert.equal(land.value, 'RP')
assert.equal(fx.alle(land, 'option').length, 16)
assert.match(fx.text(land), /Rheinland-Pfalz/)
assert.match(fx.text(bereich), /Gemeinde-Feiertage sind nicht enthalten/)
const zeilen = fx.alle(bereich, '[data-feiertag]')
assert.deepEqual(zeilen.map((z) => z.getAttribute('data-feiertag')), FEIERTAGE.map((f) => f.tag))
assert.match(fx.text(zeilen[1]), /Erster Weihnachtstag/)
assert.match(fx.text(zeilen[0]), /Heiligabend/)
assert.match(fx.text(zeilen[0]), /bitte prüfen/i)
assert.doesNotMatch(fx.text(zeilen[1]), /bitte prüfen/i)
// Vorgabe: gesetzlicher Feiertag geschlossen, Prüf-Tag nach dem Wochenplan
assert.equal(fx.finde(zeilen[1], 'select').value, '')
assert.match(fx.text(fx.finde(zeilen[1], 'select')), /geschlossen/)
assert.match(fx.text(fx.finde(zeilen[0], 'select')), /Wochenplan/)
assert.deepEqual(optionen(fx, fx.finde(zeilen[0], 'select')), ['', 'zu', 'zeiten', 'rund', 'vereinbarung'])
assert.deepEqual(optionen(fx, fx.finde(zeilen[1], 'select')), ['', 'zeiten', 'rund', 'vereinbarung'])
}))
test('Feiertage speichern: nur Abweichungen von der Vorgabe gehen in daten.feiertage, alles andere bleibt unverändert', mitDom({}, async (fx) => {
const vorhanden = { '2027-01-01': { modus: 'vereinbarung', fenster: [] }, '2030-05-01': { modus: 'rund', fenster: [] } } // 2030: nicht in der Liste, bleibt
const { root } = await oeffne(fx, { start: stand(daten({ feiertage: vorhanden })) })
const zeile = (tag) => fx.finde(root, `[data-feiertag="${tag}"]`)
assert.equal(fx.finde(zeile('2027-01-01'), 'select').value, 'vereinbarung', 'gespeicherte Abweichung ist vorbelegt')
// 25.12. mit eigenen Zeiten
await fx.setze(fx.finde(zeile('2026-12-25'), 'select'), 'zeiten')
const [von, bis] = zeitFelder(fx, zeile('2026-12-25'))
await fx.setze(von, '10:00')
await fx.setze(bis, '12:00')
// Heiligabend geschlossen (Abweichung vom Wochenplan)
await fx.setze(fx.finde(zeile('2026-12-24'), 'select'), 'zu')
// Neujahr zurück auf die Vorgabe
await fx.setze(fx.finde(zeile('2027-01-01'), 'select'), '')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(abschnitt(fx, root, 'feiertage'), 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.deepEqual(puts(fx)[0].body.daten.feiertage, {
'2026-12-24': { modus: 'zu', fenster: [] },
'2026-12-25': { modus: 'zeiten', fenster: [['10:00', '12:00']] },
'2030-05-01': { modus: 'rund', fenster: [] },
})
assert.match(meldung(fx, root), /Feiertage gespeichert/)
}))
test('Feiertage: „geschlossen“ an einem gesetzlichen Feiertag ist die Vorgabe und wird nicht gespeichert; eigene Zeiten ohne Fenster sind ein Fehler am Tag', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ feiertage: { '2026-12-25': { modus: 'zu', fenster: [] } } })) })
assert.equal(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select').value, '', 'gespeichertes „zu“ ist die Vorgabe')
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2027-01-01"]'), 'select'), 'zeiten')
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, root, 'feiertage.2027-01-01')), /mindestens ein Zeitfenster/)
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2027-01-01"]'), 'select'), '')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.deepEqual(puts(fx)[0].body.daten.feiertage, {}, 'das gespeicherte „zu“ fällt weg')
}))
test('Bundesland speichern: PUT mit land, danach zeigt die Liste die Feiertage der Antwort', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(fx.finde(root, 'select[data-feld="land"]'), 'BY')
const bayern = [{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' }, { tag: '2027-01-06', name: 'Heilige Drei Könige', art: 'feiertag' }]
fx.antworten.push(antwort(daten({ land: 'BY' }), 4, { feiertage: bayern }))
await fx.klick(fx.finde(root, 'form[aria-label="Bundesland"] button[type="submit"]'))
assert.equal(puts(fx)[0].body.daten.land, 'BY')
assert.deepEqual(fx.alle(root, '[data-feiertag]').map((z) => z.getAttribute('data-feiertag')), ['2026-12-25', '2027-01-06'])
assert.equal(fx.finde(root, 'select[data-feld="land"]').value, 'BY')
}))
// ---------------------------------------------------------------------------------------------------------------- Ausnahmen
const urlaubsformular = (fx, root) => fx.finde(root, 'form[aria-label="Betriebsurlaub eintragen"]')
const feldIn = (fx, form, name) => fx.finde(form, (el) => el.nodeType === 1 && (el.getAttribute('data-feld') || '').endsWith(`.${name}`))
const modusAuswahl = (fx, form) => fx.finde(form, (el) => el.tagName === 'SELECT' && (el.getAttribute('data-feld') || '').endsWith('.modus'))
test('Urlaub anlegen: von–bis und Text, Geltung „alle Pläne“ (Vorgabe) oder ein Plan; PUT mit neuer Ausnahme', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
await fx.setze(feldIn(fx, form, 'text'), 'Sommerpause')
const geltung = feldIn(fx, form, 'plan')
assert.deepEqual(optionen(fx, geltung), ['', P1, P2])
assert.match(fx.text(geltung), /Alle Pläne/)
assert.equal(geltung.value, '', 'Vorgabe: alle Pläne')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const a = puts(fx)[0].body.daten.ausnahmen
assert.equal(a.length, 1)
assert.match(a[0].id, /^x_[a-z0-9]{8}$/)
assert.deepEqual({ ...a[0], id: 'x' }, { id: 'x', art: 'urlaub', von, bis, text: 'Sommerpause', modus: 'zu', fenster: [], plan: null })
}))
test('Urlaub ab 2 Tagen: „Hinweis-Leiste dazu anlegen?“ – Ja legt den vorbefüllten Hinweis an und speichert, Nein lässt es', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
const urlaub = { id: 'x_urlaub01', art: 'urlaub', von, bis, text: '', modus: 'zu', fenster: [], plan: null }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const frage = fx.finde(root, '[data-frage="hinweis"]')
assert.ok(frage, 'die Frage steht in der Seite')
assert.match(fx.text(frage), /Hinweis-Leiste dazu anlegen\?/)
const ymd = (tag) => `${tag.slice(8)}.${tag.slice(5, 7)}.`
assert.ok(fx.text(frage).includes(`Betriebsurlaub vom ${ymd(von)} bis ${ymd(bis)}`), 'der Text des Vorschlags steht in der Frage')
assert.equal(puts(fx).length, 1, 'bis zur Antwort nur der Urlaub')
const vorschlag = { id: 'h_xxxxxxxx', art: 'urlaub', text: `Betriebsurlaub vom ${ymd(von)} bis ${ymd(bis)}`, knopf: null, von: tagPlus(von, -7), bis }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub], hinweise: [vorschlag] }), 5))
await fx.klick(fx.finde(frage, '[data-aktion="hinweis-ja"]'))
assert.equal(puts(fx).length, 2)
const gesendet = puts(fx)[1].body
assert.equal(gesendet.version, 4)
assert.deepEqual(gesendet.daten.ausnahmen.map((a) => a.id), ['x_urlaub01'], 'der Urlaub bleibt')
assert.equal(gesendet.daten.hinweise.length, 1)
assert.match(gesendet.daten.hinweise[0].id, /^h_[a-z0-9]{8}$/)
assert.deepEqual({ ...gesendet.daten.hinweise[0], id: 'h' }, { id: 'h', art: 'urlaub', text: vorschlag.text, knopf: null, von: tagPlus(von, -7), bis })
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null, 'die Frage ist beantwortet')
assert.match(meldung(fx, root), /Hinweis-Leiste/)
// Nein: kein weiterer Speichervorgang
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const zweites = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, zweites, 'von'), tagPlus(HEUTE, 40))
await fx.setze(feldIn(fx, zweites, 'bis'), tagPlus(HEUTE, 42))
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub] }), 6))
await fx.klick(fx.finde(zweites, 'button[type="submit"]'))
const davor = puts(fx).length
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-nein"]'))
assert.equal(puts(fx).length, davor)
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null)
}))
test('Urlaub: keine Frage, wenn es denselben Hinweis schon gibt oder sich beim Bearbeiten der Zeitraum nicht ändert', mitDom({}, async (fx) => {
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
const vorschlag = hinweisVorschlag({ von, bis })
const hinweis = { id: 'h_schonda1', art: 'urlaub', text: vorschlag.text, knopf: null, von: vorschlag.von, bis: vorschlag.bis }
const urlaub = { id: 'x_urlaub01', art: 'urlaub', von, bis, text: '', modus: 'zu', fenster: [], plan: null }
// 1. ein gleicher Hinweis existiert schon
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: [hinweis] })) })
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
fx.antworten.push(antwort(daten({ hinweise: [hinweis], ausnahmen: [urlaub] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null)
// 2. der Urlaub wird nur im Text geändert: keine Frage; ändert sich der Zeitraum (ohne Hinweis dazu): Frage
const zwei = await oeffne(fx, { start: stand(daten({ ausnahmen: [urlaub] })) })
const zeile = fx.finde(zwei.root, 'li[data-id="x_urlaub01"]')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
await fx.setze(feldIn(fx, fx.finde(zeile, 'form'), 'text'), 'Sommer')
fx.antworten.push(antwort(daten({ ausnahmen: [{ ...urlaub, text: 'Sommer' }] }), 4))
await fx.klick(fx.finde(fx.finde(zeile, 'form'), 'button[type="submit"]'))
assert.equal(fx.finde(zwei.root, '[data-frage="hinweis"]'), null, 'nur der Text hat sich geändert')
const zeile2 = fx.finde(zwei.root, 'li[data-id="x_urlaub01"]')
await fx.klick(fx.finde(zeile2, '[data-aktion="bearbeiten"]'))
await fx.setze(feldIn(fx, fx.finde(zeile2, 'form'), 'bis'), tagPlus(bis, 3))
fx.antworten.push(antwort(daten({ ausnahmen: [{ ...urlaub, text: 'Sommer', bis: tagPlus(bis, 3) }] }), 5))
await fx.klick(fx.finde(fx.finde(zeile2, 'form'), 'button[type="submit"]'))
assert.ok(fx.finde(zwei.root, '[data-frage="hinweis"]'), 'der Zeitraum hat sich geändert')
}))
test('Urlaub von einem Tag: keine Frage nach der Hinweis-Leiste', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const tag = tagPlus(HEUTE, 10)
await fx.setze(feldIn(fx, form, 'von'), tag)
await fx.setze(feldIn(fx, form, 'bis'), tag)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null)
assert.match(meldung(fx, root), /eingetragen/)
}))
test('Hinweis-Leiste anlegen scheitert (400, zu viele Hinweise): Fehler an der Frage, die Frage bleibt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, form, 'von'), tagPlus(HEUTE, 20))
await fx.setze(feldIn(fx, form, 'bis'), tagPlus(HEUTE, 25))
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: { hinweise: 'Es sind höchstens 20 Hinweise möglich.' } }))
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-ja"]'))
const frage = fx.finde(root, '[data-frage="hinweis"]')
assert.ok(frage, 'die Frage bleibt')
assert.match(fx.text(frage), /höchstens 20 Hinweise/)
}))
test('Sondertag: Datum, geschlossen oder Zeiten, Text, Geltung; PUT mit bis = von', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const form = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
const tag = tagPlus(HEUTE, 14)
await fx.setze(feldIn(fx, form, 'von'), tag)
assert.equal(feldIn(fx, form, 'plan'), null, 'mit einem Plan gibt es keine Wahl der Geltung')
assert.equal(feldIn(fx, form, 'bis'), null, 'ein Sondertag hat nur ein Datum')
const modus = modusAuswahl(fx, form)
assert.deepEqual(optionen(fx, modus), ['zu', 'zeiten', 'rund', 'vereinbarung'])
assert.equal(modus.value, 'zu', 'Vorgabe: geschlossen')
assert.equal(fx.finde(form, '.zt-zeiten').hidden, true)
await fx.setze(modus, 'zeiten')
assert.equal(fx.finde(form, '.zt-zeiten').hidden, false)
const [von1, bis1] = zeitFelder(fx, form)
await fx.setze(von1, '10:00')
await fx.setze(bis1, '14:00')
await fx.setze(feldIn(fx, form, 'text'), 'Tag der offenen Tür')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const a = puts(fx)[0].body.daten.ausnahmen[0]
assert.deepEqual({ ...a, id: 'x' }, { id: 'x', art: 'sondertag', von: tag, bis: tag, text: 'Tag der offenen Tür', modus: 'zeiten', fenster: [['10:00', '14:00']], plan: null })
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null, 'für einen Sondertag fragt der Editor nicht nach einer Leiste')
}))
test('Ausnahmen: Liste kommend und vergangen, Bearbeiten und Entfernen (je ein Speichern)', mitDom({}, async (fx) => {
const vor = tagPlus(HEUTE, -3)
const spaeter = tagPlus(HEUTE, 30)
const bald = tagPlus(HEUTE, 5)
const ausnahmen = [
{ id: 'x_spaeter1', art: 'sondertag', von: spaeter, bis: spaeter, text: 'Später', modus: 'zeiten', fenster: [['10:00', '14:00']], plan: null },
{ id: 'x_vorbei01', art: 'urlaub', von: vor, bis: vor, text: 'Vorbei', modus: 'zu', fenster: [], plan: null },
{ id: 'x_bald0001', art: 'urlaub', von: bald, bis: tagPlus(bald, 2), text: 'Bald', modus: 'zu', fenster: [], plan: P2 },
]
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)], ausnahmen })) })
const bereich = abschnitt(fx, root, 'ausnahmen')
const kommend = fx.alle(fx.finde(bereich, 'ul[aria-label="Kommende Ausnahmen"]'), 'li[data-id]')
assert.deepEqual(kommend.map((li) => li.getAttribute('data-id')), ['x_bald0001', 'x_spaeter1'], 'nach Beginn sortiert')
assert.match(fx.text(kommend[0]), /Bald/)
assert.match(fx.text(kommend[0]), /Küche/, 'die Geltung steht in der Zeile')
assert.match(fx.text(kommend[1]), /10:00–14:00/)
const vergangen = fx.finde(bereich, 'details.tm-details')
assert.match(fx.text(fx.finde(vergangen, 'summary')), /Vergangene Ausnahmen \(1\)/)
assert.match(fx.text(vergangen), /Vorbei/)
// Bearbeiten: Text ändern
await fx.klick(fx.finde(kommend[1], '[data-aktion="bearbeiten"]'))
const form = fx.finde(kommend[1], 'form')
await fx.setze(feldIn(fx, form, 'text'), 'Später neu')
const nachBearbeiten = ausnahmen.map((a) => (a.id === 'x_spaeter1' ? { ...a, text: 'Später neu' } : a))
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: nachBearbeiten }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const gesendet = puts(fx)[0].body.daten.ausnahmen
assert.deepEqual(gesendet.map((a) => a.id), ['x_spaeter1', 'x_vorbei01', 'x_bald0001'], 'Reihenfolge und Stelle bleiben, gesucht wird per ID')
assert.equal(gesendet[0].text, 'Später neu')
assert.equal(gesendet[2].plan, P2)
// Entfernen
const zeile = fx.finde(root, 'li[data-id="x_bald0001"]')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
await fx.klick(fx.finde(zeile, '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: nachBearbeiten.filter((a) => a.id !== 'x_bald0001') }), 5))
await fx.klick(fx.finde(fx.finde(zeile, '.tm-frage-box'), 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.ausnahmen.map((a) => a.id), ['x_spaeter1', 'x_vorbei01'])
assert.equal(fx.finde(root, 'li[data-id="x_bald0001"]'), null)
}))
test('Ausnahmen: bei 100 Einträgen sind beide Knöpfe gesperrt', mitDom({}, async (fx) => {
const viele = Array.from({ length: ZEITEN_LIMITS.ausnahmen }, (_, i) => ({ id: `x_aaaa${String(i).padStart(4, '0')}`, art: 'sondertag', von: tagPlus(HEUTE, 1 + (i % 300)), bis: tagPlus(HEUTE, 1 + (i % 300)), text: '', modus: 'zu', fenster: [], plan: null }))
const { root } = await oeffne(fx, { start: stand(daten({ ausnahmen: viele })) })
assert.equal(fx.finde(root, '[data-werkzeug="urlaub-neu"]').disabled, true)
assert.equal(fx.finde(root, '[data-werkzeug="sondertag-neu"]').disabled, true)
assert.match(fx.text(abschnitt(fx, root, 'ausnahmen')), /Mehr als 100 Ausnahmen sind nicht möglich/)
}))
// ---------------------------------------------------------------------------------------------------------------- Fehler des Servers
test('409 beim Speichern: Meldung „Inzwischen geändert – bitte neu laden.“ und Neu-laden-Knopf; der Knopf lädt und zeichnet neu', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push(fehler(409, 'Inzwischen geändert – bitte neu laden.', { code: 'ZEITEN_VERALTET' }))
await fx.klick(speichernKnopf(fx, root, P1))
const box = fx.finde(root, '.tm-box-warn')
assert.equal(box.hidden, false)
assert.match(fx.text(box), /Inzwischen geändert – bitte neu laden\./)
const neuLaden = fx.finde(box, 'button')
assert.equal(fx.text(neuLaden), 'Neu laden')
assert.equal(puts(fx).length, 1)
// die neue Fassung hat einen anderen Namen
const neu = daten({ plaene: [plan(P1, { name: 'Praxis' })] })
fx.antworten.push({ status: 200, body: stand(neu, { version: 7 }) })
await fx.klick(neuLaden)
assert.equal(gets(fx).length, 2, 'die Daten werden neu geholt')
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.name"]`).value, 'Praxis')
assert.equal(fx.finde(root, '.tm-box-warn').hidden, true)
fx.antworten.push(antwort(neu, 8))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx)[1].body.version, 7, 'weiter mit der neuen Version')
}))
test('400: Fehler am richtigen Feld (plaene.<id>.tage.3), Fokus dorthin, Fremdes allgemein mit dem Namen des Plans', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: {
[`plaene.${P1}.tage.3`]: 'Die Zeitfenster überschneiden sich.',
[`plaene.${P1}.name`]: 'Höchstens 60 Zeichen.',
'ausnahmen.x_fremd001.von': 'Bitte gib ein gültiges Datum an.',
daten: 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.',
} }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.3`)), 'Die Zeitfenster überschneiden sich.')
assert.equal(fx.text(fehlerAn(fx, root, `plaene.${P1}.name`)), 'Höchstens 60 Zeichen.')
const feld = fx.finde(root, `[data-feld="plaene.${P1}.tage.3"]`)
assert.equal(feld.getAttribute('aria-invalid'), 'true')
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.name"]`).getAttribute('aria-invalid'), 'true')
assert.ok(planKarte(fx, root, P1).contains(fx.finde(root, '[data-fehler="_"]')), 'Platz für allgemeine Fehler')
assert.match(fx.text(fx.finde(planKarte(fx, root, P1), '[data-fehler="_"]')), /Zu viele Einträge/)
assert.match(fx.text(fx.finde(planKarte(fx, root, P1), '[data-fehler="_"]')), /gültiges Datum/)
assert.equal(document.activeElement, fx.finde(root, '[aria-invalid="true"]'), 'der Fokus geht zum ersten Feld mit Fehler')
// ein Speichern mit Erfolg räumt die Fehler ab
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.finde(root, '[aria-invalid="true"]'), null)
}))
test('400 in Ausnahmen und Feiertagen: Fehler an Datum, Fenster und Tag (Pfade mit ID)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const form = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
await fx.setze(feldIn(fx, form, 'von'), tagPlus(HEUTE, 3))
await fx.setze(modusAuswahl(fx, form), 'zeiten')
await fx.setze(zeitFelder(fx, form)[0], '09:00')
await fx.setze(zeitFelder(fx, form)[1], '12:00')
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: {} }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const id = puts(fx)[0].body.daten.ausnahmen[0].id
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: { [`ausnahmen.${id}.von`]: 'Bitte gib ein gültiges Datum an.', [`ausnahmen.${id}.fenster`]: 'Die Zeitfenster überschneiden sich.' } }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(fx.text(fehlerAn(fx, form, `ausnahmen.${id}.von`)), 'Bitte gib ein gültiges Datum an.')
assert.equal(fx.text(fehlerAn(fx, form, `ausnahmen.${id}.fenster`)), 'Die Zeitfenster überschneiden sich.')
// Feiertag: Pfad feiertage.<tag>
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select'), 'rund')
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: { 'feiertage.2026-12-25': 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.' } }))
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.match(fx.text(fehlerAn(fx, root, 'feiertage.2026-12-25')), /Bitte wähle/)
}))
test('Sonstige Fehler (429, 500, offline) stehen als Meldung über dem Editor, nie als HTML', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const bose = '<img src=x onerror=alert(1)>'
fx.antworten.push(fehler(429, bose))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(meldung(fx, root), bose)
assert.equal(fx.alle(root, 'img').length, 0)
assert.equal(fx.finde(root, '[data-meldung="editor"]').getAttribute('role'), 'alert')
fx.antworten.push(() => { throw new TypeError('x') })
await fx.klick(speichernKnopf(fx, root, P1))
assert.match(meldung(fx, root), /Fehler|Verbindung|Interner/)
// ein Erfolg ersetzt die Fehlermeldung durch eine ruhige Meldung
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.finde(root, '[data-meldung="editor"]').getAttribute('role'), 'status')
}))
test('Laden schlägt fehl: Fehlerkarte mit „Noch einmal versuchen“; eine unbrauchbare Antwort ebenso', mitDom({}, async (fx) => {
const root = h('div', { class: 'zt-editor', 'data-state': 'active' })
document.body.append(root)
fx.antworten.push(fehler(500, 'Interner Serverfehler'))
await editor.renderEditor(root, { projectId: 'p1' })
assert.match(fx.text(root), /Interner Serverfehler/)
const nochmal = fx.finde(root, 'button')
assert.match(fx.text(nochmal), /Noch einmal versuchen/)
fx.antworten.push({ status: 200, body: { irgendwas: true } })
await fx.klick(nochmal)
assert.match(fx.text(root), /konnten gerade nicht geladen werden/)
fx.antworten.push({ status: 200, body: stand(daten()) })
await fx.klick(fx.finde(root, 'button'))
assert.ok(planKarte(fx, root, P1), 'der dritte Versuch zeigt den Editor')
}))
// ---------------------------------------------------------------------------------------------------------------- Kundenansicht, nur lesen, R12
test('Kundenansicht: Hinweis-Box, Speichern ist erlaubt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { kundenansicht: true }) })
const box = fx.finde(root, '[data-hinweis="kundenansicht"]')
assert.ok(box)
assert.match(fx.text(box), /Kundenansicht/)
assert.match(fx.text(box), /Protokoll/)
assert.equal(speichernKnopf(fx, root, P1).disabled, false)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx).length, 1)
// ohne Kundenansicht keine Box
const kunde = await oeffne(fx)
assert.equal(fx.finde(kunde.root, '[data-hinweis="kundenansicht"]'), null)
// auch die Angabe der Hülle genügt, falls die Antwort sie nicht trägt
const huelle = await oeffne(fx, { kundenansicht: true })
assert.ok(fx.finde(huelle.root, '[data-hinweis="kundenansicht"]'))
}))
test('abgeschaltet (disabled mit Daten): alles gesperrt, keine Knöpfe zum Ändern, Hinweis „nur ansehen“', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [{ id: 'x_aaaaaaa1', art: 'urlaub', von: tagPlus(HEUTE, 3), bis: tagPlus(HEUTE, 4), text: 'Urlaub', modus: 'zu', fenster: [], plan: null }] }), { state: 'disabled' }) })
assert.ok(fx.finde(root, '[data-hinweis="nurlesen"]'))
assert.equal(fx.finde(root, '[data-aktion="speichern"]'), null)
assert.equal(fx.finde(root, '[data-aktion="haupt"]'), null)
assert.equal(fx.finde(root, '[data-aktion="entfernen"]'), null)
assert.equal(fx.finde(root, '[data-werkzeug]'), null)
assert.equal(fx.finde(root, 'button[type="submit"]'), null)
for (const feld of fx.alle(root, 'input, select')) assert.equal(feld.matches(':disabled'), true, `${feld.tagName} ist gesperrt`)
assert.equal(puts(fx).length, 0)
}))
test('R12: die Kontaktdaten gehen unverändert zurück (kein neues bestaetigtAt), auch wenn nur ein Plan gespeichert wird', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.deepEqual(puts(fx)[0].body.daten.kontakt, KONTAKT)
// keine Kontaktdaten: der Editor erfindet keine
const ohne = daten()
delete ohne.kontakt
const zwei = await oeffne(fx, { start: stand(ohne) })
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, zwei.root, P1))
assert.equal(Object.hasOwn(puts(fx)[1].body.daten, 'kontakt'), false)
}))
// ---------------------------------------------------------------------------------------------------------------- Robustheit
test('Fremdtext (Plan-, Feiertags-, Ausnahme- und Hinweisnamen) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
const bose = '<img src=x onerror=alert(1)>'
const tag = tagPlus(HEUTE, 9)
const d = daten({
plaene: [plan(P1, { name: bose })],
ausnahmen: [{ id: 'x_bose0001', art: 'urlaub', von: tag, bis: tag, text: bose, modus: 'zu', fenster: [], plan: null }],
})
const { root } = await oeffne(fx, { start: stand(d, { feiertage: [{ tag: '2026-12-25', name: bose, art: 'feiertag' }] }) })
assert.equal(fx.alle(root, 'img').length, 0)
assert.ok(fx.text(root).includes(bose))
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.name"]`).value, bose)
fx.antworten.push(fehler(400, 'x', { fehler: { [`ausnahmen.x_bose0001.text`]: bose } }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.alle(root, 'img').length, 0)
}))
test('IDs wie „__proto__“ und „constructor“ sind gewöhnliche Texte: Speichern, Fehler und Löschen finden den richtigen Plan', mitDom({}, async (fx) => {
const alt = daten({ plaene: [plan('__proto__', { name: 'Eins' }), plan('constructor', { haupt: false, kurz: 'zwei', name: 'Zwei' })] })
const { root } = await oeffne(fx, { start: stand(alt) })
const karte = fx.alle(root, 'details[data-plan]').find((k) => k.getAttribute('data-plan') === 'constructor')
assert.ok(karte)
karte.setAttribute('open', '')
await fx.setze(fx.finde(karte, '[data-feld="plaene.constructor.name"]'), 'Zwei neu')
fx.antworten.push(fehler(400, 'x', { fehler: { 'plaene.constructor.tage.3': 'Die Zeitfenster überschneiden sich.' } }))
await fx.klick(fx.finde(karte, '[data-aktion="speichern"]'))
assert.deepEqual(puts(fx)[0].body.daten.plaene.map((p) => [p.id, p.name]), [['__proto__', 'Eins'], ['constructor', 'Zwei neu']])
assert.equal(fx.text(fehlerAn(fx, karte, 'plaene.constructor.tage.3')), 'Die Zeitfenster überschneiden sich.')
// Löschen
await fx.klick(fx.finde(karte, '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten({ plaene: [plan('__proto__', { name: 'Eins' })] }), 4))
await fx.klick(fx.finde(fx.finde(karte, '.tm-frage-box'), 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.plaene.map((p) => p.id), ['__proto__'])
}))
test('Ein Speichern zur Zeit: während es läuft sind die Knöpfe gesperrt und ein zweiter Aufruf geht nicht hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
globalThis.fetch = async (url, optionen) => {
if (optionen?.method === 'PUT') await wartet
return echtes(url, optionen)
}
try {
fx.antworten.push(antwort(daten(), 4))
const erster = fx.klick(speichernKnopf(fx, root, P1))
await fx.ruhe()
assert.equal(planKarte(fx, root, P1).closest('[aria-busy="true"]') !== null || root.getAttribute('aria-busy') === 'true', true, 'der Editor ist beschäftigt')
assert.equal(speichernKnopf(fx, root, P1).disabled, true)
await fx.klick(speichernKnopf(fx, root, P1))
freigeben()
await erster
assert.equal(puts(fx).length, 1, 'nur ein Speichervorgang')
assert.equal(root.hasAttribute('aria-busy'), false)
} finally {
globalThis.fetch = echtes
}
}))
test('neuerZeitenSpeicher: speichere ohne laufende Hülle (ctx mit Stand) – Erfolg liefert true, der Stand wird übernommen', mitDom({}, async (fx) => {
const root = h('div')
document.body.append(root)
const form = h('form', null, h('p', { 'data-fehler': '_' }))
root.append(form)
const ctx = {
projectId: 'p1',
stand: { daten: daten(), version: 3 },
aktuell: () => true,
neuLaden: async () => {},
meldung: () => {},
uebernimm(a) { this.stand = { daten: a.daten, version: a.version } },
}
const sp = speichern.neuerZeitenSpeicher(ctx, root, () => {})
assert.equal(typeof sp.speichere, 'function')
assert.ok(sp.konflikt)
assert.equal(typeof sp.nochHier, 'function')
fx.antworten.push(antwort(daten({ land: 'BY' }), 4))
assert.equal(await sp.speichere((entwurf) => { entwurf.land = 'BY' }, { container: form }), true)
assert.deepEqual(puts(fx)[0], { method: 'PUT', url: '/api/zeiten/p1', body: { version: 3, daten: daten({ land: 'BY' }) } })
assert.equal(ctx.stand.version, 4)
assert.equal(await sp.speichere(() => false, { container: form }), false, 'aendere kann abbrechen (Fehler am Feld schon gezeigt)')
assert.equal(puts(fx).length, 1)
}))
// ---------------------------------------------------------------------------------------------------------------- Hülle: Editor bleibt stehen
const zeitenFeature = (state = 'off', extra = {}) => ({
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
})
const projekt = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state) }, zeitenStatus: null, zeitenLeer: true, ...extra })
const angebot = { verfuegbar: true, inklusive: true, grund: null, preis: '30 €', einschalten: { consent: {}, leistungsbeschreibung: {} }, belehrung: '' }
const liste = (projects) => ({ status: 200, body: { projects, features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, zeiten: angebot } })
test('Hülle: ein Wechsel des Standes (zeitenLeer, setup → active) zeichnet nur den Kopf neu; der Editor und seine ungespeicherten Eingaben bleiben', mitDom({}, async (fx) => {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.antworten.push(liste([projekt('p1', 'setup', { zeitenLeer: true })]), { status: 200, body: stand(null, { state: 'setup' }) })
await initZeiten(root, { projectId: 'p1' })
await fx.ruhe()
const platz = fx.finde(root, '.zt-editor')
assert.ok(platz)
assert.equal(platz.getAttribute('data-state'), 'setup')
const id = fx.finde(platz, 'details[data-plan]').getAttribute('data-plan')
const name = fx.finde(platz, `[data-feld="plaene.${id}.name"]`)
await fx.setze(name, 'Praxis Dr. Muster')
const kopf = fx.finde(root, '.zt-kopf')
// Stand ändert sich (nach dem ersten Speichern ist zeitenLeer false, der Zustand wechselt auf aktiv): neuer Kopf, derselbe Editor
fx.antworten.push(liste([projekt('p1', 'active', { zeitenLeer: false })]))
await initZeiten(root, { projectId: 'p1' })
assert.equal(fx.finde(root, '.zt-editor'), platz, 'derselbe Editorplatz')
assert.equal(platz.getAttribute('data-state'), 'active', 'der Zustand wird nachgeführt')
assert.equal(fx.finde(platz, `[data-feld="plaene.${id}.name"]`), name, 'dasselbe Eingabefeld')
assert.equal(name.value, 'Praxis Dr. Muster', 'die ungespeicherte Eingabe steht noch da')
assert.notEqual(fx.finde(root, '.zt-kopf'), kopf, 'der Kopf (Statuskarte) ist neu')
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'active')
assert.equal(gets(fx).filter((a) => a.url === '/api/zeiten/p1').length, 1, 'der Editor lädt nicht noch einmal')
assert.equal(platz.hidden, false)
}))
test('Hülle: erstes Speichern im Editor holt die Liste neu (zeitenLeer) und zeichnet nur den Kopf neu, der Editor bleibt', mitDom({}, async (fx) => {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.antworten.push(liste([projekt('p1', 'active', { zeitenLeer: true })]), { status: 200, body: stand(null) })
await initZeiten(root, { projectId: 'p1' })
await fx.ruhe()
const platz = fx.finde(root, '.zt-editor')
const id = fx.finde(platz, 'details[data-plan]').getAttribute('data-plan')
const gespeichert = daten({ plaene: [plan(id)], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
fx.antworten.push(antwort(gespeichert, 1), liste([projekt('p1', 'active', { zeitenLeer: false })]))
await fx.klick(fx.finde(platz, '[data-aktion="speichern"]'))
await fx.ruhe()
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'GET /api/zeiten/p1', 'PUT /api/zeiten/p1', 'GET /api/funktionen'])
assert.equal(fx.finde(root, '.zt-editor'), platz, 'derselbe Editorplatz nach dem Neuzeichnen der Hülle')
assert.ok(abschnitt(fx, platz, 'feiertage'), 'der Editor zeigt jetzt Feiertage und Ausnahmen')
assert.doesNotMatch(fx.text(fx.finde(root, '.zt-kopf')), /Trag jetzt deine Zeiten ein/)
}))
test('Hülle: eine andere Website oder ein anderer Schreibstatus (abgeschaltet) bekommt einen frischen Editor', mitDom({}, async (fx) => {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.antworten.push(liste([projekt('p1', 'active', { zeitenLeer: false }), projekt('p2', 'active', { zeitenLeer: false })]), { status: 200, body: stand(daten()) })
await initZeiten(root, { projectId: 'p1' })
await fx.ruhe()
const erster = fx.finde(root, '.zt-editor')
// Website wechseln
fx.antworten.push({ status: 200, body: stand(daten({ plaene: [plan(P1, { name: 'Zweite Seite' })] })) })
await fx.setze(fx.finde(root, '#zeiten-project'), 'p2')
await fx.ruhe()
const zweiter = fx.finde(root, '.zt-editor')
assert.notEqual(zweiter, erster)
assert.equal(fx.finde(zweiter, `[data-feld="plaene.${P1}.name"]`).value, 'Zweite Seite')
assert.equal(erster.isConnected, false)
assert.deepEqual(gets(fx).filter((a) => a.url.startsWith('/api/zeiten/')).map((a) => a.url), ['/api/zeiten/p1', '/api/zeiten/p2'])
}))
// ---------------------------------------------------------------------------------------------------------------- Quelltext
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const DATEIEN = ['zeiten-speichern.js', 'zeiten-editor.js', 'zeiten-plaene.js', 'zeiten-ausnahmen.js']
test('Quelltext: vier neue Dateien, nur relative Importe, kein HTML aus Daten, keine Browser-Rückfragen, kein Speicher, Du-Form', () => {
const vorhanden = readdirSync(new URL('../public/', import.meta.url))
for (const datei of DATEIEN) {
assert.ok(vorhanden.includes(datei), `${datei} fehlt`)
const quelle = ohneKommentare(lesen(`public/${datei}`))
for (const m of quelle.matchAll(/\bfrom\s+'([^']+)'/g)) assert.match(m[1], /^\.\/(termine-dom|termine-view|zeiten[a-z-]*)\.js$/, `${datei}: Import ${m[1]}`)
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
assert.doesNotMatch(quelle, /24 Uhr/, `${datei}: Wortwahl`)
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
assert.doesNotMatch(quelle, /\btermine-(speichern|team|tab|aktionen|formulare)\b/, `${datei}: keine termine-Dateien angefasst`)
}
// die Hülle bindet den Editor ein
assert.match(ohneKommentare(lesen('public/zeiten.js')), /from '\.\/zeiten-editor\.js'/)
})
test('zeiten.css: jede zt-Klasse der Editor-Module ist gestaltet', () => {
const css = lesen('public/zeiten.css')
const quelle = DATEIEN.map((d) => lesen(`public/${d}`)).join('\n')
const gesetzt = new Set([...quelle.matchAll(/class: '([^']+)'/g)].flatMap((m) => m[1].split(/\s+/)).filter((k) => /^zt-/.test(k)))
assert.ok(gesetzt.size >= 5)
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
})