Files
Webklar-Kundenbereich/test/zeitenEditor.test.js
T
ClaudeandClaude Opus 5.5 b3e96d4a56 Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau
Hinweis-Leiste (Liste, „Hinweis-Leiste anlegen“, Art, Knopf, Zeitraum, Preis-Hinweis),
Kontakt-Leiste (Telefon, Adresse, WhatsApp mit Datenschutz-Haken, R12 in der Kundenansicht),
Google (Für Google kopieren, Profil-Link), Live-Vorschau mit Regler „Uhrzeit testen“
und UI-Prüfung test/ui/zeiten-portal.ui.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:43:22 +00:00

1878 lines
120 KiB
JavaScript
Raw 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 { 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“).
// Teil II (Task 30): Hinweis-Leiste, Kontakt-Leiste, Google und Vorschau (zeiten-leisten.js, zeiten-google.js, zeiten-editor.js).
// 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/)
}))
// ---------------------------------------------------------------------------------------------------------------- Nur neu zeichnen, was sich geändert hat
const kurzerUrlaub = (id, text = '') => ({ id, art: 'urlaub', von: tagPlus(HEUTE, 10), bis: tagPlus(HEUTE, 10), text, modus: 'zu', fenster: [], plan: null })
/** Einen eintägigen Betriebsurlaub über das Formular eintragen (ohne Frage nach der Hinweis-Leiste); die Antwort kommt als nächste aus der Warteschlange. */
async function urlaubEintragen(fx, root, antwortDaten, version) {
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, form, 'von'), tagPlus(HEUTE, 10))
await fx.setze(feldIn(fx, form, 'bis'), tagPlus(HEUTE, 10))
fx.antworten.push(antwort(antwortDaten, version))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
}
test('Plan speichern zeichnet nur diese Karte: ungespeicherte Eingaben in einem anderen Plan und in anderen Abschnitten bleiben stehen', mitDom({}, async (fx) => {
const zwei = daten({ plaene: [plan(P1), plan(P2)] })
const { root } = await oeffne(fx, { start: stand(zwei) })
const karteA = planKarte(fx, root, P1)
const karteB = planKarte(fx, root, P2)
const nameB = fx.finde(karteB, `[data-feld="plaene.${P2}.name"]`)
await fx.setze(nameB, 'Küche Entwurf')
await fx.klick(haken(fx, tagZeile(fx, root, P2, '7'))) // Sonntag im zweiten Plan geöffnet, nicht gespeichert
await fx.setze(fx.finde(root, 'select[data-feld="land"]'), 'BY') // Bundesland gewählt, nicht gespeichert
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select'), 'rund')
await fx.setze(zeitFelder(fx, tagZeile(fx, root, P1, '2'))[0], '08:15') // Dienstag in A: wird gleich gespeichert
fx.antworten.push(antwort(daten({ plaene: [plan(P1, { tage: { ...werktags(), 2: { modus: 'zeiten', fenster: [['08:15', '18:00']] } } }), plan(P2)] }), 4))
await fx.klick(speichernKnopf(fx, root, P1))
const gesendet = puts(fx)[0].body.daten
assert.deepEqual(gesendet.plaene[0].tage['2'].fenster, [['08:15', '18:00']])
assert.equal(gesendet.plaene[1].name, 'Küche', 'der Entwurf des anderen Plans wird nicht mitgespeichert')
assert.equal(gesendet.plaene[1].tage['7'].modus, 'zu')
assert.notEqual(planKarte(fx, root, P1), karteA, 'die gespeicherte Karte ist neu gezeichnet')
assert.equal(planKarte(fx, root, P2), karteB, 'die andere Karte ist dieselbe geblieben')
assert.equal(fx.finde(planKarte(fx, root, P2), `[data-feld="plaene.${P2}.name"]`), nameB)
assert.equal(nameB.value, 'Küche Entwurf')
assert.equal(haken(fx, tagZeile(fx, root, P2, '7')).checked, true)
assert.equal(fx.finde(root, 'select[data-feld="land"]').value, 'BY', 'das ungespeicherte Bundesland bleibt')
assert.equal(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select').value, 'rund')
assert.deepEqual(zeitFelder(fx, tagZeile(fx, root, P1, '2')).slice(0, 2).map((f) => f.value), ['08:15', '18:00'], 'die gespeicherte Karte zeigt den gespeicherten Stand')
}))
test('Betriebsurlaub speichern zeichnet nur „Ausnahmen“: eine ungespeicherte Änderung am Wochenplan bleibt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const montag = tagZeile(fx, root, P1, '1')
const feld = zeitFelder(fx, montag)[0]
await fx.setze(feld, '07:45')
const karte = planKarte(fx, root, P1)
const feiertage = abschnitt(fx, root, 'feiertage')
const ausnahmenVorher = abschnitt(fx, root, 'ausnahmen')
await urlaubEintragen(fx, root, daten({ ausnahmen: [kurzerUrlaub('x_urlaub01')] }), 4)
assert.equal(planKarte(fx, root, P1), karte, 'dieselbe Karte')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0], feld)
assert.equal(feld.value, '07:45', 'die ungespeicherte Eingabe steht noch da')
assert.equal(abschnitt(fx, root, 'feiertage'), feiertage, 'Feiertage unberührt')
assert.notEqual(abschnitt(fx, root, 'ausnahmen'), ausnahmenVorher, 'Ausnahmen neu gezeichnet')
assert.ok(fx.finde(root, 'li[data-id="x_urlaub01"]'))
assert.match(meldung(fx, root), /eingetragen/)
assert.equal(puts(fx)[0].body.daten.plaene[0].tage['1'].fenster[0][0], '09:00', 'der Entwurf ging nicht mit hinaus')
// dasselbe beim Entfernen
const zeile = fx.finde(root, 'li[data-id="x_urlaub01"]')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
await fx.klick(fx.finde(zeile, '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten(), 5))
await fx.klick(fx.finde(fx.finde(zeile, '.tm-frage-box'), 'button[type="submit"]'))
assert.equal(feld.value, '07:45')
assert.equal(fx.finde(root, 'li[data-id="x_urlaub01"]'), null)
}))
test('„Nein, danke“ entfernt nur die Frage: andere ungespeicherte Eingaben und offene Formulare bleiben', 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)
fx.antworten.push(antwort(daten({ ausnahmen: [{ ...kurzerUrlaub('x_urlaub01'), von, bis }] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.ok(fx.finde(root, '[data-frage="hinweis"]'))
// jetzt nebenher etwas eingeben: ein Wochentag, ein offenes Sondertag-Formular mit Text
const feld = zeitFelder(fx, tagZeile(fx, root, P1, '3'))[1]
await fx.setze(feld, '17:20')
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const sonder = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
await fx.setze(feldIn(fx, sonder, 'text'), 'Tag der offenen Tür')
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-nein"]'))
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null, 'die Frage ist weg')
assert.equal(feld.value, '17:20')
assert.equal(fx.finde(root, 'form[aria-label="Sondertag eintragen"]'), sonder, 'das offene Formular ist dasselbe')
assert.equal(feldIn(fx, sonder, 'text').value, 'Tag der offenen Tür')
assert.equal(puts(fx).length, 1)
}))
test('Feiertage speichern lässt ein noch ungespeichertes Bundesland stehen; Bundesland speichern nimmt Eingaben für Tage mit ins neue Land', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const zeile = (tag) => fx.finde(root, `[data-feiertag="${tag}"]`)
await fx.setze(fx.finde(root, 'select[data-feld="land"]'), 'BY')
await fx.setze(fx.finde(zeile('2026-12-25'), 'select'), 'vereinbarung')
fx.antworten.push(antwort(daten({ feiertage: { '2026-12-25': { modus: 'vereinbarung', fenster: [] } } }), 4))
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.equal(puts(fx)[0].body.daten.land, 'RP', 'das Bundesland ging nicht mit')
assert.equal(fx.finde(root, 'select[data-feld="land"]').value, 'BY', 'und steht noch da')
assert.match(meldung(fx, root), /Feiertage gespeichert/)
// jetzt das Bundesland speichern: ein Wochenplan-Entwurf bleibt, die Eingabe zu Neujahr (gibt es auch in Bayern) wandert mit
await fx.setze(fx.finde(zeile('2027-01-01'), 'select'), 'rund')
const feld = zeitFelder(fx, tagZeile(fx, root, P1, '4'))[0]
await fx.setze(feld, '10:10')
const bayern = [{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' }, { tag: '2027-01-01', name: 'Neujahr', art: 'feiertag' }, { tag: '2027-01-06', name: 'Heilige Drei Könige', art: 'feiertag' }]
fx.antworten.push(antwort(daten({ land: 'BY', feiertage: { '2026-12-25': { modus: 'vereinbarung', fenster: [] } } }), 5, { feiertage: bayern }))
await fx.klick(fx.finde(root, 'form[aria-label="Bundesland"] button[type="submit"]'))
assert.deepEqual(fx.alle(root, '[data-feiertag]').map((z) => z.getAttribute('data-feiertag')), ['2026-12-25', '2027-01-01', '2027-01-06'])
assert.equal(fx.finde(zeile('2027-01-01'), 'select').value, 'rund', 'die Eingabe zu Neujahr ist erhalten')
assert.equal(fx.finde(zeile('2026-12-25'), 'select').value, 'vereinbarung', 'der gespeicherte Stand')
assert.equal(fx.finde(zeile('2027-01-06'), 'select').value, '')
assert.equal(feld.value, '10:10', 'der Wochenplan-Entwurf bleibt')
}))
test('Plan hinzufügen, Hauptplan wechseln und Plan löschen zeichnen die Planliste neu, behalten aber die ungespeicherten Eingaben der übrigen Karten und das offene „Plan hinzufügen“', mitDom({}, async (fx) => {
const P3 = 'p_dritter3'
const d3 = daten({
plaene: [plan(P1), plan(P2), plan(P3, { kurz: 'bar', name: 'Bar', haupt: false })],
ausnahmen: [{ ...kurzerUrlaub('x_kueche01', 'Nur Küche'), plan: P2 }, kurzerUrlaub('x_alle0001', 'Alle')],
})
const { root } = await oeffne(fx, { start: stand(d3) })
const name3 = fx.finde(planKarte(fx, root, P3), `[data-feld="plaene.${P3}.name"]`)
await fx.setze(name3, 'Bar Entwurf')
const montag = zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0]
await fx.setze(montag, '06:30')
await fx.klick(fx.finde(root, '[data-werkzeug="plan-neu"]'))
const neuForm = fx.finde(root, 'form[aria-label="Plan hinzufügen"]')
await fx.setze(fx.finde(neuForm, 'input[type="text"]'), 'Halbfertig')
const ausnahmenVorher = abschnitt(fx, root, 'ausnahmen')
// Hauptplan wechseln (P2): Pillen und Knöpfe aller Karten ändern sich, die Eingaben bleiben
fx.antworten.push(antwort(daten({ ...d3, plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true }), plan(P3, { kurz: 'bar', name: 'Bar', haupt: false })] }), 4))
await fx.klick(fx.finde(planKarte(fx, root, P2), '[data-aktion="haupt"]'))
assert.match(fx.text(fx.finde(planKarte(fx, root, P2), 'summary')), /Hauptplan/)
assert.doesNotMatch(fx.text(fx.finde(planKarte(fx, root, P1), 'summary')), /Hauptplan/)
assert.ok(fx.finde(planKarte(fx, root, P1), '[data-aktion="haupt"]'), 'der frühere Hauptplan hat jetzt den Knopf')
assert.equal(fx.finde(planKarte(fx, root, P3), `[data-feld="plaene.${P3}.name"]`).value, 'Bar Entwurf')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0].value, '06:30')
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), neuForm, 'das offene Formular bleibt')
assert.equal(fx.finde(neuForm, 'input[type="text"]').value, 'Halbfertig')
assert.equal(abschnitt(fx, root, 'ausnahmen'), ausnahmenVorher, 'die Ausnahmen kennen die Pläne nur beim Namen: nichts zu tun')
// Plan löschen (P3, jetzt weder Haupt- noch letzter Plan): P1 und P2 behalten ihre Eingaben, Ausnahmen werden neu gezeichnet
const montag2 = zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0]
await fx.klick(fx.finde(planKarte(fx, root, P3), '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten({ ...d3, plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true })] }), 5))
await fx.klick(fx.finde(fx.finde(planKarte(fx, root, P3), '.tm-frage-box'), 'button[type="submit"]'))
assert.equal(planKarte(fx, root, P3), null)
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0].value, '06:30', 'Eingabe in P1 bleibt')
assert.notEqual(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0], montag2, '(die Karte wurde mit ihren Werten neu gebaut)')
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), neuForm)
assert.notEqual(abschnitt(fx, root, 'ausnahmen'), ausnahmenVorher, 'Ausnahmen neu gezeichnet')
// Plan hinzufügen: neue Karte, Formular zu, P1 behält die Eingabe, „Gilt für“ in neuen Formularen kennt den neuen Plan
await fx.setze(fx.finde(neuForm, 'input[type="text"]'), 'Lieferdienst')
fx.antworten.push((aufruf) => antwort(daten({ ...d3, plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true }), plan(aufruf.body.daten.plaene[2].id, { kurz: 'lieferdienst', name: 'Lieferdienst', haupt: false, tage: wochenplan() })] }), 6))
await fx.klick(fx.finde(neuForm, 'button[type="submit"]'))
const neueKarte = fx.alle(root, 'details[data-plan]').find((k) => /Lieferdienst/.test(fx.text(fx.finde(k, 'summary'))))
assert.ok(neueKarte, 'die neue Karte steht in der Liste')
assert.equal(fx.alle(root, 'details[data-plan]').length, 3)
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), null, 'das Formular ist zu')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0].value, '06:30', 'auch das Hinzufügen lässt die Eingaben der anderen Karten stehen')
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
assert.match(fx.text(feldIn(fx, urlaubsformular(fx, root), 'plan')), /Lieferdienst/)
}))
test('Plan löschen: die Rückfrage zählt die Ausnahmen des Plans beim Öffnen, auch wenn sie erst nach dem Zeichnen der Karte dazukamen oder wegfielen', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
const loeschenOeffnen = async () => {
await fx.klick(fx.finde(planKarte(fx, root, P2), '[data-aktion="entfernen"]'))
return fx.finde(planKarte(fx, root, P2), '.tm-frage-box')
}
const abbrechen = (frage) => fx.klick(fx.finde(frage, 'button[type="button"]'))
assert.doesNotMatch(fx.text(await loeschenOeffnen()), /nur für diesen Plan/, 'noch keine Ausnahme des Plans')
await abbrechen(fx.finde(planKarte(fx, root, P2), '.tm-frage-box'))
// ein Sondertag „nur Küche“ kommt dazu: die Karte des Plans wird dabei nicht neu gezeichnet
const karte = planKarte(fx, root, P2)
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)
await fx.setze(feldIn(fx, form, 'plan'), P2)
const sonder = { id: 'x_sonder001', art: 'sondertag', von: tag, bis: tag, text: '', modus: 'zu', fenster: [], plan: P2 }
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [sonder] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx)[0].body.daten.ausnahmen[0].plan, P2)
assert.equal(planKarte(fx, root, P2), karte, 'die Karte blieb stehen')
const frage = await loeschenOeffnen()
assert.match(fx.text(frage), /Die eine Ausnahme, die nur für diesen Plan gilt, wird mit gelöscht/)
assert.ok(fx.text(frage).includes('Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.'))
await abbrechen(frage)
// der Sondertag wird wieder entfernt: der Hinweis verschwindet
const zeile = fx.finde(root, 'li[data-id="x_sonder001"]')
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)] }), 5))
await fx.klick(fx.finde(fx.finde(zeile, '.tm-frage-box'), 'button[type="submit"]'))
assert.equal(planKarte(fx, root, P2), karte)
assert.doesNotMatch(fx.text(await loeschenOeffnen()), /nur für diesen Plan/, 'ohne Ausnahme kein Hinweis mehr')
await abbrechen(fx.finde(planKarte(fx, root, P2), '.tm-frage-box'))
// und der Plan wird tatsächlich mit seinen Ausnahmen gelöscht, wenn eine dazukam
const noch = { ...sonder, id: 'x_sonder002' }
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const form2 = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
await fx.setze(feldIn(fx, form2, 'von'), tag)
await fx.setze(feldIn(fx, form2, 'plan'), P2)
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [noch] }), 6))
await fx.klick(fx.finde(form2, 'button[type="submit"]'))
const frage2 = await loeschenOeffnen()
assert.match(fx.text(frage2), /Die eine Ausnahme/)
fx.antworten.push(antwort(daten({ plaene: [plan(P1)] }), 7))
await fx.klick(fx.finde(frage2, 'button[type="submit"]'))
assert.deepEqual(puts(fx).at(-1).body.daten.ausnahmen, [], 'die Ausnahme des Plans ging mit')
}))
test('Plan umbenennen: die Liste der Ausnahmen nennt den neuen Namen, sonst bleibt sie stehen', mitDom({}, async (fx) => {
const ausnahme = { ...kurzerUrlaub('x_kueche01', 'Nur Küche'), plan: P2 }
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [ausnahme] })) })
const vorher = abschnitt(fx, root, 'ausnahmen')
assert.match(fx.text(fx.finde(root, 'li[data-id="x_kueche01"]')), /nur Küche/)
// erst gespeichert ohne neuen Namen: Ausnahmen bleiben
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [ausnahme] }), 4))
await fx.klick(speichernKnopf(fx, root, P2))
assert.equal(abschnitt(fx, root, 'ausnahmen'), vorher)
await fx.setze(fx.finde(planKarte(fx, root, P2), `[data-feld="plaene.${P2}.name"]`), 'Restaurant')
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2, { name: 'Restaurant' })], ausnahmen: [ausnahme] }), 5))
await fx.klick(speichernKnopf(fx, root, P2))
assert.notEqual(abschnitt(fx, root, 'ausnahmen'), vorher)
assert.match(fx.text(fx.finde(root, 'li[data-id="x_kueche01"]')), /nur Restaurant/)
}))
test('Während ein Speichern läuft, sind Knöpfe und Eingabefelder gesperrt (nichts geht beim Tippen verloren); danach sind sie wieder frei', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
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 {
const ausserhalb = zeitFelder(fx, tagZeile(fx, root, P2, '1'))[0]
const selbst = zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0]
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)] }), 4))
const lauf = fx.klick(speichernKnopf(fx, root, P1))
await fx.ruhe()
assert.equal(root.getAttribute('aria-busy'), 'true')
for (const steuerung of fx.alle(root, 'input, select, textarea, button')) assert.equal(steuerung.disabled, true, `${steuerung.tagName} ist gesperrt`)
await fx.setze(ausserhalb, '05:55')
assert.notEqual(ausserhalb.value, '05:55', 'in ein gesperrtes Feld lässt sich nichts tippen')
freigeben()
await lauf
assert.equal(root.hasAttribute('aria-busy'), false)
assert.equal(ausserhalb.disabled, false, 'die Karte, die stehen blieb, ist wieder frei')
assert.equal(fx.alle(root, 'input:disabled, select:disabled, button:disabled').length, 0)
void selbst
} finally {
globalThis.fetch = echtes
}
}))
// ---------------------------------------------------------------------------------------------------------------- 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'])
}))
// ---------------------------------------------------------------------------------------------------------------- Editor II (Task 30)
// Hinweis-Leiste, Kontakt-Leiste, Google und Vorschau (public/zeiten-leisten.js, zeiten-google.js, Vorschau oben in zeiten-editor.js; Spec 3.4 Bereiche 4 bis 6)
const ymdKurz = (tag) => `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}`
const wochentagNr = (tag) => ((new Date(`${tag}T00:00:00Z`).getUTCDay() + 6) % 7) + 1
const naechster = (nr) => { for (let i = 0; i < 7; i += 1) if (wochentagNr(tagPlus(HEUTE, i)) === nr) return tagPlus(HEUTE, i); throw new Error('unmöglich') }
const hinweisAktion = { id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten', knopf: { art: 'link', ziel: 'https://beispiel.de/torten', text: 'Mehr erfahren' }, von: tagPlus(HEUTE, -1), bis: tagPlus(HEUTE, 10) }
const hinweisUrlaub = { id: 'h_urlaub001', art: 'urlaub', text: 'Betriebsurlaub im Sommer', knopf: null, von: tagPlus(HEUTE, 30), bis: tagPlus(HEUTE, 40) }
const hinweisAlt = { id: 'h_vorbei001', art: 'info', text: 'Alter Hinweis', knopf: null, von: tagPlus(HEUTE, -20), bis: tagPlus(HEUTE, -10) }
const hinweisFeld = (fx, form, name) => fx.finde(form, (el) => el.nodeType === 1 && new RegExp(`^hinweise\\.[^.]+\\.${name.replace('.', '\\.')}$`).test(el.getAttribute('data-feld') || ''))
const hinweisFehlerPfad = (fx, form, name) => fx.finde(form, (el) => el.nodeType === 1 && new RegExp(`^hinweise\\.[^.]+\\.${name.replace('.', '\\.')}$`).test(el.getAttribute('data-fehler') || ''))
const neuerHinweis = (fx, root) => fx.finde(root, 'form[aria-label="Neuer Hinweis"]')
const kontaktForm = (fx, root) => fx.finde(root, 'form[aria-label="Kontakt-Leiste"]')
const kfeld = (fx, root, name) => fx.finde(kontaktForm(fx, root), `[data-feld="kontakt.${name}"]`)
const sichtbar = (el) => el !== null && el.hidden !== true
/** Zwischenablage des Browsers für die Dauer von fn ersetzen (Node hat seit 21 ein eigenes navigator-Objekt: Eigenschaft sichern und danach zurückgeben). */
async function mitZwischenablage(clipboard, fn) {
const vorher = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { value: clipboard === undefined ? {} : { clipboard }, configurable: true, writable: true, enumerable: true })
try {
await fn()
} finally {
if (vorher) Object.defineProperty(globalThis, 'navigator', vorher)
else delete globalThis.navigator
}
}
test('Editor II: die Abschnitte Hinweis-Leiste, Kontakt-Leiste und Google stehen nach den Ausnahmen, erst nach dem ersten Speichern', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
assert.deepEqual(fx.alle(root, '[data-abschnitt]').map((el) => el.getAttribute('data-abschnitt')), ['plaene', 'feiertage', 'ausnahmen', 'hinweise', 'kontakt', 'google'])
const leer = await oeffne(fx, { start: stand(null) })
for (const key of ['hinweise', 'kontakt', 'google']) assert.equal(abschnitt(fx, leer.root, key), null, `${key}: erst nach dem ersten Speichern`)
for (const [bauen, datei] of [['hinweise', 'zeiten-leisten'], ['kontakt', 'zeiten-leisten'], ['google', 'zeiten-google']]) {
const modul = await import(`../public/${datei}.js`)
assert.equal(typeof modul[bauen], 'function', `${datei}.js exportiert ${bauen}(t)`)
}
}))
// ---------------------------------------------------------------------------------------------------------------- Hinweis-Leiste
test('Hinweis-Leiste: der Knopf heißt genau „Hinweis-Leiste anlegen“, das Formular hat Text, Art und Zeitraum, PUT mit neuem Hinweis', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const knopf = fx.finde(root, '[data-werkzeug="hinweis-neu"]')
assert.equal(fx.text(knopf), 'Hinweis-Leiste anlegen')
assert.equal(knopf.tagName, 'BUTTON')
assert.match(fx.text(abschnitt(fx, root, 'hinweise')), /Keine Hinweise eingetragen/)
await fx.klick(knopf)
const form = neuerHinweis(fx, root)
assert.ok(form)
assert.equal(hinweisFeld(fx, form, 'text').getAttribute('maxlength'), '140')
assert.deepEqual(optionen(fx, hinweisFeld(fx, form, 'art')), ['info', 'urlaub', 'aktion', 'wichtig'])
assert.deepEqual(fx.alle(hinweisFeld(fx, form, 'art'), 'option').map((o) => fx.text(o)), ['Info', 'Urlaub', 'Aktion', 'Wichtig'])
assert.equal(hinweisFeld(fx, form, 'von').getAttribute('type'), 'date')
assert.equal(hinweisFeld(fx, form, 'bis').getAttribute('type'), 'date')
await fx.setze(hinweisFeld(fx, form, 'text'), ' Heute Kuchen im Angebot ')
await fx.setze(hinweisFeld(fx, form, 'art'), 'wichtig')
const von = tagPlus(HEUTE, 1)
const bis = tagPlus(HEUTE, 3)
await fx.setze(hinweisFeld(fx, form, 'von'), von)
await fx.setze(hinweisFeld(fx, form, 'bis'), bis)
const gespeichert = { id: 'h_neu00001', art: 'wichtig', text: 'Heute Kuchen im Angebot', knopf: null, von, bis }
fx.antworten.push(antwort(daten({ hinweise: [gespeichert] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
const h = puts(fx)[0].body.daten.hinweise
assert.equal(h.length, 1)
assert.match(h[0].id, /^h_[a-z0-9]{8}$/)
assert.deepEqual({ ...h[0], id: 'h' }, { id: 'h', art: 'wichtig', text: 'Heute Kuchen im Angebot', knopf: null, von, bis })
assert.match(meldung(fx, root), /Hinweis.*gespeichert/)
const zeile = fx.finde(abschnitt(fx, root, 'hinweise'), 'li[data-id="h_neu00001"]')
assert.ok(zeile, 'der Hinweis steht in der Liste')
assert.ok(fx.text(zeile).includes('Heute Kuchen im Angebot'))
assert.ok(fx.text(zeile).includes('Wichtig'))
assert.ok(fx.text(zeile).includes(ymdKurz(von)))
assert.equal(neuerHinweis(fx, root), null, 'das Formular ist nach dem Speichern zu')
}))
test('Hinweis: Zeitraum ist optional (ohne Daten gilt der Hinweis immer), Datum leer ⇒ null', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Heute Ruhetag')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const h = puts(fx)[0].body.daten.hinweise[0]
assert.equal(h.art, 'info')
assert.equal(h.von, null)
assert.equal(h.bis, null)
}))
test('Hinweis: Aktion ohne Enddatum ⇒ Feldfehler am Enddatum (nichts geht hinaus); % im Text ⇒ Preis-Hinweis', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
const hinweis = fx.finde(form, '[data-hinweis="preis"]')
assert.ok(hinweis, 'der Preis-Hinweis hat seinen Platz im Formular')
assert.equal(sichtbar(hinweis), false, 'zuerst nicht sichtbar')
await fx.setze(hinweisFeld(fx, form, 'text'), 'Alles 20 % günstiger')
assert.equal(sichtbar(hinweis), true, '% im Text')
assert.match(fx.text(hinweis), /Endpreise/)
assert.match(fx.text(hinweis), /niedrigste/)
assert.match(fx.text(hinweis), /30 Tage/)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Jetzt nur 5 € statt 8 €')
assert.equal(sichtbar(hinweis), true, '„statt“ im Text')
await fx.setze(hinweisFeld(fx, form, 'text'), 'Heute Schlussverkauf')
assert.equal(sichtbar(hinweis), false, 'ohne % und „statt“ weg')
await fx.setze(hinweisFeld(fx, form, 'art'), 'aktion')
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 0, 'ohne Enddatum geht nichts hinaus')
assert.equal(fx.text(hinweisFehlerPfad(fx, form, 'bis')), 'Eine Aktion braucht ein Enddatum.')
assert.equal(hinweisFeld(fx, form, 'bis').getAttribute('aria-invalid'), 'true')
// mit Enddatum geht es hinaus
await fx.setze(hinweisFeld(fx, form, 'bis'), tagPlus(HEUTE, 5))
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].body.daten.hinweise[0].art, 'aktion')
}))
test('Hinweis: Ende vor Beginn ⇒ Feldfehler; ein Fehler des Servers steht am Feld', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Zeitraum falsch')
await fx.setze(hinweisFeld(fx, form, 'von'), tagPlus(HEUTE, 5))
await fx.setze(hinweisFeld(fx, form, 'bis'), tagPlus(HEUTE, 2))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 0)
assert.equal(fx.text(hinweisFehlerPfad(fx, form, 'bis')), 'Das Ende darf nicht vor dem Beginn liegen.')
// der Server hat das letzte Wort: Fehler an einem Textfeld
await fx.setze(hinweisFeld(fx, form, 'bis'), tagPlus(HEUTE, 6))
const pfad = hinweisFeld(fx, form, 'text').getAttribute('data-feld')
fx.antworten.push(fehler(400, 'x', { fehler: { [pfad]: 'Bitte gib einen Text ein.' } }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(fx.text(fehlerAn(fx, form, pfad)), 'Bitte gib einen Text ein.')
assert.equal(hinweisFeld(fx, form, 'text').getAttribute('aria-invalid'), 'true')
}))
test('Hinweis-Knopf: nur Link (https) oder Anrufen, Link-Prüfung als Hinweis, der Server entscheidet', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
const art = hinweisFeld(fx, form, 'knopf.art')
assert.deepEqual(optionen(fx, art), ['', 'link', 'anruf'], 'kein Knopf, Link, Anrufen – sonst nichts')
assert.deepEqual(fx.alle(art, 'option').map((o) => fx.text(o)), ['Kein Knopf', 'Link', 'Anrufen'])
assert.equal(art.value, '', 'Vorgabe: kein Knopf')
const felder = fx.finde(form, '[data-knopf-felder]')
assert.equal(felder.hidden, true, 'ohne Knopf keine Knopf-Felder')
assert.equal(hinweisFeld(fx, form, 'knopf.text').getAttribute('maxlength'), '30')
await fx.setze(hinweisFeld(fx, form, 'text'), 'Neue Torten')
await fx.setze(art, 'link')
assert.equal(felder.hidden, false)
assert.equal(hinweisFeld(fx, form, 'knopf.ziel').getAttribute('maxlength'), '300')
const linkHinweis = fx.finde(form, '[data-hinweis="knopf-ziel"]')
await fx.setze(hinweisFeld(fx, form, 'knopf.text'), 'Mehr erfahren')
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'http://beispiel.de/torten')
assert.equal(sichtbar(linkHinweis), true, 'http ist kein https')
assert.match(fx.text(linkHinweis), /https:\/\//)
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'javascript:alert(1)')
assert.equal(sichtbar(linkHinweis), true)
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'https://beispiel.de/torten')
assert.equal(sichtbar(linkHinweis), false, 'ein https-Link ist in Ordnung')
// die Prüfung ist nur ein Hinweis: ein schlechter Link geht hinaus, der Server lehnt ihn ab, der Fehler steht am Feld
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), 'http://beispiel.de/torten')
const pfad = hinweisFeld(fx, form, 'knopf.ziel').getAttribute('data-feld')
fx.antworten.push(fehler(400, 'x', { fehler: { [pfad]: 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).' } }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1, 'der Server entscheidet')
assert.deepEqual(puts(fx)[0].body.daten.hinweise[0].knopf, { art: 'link', ziel: 'http://beispiel.de/torten', text: 'Mehr erfahren' })
assert.match(fx.text(fehlerAn(fx, form, pfad)), /https:\/\//)
// Anrufen: Ziffern, Leerzeichen, + / -
await fx.setze(art, 'anruf')
const anrufHinweis = fx.finde(form, '[data-hinweis="knopf-ziel"]')
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), '06301 abc')
assert.equal(sichtbar(anrufHinweis), true)
assert.match(fx.text(anrufHinweis), /Ziffern/)
await fx.setze(hinweisFeld(fx, form, 'knopf.ziel'), '+49 6301 123-456')
assert.equal(sichtbar(anrufHinweis), false)
await fx.setze(hinweisFeld(fx, form, 'knopf.text'), 'Anrufen')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.hinweise[0].knopf, { art: 'anruf', ziel: '+49 6301 123-456', text: 'Anrufen' })
// Knopf wieder „Kein Knopf“ ⇒ knopf null
const zwei = await oeffne(fx)
await fx.klick(fx.finde(zwei.root, '[data-werkzeug="hinweis-neu"]'))
const form2 = neuerHinweis(fx, zwei.root)
await fx.setze(hinweisFeld(fx, form2, 'text'), 'Ohne Knopf')
await fx.setze(hinweisFeld(fx, form2, 'knopf.art'), 'link')
await fx.setze(hinweisFeld(fx, form2, 'knopf.art'), '')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form2, 'button[type="submit"]'))
assert.equal(puts(fx)[2].body.daten.hinweise[0].knopf, null)
}))
test('Hinweis bearbeiten und entfernen: der Eintrag wird per ID gefunden, die Reihenfolge der übrigen bleibt, Rückfrage in der Seite', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: [hinweisAktion, hinweisUrlaub] })) })
const liste = abschnitt(fx, root, 'hinweise')
const zeile = fx.finde(liste, 'li[data-id="h_aktion001"]')
assert.ok(fx.text(zeile).includes('20 % auf alle Torten'))
assert.ok(fx.text(zeile).includes('Aktion'))
assert.match(fx.text(zeile), /Mehr erfahren/, 'der Knopf steht in der Zeile')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
const form = fx.finde(zeile, 'form[aria-label="Hinweis bearbeiten"]')
assert.ok(form)
assert.equal(hinweisFeld(fx, form, 'text').value, '20 % auf alle Torten')
assert.equal(hinweisFeld(fx, form, 'art').value, 'aktion')
assert.equal(hinweisFeld(fx, form, 'knopf.art').value, 'link')
assert.equal(hinweisFeld(fx, form, 'knopf.ziel').value, 'https://beispiel.de/torten')
assert.equal(hinweisFeld(fx, form, 'knopf.text').value, 'Mehr erfahren')
assert.equal(hinweisFeld(fx, form, 'bis').value, hinweisAktion.bis)
assert.equal(sichtbar(fx.finde(form, '[data-hinweis="preis"]')), true, '% im gespeicherten Text zeigt den Preis-Hinweis gleich')
await fx.setze(hinweisFeld(fx, form, 'text'), '30 % auf alle Torten')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const h = puts(fx)[0].body.daten.hinweise
assert.deepEqual(h.map((x) => x.id), ['h_aktion001', 'h_urlaub001'], 'dieselbe Reihenfolge')
assert.equal(h[0].text, '30 % auf alle Torten')
assert.deepEqual(h[1], hinweisUrlaub)
assert.deepEqual(h[0].knopf, hinweisAktion.knopf)
// Entfernen mit Rückfrage
const zwei = await oeffne(fx, { start: stand(daten({ hinweise: [hinweisAktion, hinweisUrlaub] })) })
const zeile2 = fx.finde(zwei.root, 'li[data-id="h_urlaub001"]')
await fx.klick(fx.finde(zeile2, '[data-aktion="bearbeiten"]'))
await fx.klick(fx.finde(zeile2, '[data-aktion="entfernen"]'))
const rueckfrage = fx.finde(zeile2, '.tm-frage-box')
assert.match(fx.text(rueckfrage), /Hinweis entfernen\?/)
assert.equal(puts(fx).length, 1, 'erst die Rückfrage')
fx.antworten.push(antwort(daten({ hinweise: [hinweisAktion] }), 5))
await fx.klick(fx.finde(rueckfrage, 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.hinweise.map((x) => x.id), ['h_aktion001'])
assert.equal(fx.finde(zwei.root, 'li[data-id="h_urlaub001"]'), null, 'der Hinweis ist aus der Liste')
}))
test('Hinweis-Liste: laufend und geplant mit Pille, Vergangenes eingeklappt, nach Beginn sortiert; Text als Text', mitDom({}, async (fx) => {
const bose = '<img src=x onerror=alert(1)>'
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: [hinweisUrlaub, { ...hinweisAktion, text: bose }, hinweisAlt] })) })
const liste = abschnitt(fx, root, 'hinweise')
assert.equal(fx.alle(root, 'img').length, 0)
const kommend = fx.alle(fx.finde(liste, 'ul[aria-label="Laufende und geplante Hinweise"]'), 'li[data-id]').map((li) => li.getAttribute('data-id'))
assert.deepEqual(kommend, ['h_aktion001', 'h_urlaub001'], 'nach Beginn sortiert')
assert.match(fx.text(fx.finde(liste, 'li[data-id="h_aktion001"]')), /läuft/)
assert.match(fx.text(fx.finde(liste, 'li[data-id="h_urlaub001"]')), /geplant/)
const vorbei = fx.finde(liste, 'details')
assert.match(fx.text(fx.finde(vorbei, 'summary')), /Vergangene Hinweise \(1\)/)
assert.ok(fx.finde(vorbei, 'li[data-id="h_vorbei001"]'))
}))
test('Hinweise: bei 20 Hinweisen ist „Hinweis-Leiste anlegen“ gesperrt und sagt warum', mitDom({}, async (fx) => {
const viele = Array.from({ length: ZEITEN_LIMITS.hinweise }, (_, i) => ({ id: `h_viele${String(i).padStart(3, '0')}`, art: 'info', text: `Hinweis ${i}`, knopf: null, von: null, bis: null }))
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: viele })) })
assert.equal(fx.finde(root, '[data-werkzeug="hinweis-neu"]').disabled, true)
assert.match(fx.text(abschnitt(fx, root, 'hinweise')), /Mehr als 20 Hinweise sind nicht möglich/)
}))
test('Hinweis speichern zeichnet nur die Hinweise neu: Wochenplan-Entwurf und Kontakt-Eingaben bleiben', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const [von1] = zeitFelder(fx, tagZeile(fx, root, P1, '5'))
await fx.setze(von1, '08:15')
await fx.setze(kfeld(fx, root, 'adresse'), 'Neue Straße 9')
const kontaktAlt = abschnitt(fx, root, 'kontakt')
await fx.klick(fx.finde(root, '[data-werkzeug="hinweis-neu"]'))
const form = neuerHinweis(fx, root)
await fx.setze(hinweisFeld(fx, form, 'text'), 'Neu')
fx.antworten.push(antwort(daten({ hinweise: [{ id: 'h_neu00002', art: 'info', text: 'Neu', knopf: null, von: null, bis: null }] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '5'))[0].value, '08:15', 'der Wochenplan-Entwurf steht noch da')
assert.equal(abschnitt(fx, root, 'kontakt'), kontaktAlt, 'der Kontakt-Abschnitt ist dasselbe Element')
assert.equal(kfeld(fx, root, 'adresse').value, 'Neue Straße 9')
assert.ok(fx.finde(root, 'li[data-id="h_neu00002"]'))
assert.equal(puts(fx)[0].body.daten.plaene[0].tage['5'].fenster[0][0], '09:00', 'der Entwurf ging nicht mit hinaus')
}))
test('Urlaub-Frage „Ja, anlegen“: der neue Hinweis steht danach auch in der Hinweis-Liste', 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_urlaub02', 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 vorschlag = { id: 'h_vorschlag', art: 'urlaub', text: 'Betriebsurlaub', knopf: null, von: tagPlus(von, -7), bis }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub], hinweise: [vorschlag] }), 5))
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-ja"]'))
assert.ok(fx.finde(abschnitt(fx, root, 'hinweise'), 'li[data-id="h_vorschlag"]'), 'die Hinweis-Liste zeigt ihn')
}))
test('Hinweise abgeschaltet (nur ansehen): Liste ja, aber kein Knopf zum Anlegen und alle Felder gesperrt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten({ hinweise: [hinweisAktion] }), { state: 'disabled' }) })
const liste = abschnitt(fx, root, 'hinweise')
assert.ok(fx.finde(liste, 'li[data-id="h_aktion001"]'))
assert.equal(fx.finde(liste, '[data-werkzeug="hinweis-neu"]'), null)
await fx.klick(fx.finde(liste, '[data-aktion="bearbeiten"]'))
assert.equal(fx.text(fx.finde(liste, '[data-aktion="bearbeiten"]')), 'Ansehen')
const form = fx.finde(liste, 'form[aria-label="Hinweis bearbeiten"]')
assert.equal(fx.finde(form, 'button[type="submit"]'), null)
for (const feld of fx.alle(form, 'input, select')) assert.equal(feld.matches(':disabled'), true, `${feld.tagName} ist gesperrt`)
}))
// ---------------------------------------------------------------------------------------------------------------- Kontakt-Leiste
test('Kontakt-Leiste: Ein/Aus, Telefon, Adresse und WhatsApp aus den Daten; unverändert gespeichert bleibt bestaetigtAt (R12)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const form = kontaktForm(fx, root)
assert.ok(form)
assert.equal(kfeld(fx, root, 'an').checked, true)
assert.equal(kfeld(fx, root, 'telefon').value, '+49 6301123456', 'die gespeicherte Nummer (Ziffern mit Vorwahl) steht mit + da')
assert.equal(kfeld(fx, root, 'telefon').getAttribute('maxlength'), '30')
assert.equal(kfeld(fx, root, 'adresse').value, KONTAKT.adresse)
assert.equal(kfeld(fx, root, 'adresse').getAttribute('maxlength'), '200')
assert.equal(kfeld(fx, root, 'whatsapp').checked, true)
assert.equal(kfeld(fx, root, 'whatsapp.nummer').value, '+49 17112345678')
assert.equal(kfeld(fx, root, 'whatsapp.starttext').value, 'Hallo!')
assert.equal(kfeld(fx, root, 'whatsapp.starttext').getAttribute('maxlength'), '200')
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').checked, true, 'schon bestätigt')
assert.match(fx.text(form), /Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen/)
assert.equal(fx.finde(form, '[data-whatsapp]').hidden, false)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, '[data-aktion="kontakt-speichern"]'))
assert.deepEqual(puts(fx)[0].body.daten.kontakt, KONTAKT, 'dieselben Werte, bestaetigtAt unverändert')
}))
test('Kontakt: das Telefon geht so hinaus, wie der Kunde es schreibt; die Nummer aus der Antwort des Servers steht danach im Feld und in der Meldung', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(kfeld(fx, root, 'telefon'), '0171 1234567')
await fx.setze(kfeld(fx, root, 'adresse'), 'Marktplatz 1')
const gespeichert = daten({ kontakt: { ...KONTAKT, telefon: '491711234567', adresse: 'Marktplatz 1' } })
fx.antworten.push(antwort(gespeichert, 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
const k = puts(fx)[0].body.daten.kontakt
assert.equal(k.telefon, '0171 1234567')
assert.equal(k.adresse, 'Marktplatz 1')
assert.equal(kfeld(fx, root, 'telefon').value, '+49 1711234567', 'das Feld zeigt, was der Server gespeichert hat')
assert.ok(meldung(fx, root).includes('+49 1711234567'), 'die Meldung nennt die gespeicherte Nummer')
assert.match(meldung(fx, root), /Kontakt-Leiste gespeichert/)
}))
test('Kontakt: ohne gespeicherte Kontaktdaten sind die Felder leer und die Leiste aus', mitDom({}, async (fx) => {
const ohne = daten()
delete ohne.kontakt
const { root } = await oeffne(fx, { start: stand(ohne) })
assert.equal(kfeld(fx, root, 'an').checked, false)
assert.equal(kfeld(fx, root, 'telefon').value, '')
assert.equal(kfeld(fx, root, 'whatsapp').checked, false)
assert.equal(fx.finde(kontaktForm(fx, root), '[data-whatsapp]').hidden, true)
await fx.klick(kfeld(fx, root, 'an'))
await fx.setze(kfeld(fx, root, 'telefon'), '06301 123456')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.deepEqual(puts(fx)[0].body.daten.kontakt, { an: true, telefon: '06301 123456', adresse: '', whatsapp: null })
}))
test('Kontakt: WhatsApp verlangt den Datenschutz-Haken – ohne Haken geht nichts hinaus, mit neuem Haken ein frischer ISO-Zeitpunkt, ein gespeicherter bleibt', mitDom({}, async (fx) => {
const ohne = daten({ kontakt: { an: true, telefon: '496301123456', adresse: '', whatsapp: null } })
const { root } = await oeffne(fx, { start: stand(ohne) })
const form = kontaktForm(fx, root)
assert.equal(fx.finde(form, '[data-whatsapp]').hidden, true)
await fx.klick(kfeld(fx, root, 'whatsapp'))
assert.equal(fx.finde(form, '[data-whatsapp]').hidden, false, 'WhatsApp an zeigt die Felder')
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').checked, false)
await fx.setze(kfeld(fx, root, 'whatsapp.nummer'), '0171 7654321')
await fx.setze(kfeld(fx, root, 'whatsapp.starttext'), 'Hallo, ich habe eine Frage.')
await fx.klick(fx.finde(form, '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx).length, 0, 'ohne Haken geht nichts hinaus')
assert.match(fx.text(fehlerAn(fx, form, 'kontakt.whatsapp.bestaetigtAt')), /Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast/)
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').getAttribute('aria-invalid'), 'true')
// mit Haken: frischer Zeitpunkt (der Server ersetzt ihn durch seine Zeit)
await fx.klick(kfeld(fx, root, 'whatsapp.bestaetigtAt'))
const vorher = Date.now()
const serverZeit = '2026-10-05T08:00:00.000Z'
fx.antworten.push(antwort(daten({ kontakt: { an: true, telefon: '496301123456', adresse: '', whatsapp: { nummer: '491717654321', starttext: 'Hallo, ich habe eine Frage.', bestaetigtAt: serverZeit } } }), 4))
await fx.klick(fx.finde(form, '[data-aktion="kontakt-speichern"]'))
const w = puts(fx)[0].body.daten.kontakt.whatsapp
assert.equal(w.nummer, '0171 7654321')
assert.equal(w.starttext, 'Hallo, ich habe eine Frage.')
assert.match(w.bestaetigtAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
assert.ok(Math.abs(Date.parse(w.bestaetigtAt) - vorher) < 5000, 'ein frischer Zeitpunkt')
assert.equal(kfeld(fx, root, 'whatsapp.nummer').value, '+49 1717654321', 'die Nummer des Servers')
assert.ok(meldung(fx, root).includes('+49 1717654321'))
// der Haken steht jetzt (gespeichert): das nächste Speichern schickt den gespeicherten Wert unverändert zurück
assert.equal(kfeld(fx, root, 'whatsapp.bestaetigtAt').checked, true)
fx.antworten.push(antwort(daten(), 5))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx)[1].body.daten.kontakt.whatsapp.bestaetigtAt, serverZeit)
}))
test('Kontakt: WhatsApp abwählen ⇒ whatsapp null (und der Haken ist weg); die Leiste ausschalten ⇒ an false', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(kfeld(fx, root, 'whatsapp'))
assert.equal(fx.finde(kontaktForm(fx, root), '[data-whatsapp]').hidden, true)
await fx.klick(kfeld(fx, root, 'an'))
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
const k = puts(fx)[0].body.daten.kontakt
assert.equal(k.whatsapp, null)
assert.equal(k.an, false)
assert.equal(k.telefon, '496301123456', 'die übrigen Angaben bleiben')
}))
test('Kontakt: ein Fehler des Servers steht am Feld; Speichern ändert nichts an den anderen Formularen', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const [von1] = zeitFelder(fx, tagZeile(fx, root, P1, '5'))
await fx.setze(von1, '07:45')
await fx.setze(kfeld(fx, root, 'telefon'), 'abc')
fx.antworten.push(fehler(400, 'x', { fehler: { 'kontakt.telefon': 'Bitte gib eine gültige Telefonnummer an (nur Ziffern, Leerzeichen, + ( ) / . und -).' } }))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.match(fx.text(fehlerAn(fx, root, 'kontakt.telefon')), /gültige Telefonnummer/)
assert.equal(kfeld(fx, root, 'telefon').getAttribute('aria-invalid'), 'true')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '5'))[0].value, '07:45')
// Speichern gelingt: nur der Kontakt-Abschnitt wird neu gezeichnet
await fx.setze(kfeld(fx, root, 'telefon'), '06301 111')
fx.antworten.push(antwort(daten({ kontakt: { ...KONTAKT, telefon: '4963011110' } }), 4))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '5'))[0].value, '07:45', 'der Wochenplan-Entwurf bleibt')
}))
test('Kontakt in der Kundenansicht (R12): der WhatsApp-Haken ist gesperrt, ein gespeicherter Zeitpunkt geht unverändert zurück, nie ein neuer', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { kundenansicht: true }), kundenansicht: true })
const haken = kfeld(fx, root, 'whatsapp.bestaetigtAt')
assert.equal(haken.checked, true)
assert.equal(haken.disabled, true, 'gesperrt')
assert.match(fx.text(kontaktForm(fx, root)), /Kundenansicht/)
assert.match(fx.text(fx.finde(kontaktForm(fx, root), '[data-hinweis="whatsapp-kundenansicht"]')), /nur der Kunde selbst/)
await fx.klick(haken)
assert.equal(haken.checked, true, 'ein Klick ändert nichts')
await fx.setze(kfeld(fx, root, 'whatsapp.starttext'), 'Hallo zusammen')
fx.antworten.push(antwort(daten(), 4, { kundenansicht: true }))
await fx.klick(fx.finde(kontaktForm(fx, root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx)[0].body.daten.kontakt.whatsapp.bestaetigtAt, KONTAKT.whatsapp.bestaetigtAt, 'unverändert zurück')
assert.equal(puts(fx)[0].body.daten.kontakt.whatsapp.starttext, 'Hallo zusammen')
// ohne gespeicherte Bestätigung: WhatsApp einschalten geht in der Kundenansicht nicht (nie ein neuer Zeitpunkt)
const ohne = daten({ kontakt: { an: true, telefon: '496301123456', adresse: '', whatsapp: null } })
const zwei = await oeffne(fx, { start: stand(ohne, { kundenansicht: true }), kundenansicht: true })
await fx.klick(kfeld(fx, zwei.root, 'whatsapp'))
await fx.setze(kfeld(fx, zwei.root, 'whatsapp.nummer'), '0171 7654321')
const haken2 = kfeld(fx, zwei.root, 'whatsapp.bestaetigtAt')
assert.equal(haken2.disabled, true)
assert.equal(haken2.checked, false)
await fx.klick(fx.finde(kontaktForm(fx, zwei.root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx).length, 1, 'es geht nichts hinaus')
assert.match(fx.text(fehlerAn(fx, zwei.root, 'kontakt.whatsapp.bestaetigtAt')), /Kunde/)
// Kundenansicht ohne WhatsApp: speichern geht, whatsapp bleibt null
await fx.klick(kfeld(fx, zwei.root, 'whatsapp'))
fx.antworten.push(antwort(daten(), 5, { kundenansicht: true }))
await fx.klick(fx.finde(kontaktForm(fx, zwei.root), '[data-aktion="kontakt-speichern"]'))
assert.equal(puts(fx)[1].body.daten.kontakt.whatsapp, null)
}))
test('Kontakt abgeschaltet (nur ansehen): Felder gesperrt, kein Speichern-Knopf', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten(), { state: 'disabled' }) })
const form = kontaktForm(fx, root)
assert.equal(fx.finde(form, 'button[type="submit"]'), null)
for (const feld of fx.alle(form, 'input, select')) assert.equal(feld.matches(':disabled'), true, `${feld.tagName} ist gesperrt`)
}))
// ---------------------------------------------------------------------------------------------------------------- Google
test('Google: „Für Google kopieren“ legt den googleText des Servers in die Zwischenablage; Link zum Google-Profil, Satz zu den Google-Daten', mitDom({}, async (fx) => {
const text = 'Montag: 09:00–12:30, 13:30–18:00\nDienstag: 09:00–18:00\nSonntag: geschlossen'
const { root } = await oeffne(fx, { start: stand(daten(), { googleText: text }) })
const el = abschnitt(fx, root, 'google')
const link = fx.finde(el, 'a[data-google="profil"]')
assert.equal(fx.text(link), 'Google-Profil öffnen')
assert.equal(link.getAttribute('href'), 'https://business.google.com/')
assert.match(link.getAttribute('rel'), /noopener/)
assert.equal(link.getAttribute('target'), '_blank')
assert.match(fx.text(el), /Google-Daten im Seitencode laufen automatisch/)
const knopf = fx.finde(el, '[data-aktion="google-kopieren"]')
assert.equal(fx.text(knopf), 'Für Google kopieren')
assert.equal(fx.finde(el, '[data-google="text"]').value, text, 'der Text steht zum Nachlesen da')
const kopiert = []
await mitZwischenablage({ writeText: async (t) => { kopiert.push(t) } }, async () => {
await fx.klick(knopf)
})
assert.deepEqual(kopiert, [text])
assert.match(fx.text(fx.finde(el, '[data-meldung="google"]')), /Kopiert/)
// nach einem Speichern gilt der neue Text vom Server
const neu = 'Montag: 08:00–12:00\nSonntag: geschlossen'
fx.antworten.push(antwort(daten(), 4, { googleText: neu }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.finde(el, '[data-google="text"]').value, neu, 'die Anzeige folgt dem Server')
await mitZwischenablage({ writeText: async (t) => { kopiert.push(t) } }, async () => {
await fx.klick(knopf)
})
assert.deepEqual(kopiert, [text, neu])
}))
test('Google: fehlt die Zwischenablage oder wird das Kopieren abgelehnt, ist der Text markiert und eine Meldung sagt, wie es weitergeht', mitDom({}, async (fx) => {
const text = 'Montag: 09:00–18:00'
const { root } = await oeffne(fx, { start: stand(daten(), { googleText: text }) })
const el = abschnitt(fx, root, 'google')
const feld = fx.finde(el, '[data-google="text"]')
assert.equal(feld.hasAttribute('readonly'), true)
let markiert = 0
feld.select = () => { markiert += 1 }
await mitZwischenablage(undefined, async () => { await fx.klick(fx.finde(el, '[data-aktion="google-kopieren"]')) })
assert.equal(markiert, 1, 'ohne Zwischenablage: Text markiert')
assert.match(fx.text(fx.finde(el, '[data-meldung="google"]')), /markiert/)
assert.doesNotMatch(fx.text(fx.finde(el, '[data-meldung="google"]')), /^Kopiert/)
await mitZwischenablage({ writeText: async () => { throw new Error('verweigert') } }, async () => { await fx.klick(fx.finde(el, '[data-aktion="google-kopieren"]')) })
assert.equal(markiert, 2, 'abgelehnt: Text markiert')
assert.match(fx.text(fx.finde(el, '[data-meldung="google"]')), /markiert/)
}))
// ---------------------------------------------------------------------------------------------------------------- Vorschau
const vorschauText = (fx, root) => fx.text(fx.finde(root, '[data-vorschau="jetzt"]'))
const regler = (fx, root, name) => fx.finde(root, `[data-regler="${name}"]`)
const vorschauAufrufe = (fx) => fx.fetchLog.filter((a) => /\/vorschau\?/.test(a.url))
test('Vorschau oben: „So steht es gerade auf deiner Seite: …“ aus dem GET, nach dem Speichern aus der Antwort des PUT', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { vorschau: { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } }) })
const zeile = fx.finde(root, '.zt-vorschau')
assert.ok(zeile)
assert.match(fx.text(zeile), /So steht es gerade auf deiner Seite:/)
assert.equal(vorschauText(fx, root), 'Jetzt geöffnet · schließt um 18 Uhr')
assert.equal(fx.finde(root, '[data-vorschau="jetzt"]').getAttribute('data-offen'), 'true')
assert.equal(root.querySelector('.zt-kopfbereich').contains(zeile), true, 'oben, im Kopfbereich')
fx.antworten.push(antwort(daten(), 4, { vorschau: { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' } }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(vorschauText(fx, root), 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(fx.finde(root, '[data-vorschau="jetzt"]').getAttribute('data-offen'), 'false')
assert.equal(vorschauAufrufe(fx).length, 0, 'ohne Regler keine weitere Anfrage')
}))
test('Vorschau vor dem ersten Speichern: es steht noch nichts da; nach dem ersten Speichern der Status aus der Antwort', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
assert.match(fx.text(fx.finde(root, '.zt-vorschau')), /So steht es gerade auf deiner Seite:/)
assert.match(vorschauText(fx, root), /noch keine Öffnungszeiten gespeichert/i)
const id = fx.finde(root, 'details[data-plan]').getAttribute('data-plan')
fx.antworten.push(antwort(daten({ plaene: [plan(id)] }), 1, { vorschau: { offen: null, text: 'Öffnungszeiten nach Vereinbarung', kurz: 'Nach Vereinbarung' } }))
await fx.klick(speichernKnopf(fx, root, id))
assert.equal(vorschauText(fx, root), 'Öffnungszeiten nach Vereinbarung')
assert.equal(fx.alle(root, '.zt-vorschau').length, 1)
}))
test('Vorschau-Regler: fragt /vorschau mit dem nächsten Vorkommen des Wochentags und der Uhrzeit (Berliner Wanduhr) und zeigt die Antwort', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { vorschau: { offen: true, text: 'Jetzt geöffnet', kurz: 'Geöffnet' } }) })
const tag = regler(fx, root, 'tag')
const zeit = regler(fx, root, 'zeit')
assert.deepEqual(optionen(fx, tag), ['1', '2', '3', '4', '5', '6', '7'])
assert.deepEqual(fx.alle(tag, 'option').map((o) => fx.text(o)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
assert.equal(tag.value, String(wochentagNr(HEUTE)), 'Vorgabe: heute')
assert.equal(zeit.getAttribute('type'), 'time')
assert.match(zeit.value, /^\d{2}:\d{2}$/, 'Vorgabe: jetzt (Berliner Zeit)')
assert.match(fx.text(root.querySelector('.zt-regler')), /Uhrzeit testen/)
const geschlossen = { status: 200, body: { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' } }
fx.antworten.push(geschlossen, geschlossen) // eine Anfrage je Änderung
await fx.setze(tag, '3')
await fx.setze(zeit, '14:30')
const aufrufe = vorschauAufrufe(fx)
assert.ok(aufrufe.length >= 1)
const letzter = aufrufe[aufrufe.length - 1]
assert.equal(letzter.method, 'GET')
const url = new URL(letzter.url, 'http://x')
assert.equal(url.pathname, '/api/zeiten/p1/vorschau')
assert.equal(url.searchParams.get('zeit'), `${naechster(3)}T14:30`)
const test = fx.text(fx.finde(root, '[data-vorschau="test"]'))
assert.match(test, /Geschlossen · öffnet morgen um 9 Uhr/)
assert.ok(test.includes('Mittwoch'), 'der Tag der Anfrage steht dabei')
assert.ok(test.includes('14:30'))
assert.equal(vorschauText(fx, root), 'Jetzt geöffnet', 'die Vorschau „jetzt“ bleibt')
}))
test('Vorschau-Regler: 409 keine_zeiten ist ein Hinweis, kein Fehler; ein anderer Fehler steht als Fehler; leere Uhrzeit fragt nichts', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
fx.antworten.push(fehler(409, 'Bitte speichere zuerst deine Öffnungszeiten.', { code: 'keine_zeiten' }))
await fx.setze(regler(fx, root, 'zeit'), '10:00')
const ergebnis = fx.finde(root, '[data-vorschau="test"]')
assert.match(fx.text(ergebnis), /Speichere zuerst deine Öffnungszeiten/)
assert.notEqual(ergebnis.getAttribute('role'), 'alert', 'kein Fehler')
assert.equal(root.querySelector('[data-meldung="editor"]').textContent, '', 'nichts über dem Editor')
// anderer Fehler
fx.antworten.push(fehler(500, 'Fehler 500'))
await fx.setze(regler(fx, root, 'zeit'), '11:00')
assert.equal(fx.finde(root, '[data-vorschau="test"]').getAttribute('role'), 'alert')
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Fehler 500/)
// Uhrzeit leer ⇒ keine Anfrage
const n = vorschauAufrufe(fx).length
await fx.setze(regler(fx, root, 'zeit'), '')
assert.equal(vorschauAufrufe(fx).length, n)
}))
test('Vorschau-Regler: nur die letzte Anfrage zählt – eine späte Antwort auf eine frühere überschreibt nichts', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
let nr = 0
globalThis.fetch = async (url, optionen) => {
if (!/\/vorschau\?/.test(String(url))) return echtes(url, optionen)
nr += 1
const meine = nr
if (meine === 1) await wartet // die erste Antwort kommt zuletzt
fx.fetchLog.push({ method: 'GET', url: String(url) })
return { ok: true, status: 200, json: async () => ({ offen: meine === 1, text: meine === 1 ? 'Erste Frage' : 'Zweite Frage', kurz: '' }) }
}
try {
const erste = fx.setze(regler(fx, root, 'zeit'), '09:00')
await fx.ruhe()
await fx.setze(regler(fx, root, 'zeit'), '20:00')
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Zweite Frage/)
freigeben()
await erste
await fx.ruhe()
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Zweite Frage/, 'die späte erste Antwort wird verworfen')
assert.doesNotMatch(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Erste Frage/)
} finally {
globalThis.fetch = echtes
}
}))
test('Vorschau-Regler: nach dem Speichern wird dieselbe Uhrzeit mit den neuen Daten erneut gefragt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push({ status: 200, body: { offen: true, text: 'Alt', kurz: '' } })
await fx.setze(regler(fx, root, 'zeit'), '10:00')
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Alt/)
fx.antworten.push(antwort(daten(), 4), { status: 200, body: { offen: false, text: 'Neu', kurz: '' } })
await fx.klick(speichernKnopf(fx, root, P1))
await fx.ruhe()
assert.match(fx.text(fx.finde(root, '[data-vorschau="test"]')), /Neu/)
assert.equal(new URL(vorschauAufrufe(fx).at(-1).url, 'http://x').searchParams.get('zeit').slice(-5), '10:00')
}))
test('Vorschau abgeschaltet (nur ansehen): keine Vorschau und kein Regler (die Website zeigt nichts mehr)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten(), { state: 'disabled' }) })
assert.equal(fx.finde(root, '.zt-vorschau'), null)
assert.equal(fx.finde(root, '[data-regler]'), null)
}))
test('naechstesVorkommen: der erste Tag ab heute (einschließlich) mit dem Wochentag', async () => {
const { naechstesVorkommen, wochentagVon } = await import('../public/zeiten-view.js')
assert.equal(wochentagVon('2026-10-05'), 1, 'Montag')
assert.equal(wochentagVon('2026-10-11'), 7, 'Sonntag')
assert.equal(naechstesVorkommen('2026-10-05', 1), '2026-10-05', 'heute zählt')
assert.equal(naechstesVorkommen('2026-10-05', 3), '2026-10-07')
assert.equal(naechstesVorkommen('2026-10-07', 1), '2026-10-12', 'nächste Woche')
assert.equal(naechstesVorkommen('2026-10-05', 7), '2026-10-11')
assert.equal(naechstesVorkommen('2026-12-30', 5), '2027-01-01', 'über den Jahreswechsel')
})
// ---------------------------------------------------------------------------------------------------------------- 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', 'zeiten-leisten.js', 'zeiten-google.js']
test('Quelltext: sechs Editor-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`)
})