Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 650919b022
commit 0b75b31009
15 files changed
+2479 -79

No files matched your search

+278 -11
View File
@@ -5,7 +5,18 @@ import {
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
} from '../public/termine-view.js'
import {
MAX, BUNDESLAENDER, ERINNERUNG_STUNDEN, FENSTER_FEHLER, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, RASTER_MIN, WOCHENTAGE_PLAN, ZAHLARTEN,
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
} from '../public/termine-view.js'
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
import { exportUrl, request } from '../public/termine-dom.js'
import { beschrifteFehler } from '../public/termine-speichern.js'
import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js'
import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js'
import { VORLAGEN, ausVorlage } from '../server/services/termine/vorlagen.js'
const JETZT_SERVER = new Date('2026-10-05T08:00:00.000Z') // feste Uhr für Aufrufe der Server-Prüfung
// Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird
// nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit.
@@ -81,11 +92,10 @@ test('bereichFuer und verschiebeTag je Ansicht', () => {
assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' })
assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' })
assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' })
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-04' }, 'ab heute bis zum Ende des Buchungszeitraums (Vorgabe 60 Tage)')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 180), { von: '2026-10-05', bis: '2027-04-03' }, 'Buchungszeitraum der Einstellung')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 365), { von: '2026-10-05', bis: '2027-10-05' })
assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 9999).bis, '2027-10-05', 'höchstens 365 Tage')
for (const kaputt of [0, -5, NaN, 'x', null, undefined]) assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', kaputt).bis, '2026-12-04', `ungültiger Zeitraum ${String(kaputt)} ⇒ Vorgabe`)
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (so lange kann eine Anfrage nicht in der Zukunft liegen), unabhängig vom Buchungszeitraum
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2027-10-05' }, 'ab heute 365 Tage')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 30), { von: '2026-10-05', bis: '2027-10-05' }, 'ein kurzer Buchungszeitraum begrenzt die Liste nicht')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2028-02-28'), { von: '2028-02-28', bis: '2029-02-27' }, 'Schaltjahr: 365 Kalendertage')
assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01')
assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30')
assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12')
@@ -261,33 +271,65 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen eine
test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort()
assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js'])
assert.deepEqual(dateien, [
'termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js',
'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-termine.js', 'termine-team-ausnahmen.js', 'termine-view.js', 'termine.js',
])
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))])
// Nur termine-gaestetexte.js schreibt für Gäste (Mustertext, Auswahl der Anrede) und darf deshalb die Sie-Form enthalten
const GAESTETEXTE = 'termine-gaestetexte.js'
for (const [datei, quelle] of quellen) {
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`)
if (datei !== GAESTETEXTE) 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.match(quellen.find(([f]) => f === GAESTETEXTE)[1], /\bSie\b/, 'die Ausnahme hat einen Grund')
// Links nur auf die eigene Export-Schnittstelle (exportUrl), nie auf Kundentext
const hrefs = quellen.flatMap(([datei, quelle]) => (quelle.match(/href: [^,}]+|setAttribute\('href'/g) || []).map((m) => `${datei}: ${m}`))
assert.deepEqual(hrefs, ['termine.js: href: exportUrl(projectId)'])
assert.deepEqual(hrefs, ['termine-dom.js: href: exportUrl(projectId)'])
const dom = quellen.find(([f]) => f === 'termine-dom.js')[1]
assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID')
// Speichern der Einstellungen gibt es genau einmal (termine-speichern.js), nicht in jedem Reiter
assert.deepEqual(quellen.filter(([, quelle]) => /'PATCH'/.test(quelle)).map(([datei]) => datei), ['termine-speichern.js'])
})
test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => {
const huelle = lies('public/termine.js')
assert.match(huelle, /import \{ render as renderTermine \} from '\.\/termine-tab-termine\.js'/)
for (const tab of ['termine', 'leistungen', 'team', 'einstellungen']) {
assert.match(huelle, new RegExp(`import \\{ render as render${tab[0].toUpperCase()}${tab.slice(1)} \\} from '\\./termine-tab-${tab}\\.js'`))
assert.match(lies(`public/termine-tab-${tab}.js`), /export function render\(panel, ctx\)/)
}
assert.match(huelle, /const REITER = \[/)
assert.match(lies('public/termine-tab-termine.js'), /export function render\(panel, ctx\)/)
assert.deepEqual([...huelle.matchAll(/\{ key: '(\w+)', label: '([^']+)', render: (\w+) \}/g)].map((m) => [m[1], m[2], m[3]]), [
['termine', 'Termine', 'renderTermine'], ['leistungen', 'Leistungen', 'renderLeistungen'], ['team', 'Team & Plätze', 'renderTeam'], ['einstellungen', 'Einstellungen', 'renderEinstellungen'],
])
// Reiter lesen Zustand nur aus ctx, nicht aus der Huelle
for (const datei of ['termine-tab-termine.js', 'termine-aktionen.js', 'termine-formulare.js']) {
for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js']) {
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
}
})
test('beschrifteFehler: Fehler ohne Platz am Feld nennen den Eintrag', () => {
const quellen = [{
leistungen: [{ id: 's_aaaaaaaa', name: 'Herrenschnitt' }, { id: 's_bbbbbbbb', name: 'Färben' }],
ressourcen: [{ id: 'r_aaaaaaaa', name: 'Anna', typ: 'person' }, { id: 'r_bbbbbbbb', name: 'Kabine 1', typ: 'platz' }],
abwesenheiten: [{ id: 'a_aaaaaaaa', titel: 'Betriebsurlaub' }],
}]
assert.equal(beschrifteFehler('leistungen.1.ressourcen', 'Eine aktive Leistung braucht …', quellen), 'Leistung „Färben“: Eine aktive Leistung braucht …', 'nach Stelle')
assert.equal(beschrifteFehler('leistungen.s_aaaaaaaa', 'Für diese Leistung gibt es noch 1 künftigen Termin.', quellen), 'Leistung „Herrenschnitt“: Für diese Leistung gibt es noch 1 künftigen Termin.', 'nach ID')
assert.equal(beschrifteFehler('ressourcen.r_bbbbbbbb', 'Für diesen Platz …', quellen), 'Platz „Kabine 1“: Für diesen Platz …')
assert.equal(beschrifteFehler('ressourcen.0.plan.3', 'Zeitfenster überlappen sich.', quellen), 'Person „Anna“: Zeitfenster überlappen sich.')
assert.equal(beschrifteFehler('abwesenheiten.0.bis', 'Das Ende …', quellen), 'Abwesenheit „Betriebsurlaub“: Das Ende …')
assert.equal(beschrifteFehler('leistungen.7.name', 'Bitte gib einen Namen an.', quellen), 'Bitte gib einen Namen an.', 'unbekannter Eintrag: Text unverändert')
assert.equal(beschrifteFehler('regeln.raster', 'Das Raster …', quellen), 'Das Raster …')
assert.equal(beschrifteFehler('betrieb.adresse', 'Bitte gib die Anschrift an.', quellen), 'Bitte gib die Anschrift an. Das änderst du im Reiter „Einstellungen“.', 'Betriebsangaben stehen woanders')
assert.equal(beschrifteFehler('_', 'Zu umfangreich.', quellen), 'Zu umfangreich.')
// gesucht wird in allen Quellen (der alte Stand kennt Einträge, die der Entwurf nicht mehr hat)
assert.equal(beschrifteFehler('leistungen.s_cccccccc', 'x', [{ leistungen: [] }, { leistungen: [{ id: 's_cccccccc', name: 'Alt' }] }]), 'Leistung „Alt“: x')
})
test('request: Netzwerkfehler auf Deutsch, Fehler der Schnittstelle mit Feldern und Code', async () => {
const vorher = globalThis.fetch
try {
@@ -306,3 +348,228 @@ test('exportUrl: Projekt-ID wird codiert', () => {
assert.equal(exportUrl('p1'), '/api/termine/export.csv?projectId=p1')
assert.equal(exportUrl('a&b=c d'), '/api/termine/export.csv?projectId=a%26b%3Dc%20d')
})
// ---- Einstellungen bearbeiten (Aufgabe 21b): Wochenplan, IDs, Preise, Spiegel der Server-Grenzen
test('Wochenplan: Text je Wochentag ⇔ Plan der Einstellung', () => {
const plan = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
assert.deepEqual(WOCHENTAGE_PLAN.map(([nr, name]) => `${nr}${name}`), ['1Montag', '2Dienstag', '3Mittwoch', '4Donnerstag', '5Freitag', '6Samstag', '7Sonntag'])
assert.deepEqual(planTexte(plan), { 1: '09:00–12:30, 13:30–18:00', 2: '', 3: '', 4: '', 5: '', 6: '09:00–13:00', 7: '' })
assert.deepEqual(planTexte(undefined), { 1: '', 2: '', 3: '', 4: '', 5: '', 6: '', 7: '' })
assert.deepEqual(planAusTexten(planTexte(plan)), { plan }, 'Umkehrung')
assert.deepEqual(planAusTexten({ 1: ' ', 2: '8:00-12:00 und 14:00 bis 24:00' }), { plan: { 2: [['08:00', '12:00'], ['14:00', '24:00']] } }, 'leer = geschlossen, Schreibweisen werden vereinheitlicht')
assert.deepEqual(planAusTexten({}), { plan: {} })
assert.deepEqual(planAusTexten(undefined), { plan: {} })
assert.deepEqual(planAusTexten({ 3: '9-12', 5: '09:00–09:00', 1: '09:00–10:00' }), { fehler: { 3: FENSTER_FEHLER, 5: FENSTER_FEHLER } }, 'Fehler je Tag, kein Teilplan')
assert.match(FENSTER_FEHLER, /09:00–12:30, 13:30–18:00/)
})
test('Wochenplan aus dem Formular nimmt der Server an (5-Minuten-Raster, aufsteigend, bis 24:00)', () => {
const e = ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '1', adresse: 'Str. 1' }, JETZT_SERVER)
e.ressourcen[0].plan = planAusTexten({ 1: '07:05–12:00, 13:00–24:00', 7: '10:00–11:00' }).plan
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
assert.deepEqual(erg.einstellung.ressourcen[0].plan, { 1: [['07:05', '12:00'], ['13:00', '24:00']], 7: [['10:00', '11:00']] })
})
test('neueId: Präfix + 8 Zeichen a–z0–9, mit crypto.getRandomValues (Ruling R8)', () => {
for (const p of ['r', 's', 'a']) for (let i = 0; i < 50; i += 1) assert.match(neueId(p), new RegExp(`^${p}_[a-z0-9]{8}$`))
assert.equal(new Set(Array.from({ length: 2000 }, () => neueId('s'))).size, 2000, 'Zufall, keine Wiederholung')
assert.throws(() => neueId('x'), /Präfix/)
// Bytes ab 252 werden verworfen (252 = 7 · 36: sonst wären die ersten Zeichen häufiger); der Rest wird modulo 36 abgebildet
assert.equal(neueId('r', () => Uint8Array.from([255, 252, 253, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])), 'r_abcdefgh')
assert.equal(neueId('s', () => Uint8Array.from([35, 36, 71, 251, 250, 249, 248, 247, 0, 0, 0, 0, 0, 0, 0, 0])), 's_9a998765')
// zu wenige brauchbare Bytes ⇒ es wird nachgezogen
let aufrufe = 0
const nurSpaeterBrauchbar = () => {
aufrufe += 1
return Uint8Array.from({ length: 16 }, (_, i) => (aufrufe === 1 ? 255 : i))
}
assert.equal(neueId('a', nurSpaeterBrauchbar), 'a_abcdefgh')
assert.equal(aufrufe, 2)
})
test('eindeutigeId: nie eine ID, die es in der Liste schon gibt', () => {
const folgen = [Array.from({ length: 16 }, (_, i) => i), Array.from({ length: 16 }, (_, i) => i + 8)]
let k = 0
const bytes = () => Uint8Array.from(folgen[k++])
assert.equal(eindeutigeId('r', ['r_abcdefgh'], bytes), 'r_ijklmnop')
assert.equal(k, 2)
assert.match(eindeutigeId('s', []), /^s_[a-z0-9]{8}$/)
})
test('Preis: Euro-Text ⇔ Cent (deutsche Schreibweise), Anzeige wie beim Server', () => {
const gut = { '45': 4500, '45,00': 4500, '45,5': 4550, '45.50': 4550, '1.234,50': 123450, '1.234': 123400, '1234,5': 123450, ' 19,99 € ': 1999, '0': 0, '0,05': 5, '10000': 1000000, '10000,01': 1000001, '12.5': 1250 }
for (const [text, cent] of Object.entries(gut)) assert.equal(euroZuCent(text), cent, text)
for (const schlecht of ['', ' ', 'abc', ',50', '12,345', '1,2,3', '-5', '1.2.3', '1e3', '99999999999', '4 5', null, undefined, 45]) assert.equal(euroZuCent(schlecht), null, String(schlecht))
assert.equal(centZuEuro(4500), '45,00')
assert.equal(centZuEuro(5), '0,05')
assert.equal(centZuEuro(123450), '1234,50')
assert.equal(centZuEuro(0), '0,00')
assert.equal(centZuEuro(-1), '')
assert.equal(centZuEuro(1.5), '')
assert.equal(centZuEuro(undefined), '')
assert.equal(euroZuCent(centZuEuro(123456)), 123456, 'Hin und zurück')
for (const l of [{ preisArt: 'fest', preisCent: 4500 }, { preisArt: 'ab', preisCent: 4500 }, { preisArt: 'fest', preisCent: 123450 }, { preisArt: 'ab', preisCent: 5 }]) {
assert.equal(preisAnzeige(l), preisText(l), `wie preisText des Servers: ${JSON.stringify(l)}`)
}
assert.equal(preisAnzeige({ preisArt: 'keiner', preisCent: 0 }), 'ohne Preis')
assert.equal(preisAnzeige({ preisArt: 'fest', preisCent: 0 }), 'ohne Preis')
assert.equal(preisAnzeige(undefined), 'ohne Preis')
})
test('ganzzahl: nur ganze Zahlen aus Feldern, sonst null', () => {
assert.equal(ganzzahl('45'), 45)
assert.equal(ganzzahl(' 45 '), 45)
assert.equal(ganzzahl('0'), 0)
assert.equal(ganzzahl('-5'), -5)
for (const schlecht of ['', ' ', '4.5', '4,5', 'abc', '1e3', '9999999999', null, undefined, 5]) assert.equal(ganzzahl(schlecht), null, String(schlecht))
})
test('zusatzSchluessel: Schlüssel aus der Beschriftung, eindeutig, vom Server erlaubt', () => {
assert.equal(zusatzSchluessel('Wunschfarbe', []), 'wunschfarbe')
assert.equal(zusatzSchluessel('Größe in cm', []), 'groesse_in_cm')
assert.equal(zusatzSchluessel(' Haar-Länge (Straße) ', []), 'haar_laenge_strasse')
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge']), 'haarlaenge_2')
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge', 'haarlaenge_2']), 'haarlaenge_3')
assert.equal(zusatzSchluessel('!!!', []), 'feld')
assert.equal(zusatzSchluessel('', ['feld']), 'feld_2')
const lang = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [])
assert.match(lang, /^[a-z0-9_]{1,20}$/)
const langZwei = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [lang])
assert.match(langZwei, /^[a-z0-9_]{1,20}$/)
assert.notEqual(langZwei, lang)
for (const reserviert of ['constructor', 'prototype', '__proto__']) {
const key = zusatzSchluessel(reserviert, [])
assert.match(key, /^[a-z0-9_]{1,20}$/)
assert.ok(!['__proto__', 'constructor', 'prototype'].includes(key), `${reserviert} ⇒ ${key}`)
}
})
test('Auswahlmöglichkeiten: eine je Zeile', () => {
assert.deepEqual(optionenAusText('Rot\n Blau \r\n\nGrün\n'), ['Rot', 'Blau', 'Grün'])
assert.deepEqual(optionenAusText(''), [])
assert.deepEqual(optionenAusText(undefined), [])
assert.equal(optionenText(['Rot', 'Blau']), 'Rot\nBlau')
assert.equal(optionenText(undefined), '')
})
test('verschiebeEintrag: Reihenfolge ändern, ohne die Liste zu verändern', () => {
const liste = ['a', 'b', 'c']
assert.deepEqual(verschiebeEintrag(liste, 1, -1), ['b', 'a', 'c'])
assert.deepEqual(verschiebeEintrag(liste, 0, 1), ['b', 'a', 'c'])
assert.deepEqual(verschiebeEintrag(liste, 2, 1), ['a', 'b', 'c'], 'am Ende nach unten: unverändert')
assert.deepEqual(verschiebeEintrag(liste, 0, -1), ['a', 'b', 'c'], 'am Anfang nach oben: unverändert')
assert.deepEqual(verschiebeEintrag(liste, 7, 1), ['a', 'b', 'c'], 'unbekannte Stelle')
assert.deepEqual(liste, ['a', 'b', 'c'])
assert.notEqual(verschiebeEintrag(liste, 0, -1), liste, 'immer eine Kopie')
})
test('aktivSeitText: Datum nach Berliner Kalender', () => {
assert.equal(aktivSeitText('2026-10-01T09:00:00.000Z'), 'Aktiv seit 01.10.2026')
assert.equal(aktivSeitText('2026-09-30T22:30:00.000Z'), 'Aktiv seit 01.10.2026', 'nach Mitternacht in Berlin')
assert.equal(aktivSeitText('2026-09-30T21:30:00.000Z'), 'Aktiv seit 30.09.2026')
for (const schlecht of ['', 'gestern', null, undefined, 0]) assert.equal(aktivSeitText(schlecht), '', String(schlecht))
})
test('Hinweis zu Gesundheitsangaben: Wortlaut wie bei den Gästen', () => {
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
})
// ---- Spiegel der Auswahl und Grenzen des Servers (einstellungen.js): driftet eine Seite, wird dieser Test rot
const basisEinstellung = () => ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '06301 1', adresse: 'Hauptstr. 1' }, JETZT_SERVER)
/** Fehler des Servers für eine geänderte Einstellung ({} = gültig). */
const serverFehler = (aendere) => {
const e = basisEinstellung()
aendere(e)
return normalisiereEinstellung(e, { now: JETZT_SERVER }).fehler || {}
}
test('Spiegel: Auswahllisten sind die des Servers', () => {
assert.deepEqual(RASTER_MIN, [5, 10, 15, 20, 30, 60])
for (const r of RASTER_MIN) assert.equal(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], undefined, `Raster ${r}`)
for (const r of [0, 7, 45, 90]) assert.ok(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], `Raster ${r} gibt es nicht`)
assert.deepEqual(ERINNERUNG_STUNDEN, [2, 12, 24, 48])
for (const n of ERINNERUNG_STUNDEN) assert.equal(serverFehler((e) => { e.erinnerung.stundenVorher = n })['erinnerung.stundenVorher'], undefined, `${n} Stunden`)
assert.ok(serverFehler((e) => { e.erinnerung.stundenVorher = 3 })['erinnerung.stundenVorher'])
assert.deepEqual(GAST_FELD_WAHL.map(([key]) => key), ['aus', 'optional', 'pflicht'])
for (const [key] of GAST_FELD_WAHL) {
assert.deepEqual(serverFehler((e) => { e.gastFelder.telefon = key; e.gastFelder.nachricht = key }), {}, key)
}
assert.deepEqual(new Set(BUNDESLAENDER.map(([key]) => key)), new Set(SERVER_BUNDESLAENDER), 'alle 16 Bundesländer, keines zu viel')
assert.equal(BUNDESLAENDER.length, 16)
assert.deepEqual(BUNDESLAENDER.map(([, name]) => name), [...BUNDESLAENDER.map(([, name]) => name)].sort((a, b) => a.localeCompare(b, 'de')), 'nach Namen sortiert')
for (const [key] of BUNDESLAENDER) assert.deepEqual(serverFehler((e) => { e.bundesland = key }), {}, key)
assert.deepEqual(ANREDE_WAHL.map(([key]) => key), ['sie', 'du'])
for (const [key] of ANREDE_WAHL) assert.deepEqual(serverFehler((e) => { e.anrede = key }), {}, key)
})
test('Spiegel: Zahlarten und ihre Namen sind die des Servers', () => {
const keys = ZAHLARTEN.map(([key]) => key)
assert.deepEqual(keys, ['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal'])
const e = basisEinstellung()
e.zahlung.vorOrtArten = keys
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
assert.equal(erg.fehler, undefined)
assert.deepEqual(erg.einstellung.zahlung.vorOrtArten, keys, 'Reihenfolge wie beim Server')
assert.equal(zahlungText(erg.einstellung), `Bezahlung vor Ort: ${ZAHLARTEN.map(([, name]) => name).join(', ')}`)
assert.ok(serverFehler((x) => { x.zahlung.vorOrtArten = ['bitcoin'] })['zahlung.vorOrtArten'])
})
test('Spiegel: Obergrenzen der Listen sind die des Servers', () => {
assert.deepEqual(LIMITS, { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 })
const tag = (i) => new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10)
const faelle = {
ressourcen: [LIMITS.ressourcen, 'ressourcen', (e, n) => { e.leistungen = []; e.abwesenheiten = []; e.ressourcen = Array.from({ length: n }, (_, i) => ({ id: neueId('r'), typ: 'person', name: `P${i}`, aktiv: true, plan: {} })) }],
leistungen: [LIMITS.leistungen, 'leistungen', (e, n) => { e.leistungen = Array.from({ length: n }, (_, i) => ({ ...e.leistungen[0], id: neueId('s'), name: `L${i}` })) }],
abwesenheiten: [LIMITS.abwesenheiten, 'abwesenheiten', (e, n) => { e.abwesenheiten = Array.from({ length: n }, (_, i) => ({ id: neueId('a'), ressourceId: null, von: tag(i), bis: tag(i), titel: '' })) }],
sonderoeffnungen: [LIMITS.sonderoeffnungen, 'sonderoeffnungen', (e, n) => { e.sonderoeffnungen = Object.fromEntries(Array.from({ length: n }, (_, i) => [tag(i), []])) }],
zusatzfelder: [LIMITS.zusatzfelder, 'gastFelder.zusatz', (e, n) => { e.gastFelder.zusatz = Array.from({ length: n }, (_, i) => ({ key: `f${i}`, label: `F${i}`, typ: 'text', optionen: [], pflicht: false })) }],
optionen: [LIMITS.optionen, 'gastFelder.zusatz.0.optionen', (e, n) => { e.gastFelder.zusatz = [{ key: 'wahl', label: 'Wahl', typ: 'auswahl', optionen: Array.from({ length: n }, (_, i) => `O${i}`), pflicht: false }] }],
}
for (const [name, [grenze, pfad, baue]] of Object.entries(faelle)) {
assert.equal(serverFehler((e) => baue(e, grenze))[pfad], undefined, `${name}: ${grenze} sind erlaubt`)
assert.ok(serverFehler((e) => baue(e, grenze + 1))[pfad], `${name}: ${grenze + 1} sind zu viele`)
}
})
test('Spiegel: Längen der Eingabefelder (maxlength) kürzt der Server nicht mehr', () => {
const lang = (n) => 'x'.repeat(n)
const lies = (e, was) => was(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung)
const faelle = [
['betriebName', (e, t) => { e.betrieb.name = t }, (x) => x.betrieb.name],
['telefon', (e, t) => { e.betrieb.telefon = t }, (x) => x.betrieb.telefon],
['adresse', (e, t) => { e.betrieb.adresse = t }, (x) => x.betrieb.adresse],
['leistungName', (e, t) => { e.leistungen[0].name = t }, (x) => x.leistungen[0].name],
['beschreibung', (e, t) => { e.leistungen[0].beschreibung = t }, (x) => x.leistungen[0].beschreibung],
['ressourcenName', (e, t) => { e.ressourcen[0].name = t }, (x) => x.ressourcen[0].name],
['abwesenheitTitel', (e, t) => { e.abwesenheiten = [{ id: 'a_abcd1234', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: t }] }, (x) => x.abwesenheiten[0].titel],
['zusatzLabel', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: t, typ: 'text', optionen: [], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].label],
['option', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: 'K', typ: 'auswahl', optionen: [t], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].optionen[0]],
['storno', (e, t) => { e.storno = { text: t } }, (x) => x.storno.text],
]
for (const [name, setze, lese] of faelle) {
const grenze = MAX[name === 'telefon' ? 'telefon' : name]
assert.ok(Number.isInteger(grenze), `MAX.${name}`)
const e = basisEinstellung()
setze(e, lang(grenze))
assert.equal(lies(e, lese).length, grenze, `${name}: ${grenze} Zeichen bleiben ganz`)
const zuLang = basisEinstellung()
setze(zuLang, lang(grenze + 1))
assert.equal(lies(zuLang, lese).length, grenze, `${name}: mehr als ${grenze} würden gekürzt`)
}
assert.equal(MAX.url, 300)
const url = (n) => `https://example.org/${'a'.repeat(n - 'https://example.org/'.length)}`
assert.equal(serverFehler((e) => { e.betrieb.impressumUrl = url(300) })['betrieb.impressumUrl'], undefined)
assert.ok(serverFehler((e) => { e.betrieb.impressumUrl = url(301) })['betrieb.impressumUrl'])
})
test('Mustertext Storno (Gästetext): wie die Vorlagen des Servers, Frist einsetzbar', () => {
assert.equal(stornoMuster(24), VORLAGEN.salon.storno, 'derselbe Wortlaut wie in den Vorlagen')
assert.ok(stornoMuster(48).includes('bis 48 Stunden vorher'))
assert.ok(stornoMuster(48).includes('50 % des Preises'))
for (const unbrauchbar of [0, -1, 1.5, NaN, 'x', null, undefined]) assert.equal(stornoMuster(unbrauchbar), stornoMuster(24), String(unbrauchbar))
const e = basisEinstellung()
e.storno = { text: stornoMuster(36) }
assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht')
assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)'])
})
+775 -17
View File
@@ -7,13 +7,13 @@
// - "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten; der Server liefert nur dashboard.html und die Dateien aus public/.
// - "echt_*": die echten Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite; nur die uebrigen Portal-Schnittstellen
// (/api/auth/me usw.) mockt page.route. Portal-Sitzung per Cookie (srv.portalCookie()).
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln).
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
// Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import {
startTermineUiServer, salonEingabe,
R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_ANFRAGE,
R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_KINDER, S_ANFRAGE,
} from './termineTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
@@ -151,6 +151,7 @@ async function fixtureAntwort(fx, route, req, url, json) {
if (aktion && m === 'POST') {
const body = req.postDataJSON()
fx.aufrufe.push(['aktion', aktion[1], body])
if (fx.aktionVerzoegerung) await new Promise((fertig) => { setTimeout(fertig, fx.aktionVerzoegerung) })
const b = fx.buchungen.find((x) => x.id === aktion[1])
if (b.gastName === 'Konflikt Test') return json({ error: 'Dieser Termin ist nicht mehr offen.', code: 'nicht_moeglich', fehler: { _: 'Dieser Termin ist nicht mehr offen.' } }, 409)
if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
@@ -176,14 +177,15 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
await ctx.clock.setFixedTime(jetzt)
if (!fixture) await ctx.addCookies([await srv.portalCookie()])
const page = await ctx.newPage()
page.fx = fixture
page.setDefaultTimeout(8000)
ctx.setDefaultTimeout(8000)
const fehler = []
page.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
await page.route('**/api/**', async (route) => {
// jede Seite des Kontexts (auch ein zweiter Tab, ctx.newPage()) wird beobachtet und beantwortet die Schnittstellen gleich
ctx.on('page', (seite) => {
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
})
await ctx.route('**/api/**', async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = url.pathname
@@ -191,6 +193,8 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return fixture ? fixtureAntwort(fixture, route, req, url, json) : route.continue()
return portalHilfe(p, json)
})
const page = await ctx.newPage()
page.fx = fixture
try {
await fn(page, ctx)
assert(fehler.length === 0, fehler.join(' | '))
@@ -199,6 +203,7 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
if (process.env.STACK) console.log(e.stack)
await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
@@ -208,6 +213,29 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
const oeffne = async (page, hash) => {
await page.goto(`${srv.url}/dashboard.html${hash}`)
}
/** Ansicht öffnen und zum Reiter wechseln ('leistungen' | 'team' | 'einstellungen' | 'termine'). */
const oeffneReiter = async (page, key) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#tm-tab-${key}[aria-selected="true"]`).waitFor()
await page.locator('#tm-panel .tm-tab').waitFor()
}
/** Gespeicherte Einstellung und Version im Nachbau von Appwrite. */
const gespeichert = () => srv.einstellung()
const version = () => srv.aw.docs('termineConfig').get(srv.FEATURE).version
/** Wartet (bis 5 s), bis eine Bedingung im Nachbau des Servers stimmt (z. B. ein Speichern angekommen ist). */
async function warteBis(bedingung, text) {
for (let i = 0; i < 100; i += 1) {
if (bedingung()) return
await new Promise((fertig) => { setTimeout(fertig, 50) })
}
throw new Error(`Zeit abgelaufen: ${text}`)
}
const zeileMitId = (page, id) => page.locator(`#tm-panel li[data-id="${id}"]`)
const eintrag = (page, text) => page.locator('#tm-panel li.tm-eintrag', { hasText: text })
const formular = (page, name) => page.getByRole('form', { name })
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
const warteAnsicht = async (page) => {
await page.locator('#view-termine.active').waitFor()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
@@ -496,7 +524,10 @@ async function pruefungen() {
await knopf(banner, 'Anfragen ansehen').click()
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist
eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen')
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-04', status: 'angefragt' }, 'Abfrage: ab heute bis Ende des Buchungszeitraums (60 Tage), nur Anfragen')
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (sechs Aufrufe zu hoechstens 62 Tagen), nur Anfragen
const anfragenAufrufe = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
eq(anfragenAufrufe.length, 6, 'sechs Abfragefenster')
eq([anfragenAufrufe[0].von, anfragenAufrufe.at(-1).bis], ['2026-10-05', '2027-10-05'], 'ab heute bis heute + 365 Tage')
eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage')
const zeile = reihe(page, 'Lea Braun')
// Details zeigen Kontakt und Nachricht als Text
@@ -621,18 +652,18 @@ async function pruefungen() {
await oeffne(page, '#termine')
await warteAnsicht(page)
const banner = page.locator('.tm-box-info')
await banner.getByText('3 Anfragen warten').waitFor()
await banner.getByText('4 Anfragen warten').waitFor()
await knopf(banner, 'Anfragen ansehen').click()
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 3)
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2'], 'alle drei Anfragen in Zeitfolge')
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 4)
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei, auch die jenseits des Buchungszeitraums von 180 Tagen (330 Tage)
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2', 'bweit'], 'alle vier Anfragen in Zeitfolge')
assert((await textVon(reihe(page, 'Fern Eins'))).includes('Angefragt'), 'ferne Anfrage mit Status')
// Abfragen: lueckenlose Fenster ab heute bis heute + Buchungszeitraum (180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
// Abfragen (Ruling R50): lueckenlose Fenster ab heute bis heute + 365 Tage, unabhaengig vom Buchungszeitraum (hier 180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
const tageZwischen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000)
const fenster = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
eq(fenster.length, 3, 'drei Aufrufe')
eq(fenster.length, 6, 'sechs Aufrufe')
eq(fenster[0].von, '2026-10-05', 'beginnt heute')
eq(fenster.at(-1).bis, '2027-04-03', 'endet nach 180 Tagen')
eq(fenster.at(-1).bis, '2027-10-05', 'endet nach 365 Tagen')
fenster.forEach((f, i) => {
assert(tageZwischen(f.von, f.bis) <= 62, `Fenster ${i} zu lang: ${f.von}..${f.bis}`)
if (i > 0) eq(tageZwischen(fenster[i - 1].bis, f.von), 1, `Fenster ${i} schließt lückenlos an`)
@@ -646,6 +677,7 @@ async function pruefungen() {
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun' }),
buchung('bfern1', '2027-01-20', '09:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Eins' }),
buchung('bfern2', '2027-03-10', '14:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Zwei' }),
buchung('bweit', '2027-09-01', '11:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Weit Weg' }),
]
return fx
})(),
@@ -1071,6 +1103,732 @@ async function pruefungen() {
await shot(page, name === 'echt_zeitzone_new_york' ? 'echt-new-york' : 'echt-berlin')
}, { context: { timezoneId: zone }, jetzt: new Date('2026-10-05T23:30:00Z') })
}
// ============================================================ Aufgabe 21b: Leistungen, Team & Plätze, Einstellungen (echte Router)
// ------------------------------------------------------------ Leistungen: anlegen, bearbeiten, Reihenfolge
await check('echt_leistung_anlegen_bearbeiten_reihenfolge', async (page) => {
await oeffneReiter(page, 'leistungen')
assert((await textVon(page.locator('#tm-panel .tm-box-info'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts(), ['Herrenschnitt', 'Damenschnitt', 'Kinderschnitt', 'Färben', 'Kennenlern-Beratung', 'Brautfrisur'], 'Reihenfolge wie gespeichert')
const meta = await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())
assert(['30 Min.', '5 Min. Pause danach', '28,00 €', 'Sofort buchbar', 'Anna, Ben'].every((t) => meta.includes(t)), `Kurzfassung: ${meta}`)
assert((await textVon(eintrag(page, 'Brautfrisur'))).includes('Nur als Anfrage') && (await textVon(eintrag(page, 'Kennenlern-Beratung'))).includes('ohne Preis'), 'Anfrage und ohne Preis')
await shot(page, 'leistungen')
// Leistung anlegen: erst Fehler (Dauer leer: im Feld erkannt; ohne Name und Person: vom Server), dann ein gueltiger Eintrag
const v0 = version()
await knopf(page, '+ Leistung anlegen').click()
const neu = formular(page, 'Leistung anlegen')
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('')
await knopf(neu, 'Leistung anlegen').click()
eq(await textVon(fehlerAm(neu, 'leistungen.6.dauer')), 'Bitte gib die Dauer in ganzen Minuten an.', 'Dauer leer')
eq(version(), v0, 'nichts gespeichert')
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('20')
await knopf(neu, 'Leistung anlegen').click()
await fehlerAm(neu, 'leistungen.6.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
assert((await textVon(fehlerAm(neu, 'leistungen.6.ressourcen'))).includes('mindestens eine aktive Person'), 'Zuordnung fehlt (Server)')
eq(await neu.getByLabel('Name', { exact: true }).getAttribute('aria-invalid'), 'true', 'Feld markiert')
eq(version(), v0, 'auch der Server hat nichts gespeichert')
await neu.getByLabel('Name', { exact: true }).fill('Bartpflege')
await neu.getByLabel('Beschreibung (optional)').fill('Trimmen und Konturen.')
await neu.getByLabel('Pause danach (Minuten)').fill('5')
await neu.getByLabel('Preis', { exact: true }).selectOption('fest')
await neu.getByLabel('Betrag in Euro').fill('19,90')
await neu.getByLabel('Anna · Person').check()
await shot(page, 'leistung-anlegen')
await knopf(neu, 'Leistung anlegen').click()
await eintrag(page, 'Bartpflege').waitFor()
assert((await textVon(meldung(page))).includes('Leistung angelegt.'), 'Meldung')
eq(version(), v0 + 1, 'ein Speichern')
const angelegt = gespeichert().leistungen.at(-1)
assert(/^s_[a-z0-9]{8}$/.test(angelegt.id), `ID im Muster s_ + 8: ${angelegt.id}`)
eq({ ...angelegt, id: 'x' }, { id: 'x', name: 'Bartpflege', beschreibung: 'Trimmen und Konturen.', dauer: 20, pufferNach: 5, preisArt: 'fest', preisCent: 1990, buchungsart: 'sofort', ressourcen: [R_ANNA], aktiv: true, reihenfolge: 7 }, 'gespeicherte Leistung')
const meta2 = await textVon(eintrag(page, 'Bartpflege').locator('.tm-meta').first())
assert(meta2.includes('19,90 €') && meta2.includes('Anna') && !meta2.includes('Ben'), `Zeile: ${meta2}`)
// Gaeste sehen sie sofort
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
const gast = conf.leistungen.find((l) => l.name === 'Bartpflege')
assert(gast && gast.preis === '19,90 €' && gast.id === angelegt.id, 'oeffentliche Config nennt die neue Leistung')
// bearbeiten, zweimal hintereinander ohne Neuladen (die Version wird mitgefuehrt)
for (const dauer of ['35', '40']) {
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill(dauer)
await knopf(form, 'Speichern').click()
await page.waitForFunction((d) => document.querySelector('#tm-panel li[data-id="s_herren01"] .tm-meta')?.textContent.includes(`${d} Min.`), dauer)
eq(gespeichert().leistungen[0].dauer, Number(dauer), `Dauer ${dauer} gespeichert`)
}
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt mit sich selbst')
assert((await textVon(meldung(page))).includes('Leistung gespeichert.'), 'Meldung Speichern')
// Reihenfolge: Damenschnitt nach oben
await knopf(page, 'Nach oben: Damenschnitt').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li.tm-eintrag .tm-name')?.textContent === 'Damenschnitt')
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 2), [['Damenschnitt', 1], ['Herrenschnitt', 2]], 'Reihenfolge gespeichert')
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), true, 'ganz oben: nicht weiter hoch')
eq(await knopf(page, 'Nach unten: Bartpflege').isDisabled(), true, 'ganz unten: nicht weiter runter')
// der Fokus bleibt in der verschobenen Zeile (kein Verlust nach dem Neuzeichnen)
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
// Deaktivieren (Leistung bleibt gespeichert), Gaeste sehen sie nicht mehr
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
await kind.getByLabel(/^Aktiv/).uncheck()
await knopf(kind, 'Speichern').click()
await eintrag(page, 'Kinderschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
assert(!(await (await fetch(`${API}/${KEY}/config`)).json()).leistungen.some((l) => l.name === 'Kinderschnitt'), 'inaktive Leistung nicht oeffentlich')
await shot(page, 'leistungen-nach-aenderung')
})
// ------------------------------------------------------------ Leistung mit kuenftigem Termin entfernen ⇒ Anzahl am Eintrag
await check('echt_leistung_entfernen_mit_termin', async (page) => {
await buche({ leistung: S_HERREN, tag: '2026-10-12', zeit: '10:00', email: 'a@example.com' })
await buche({ leistung: S_HERREN, tag: '2026-10-13', zeit: '11:00', email: 'b@example.com' })
await buche({ leistung: S_DAMEN, tag: '2026-10-14', zeit: '09:00', email: 'c@example.com', name: 'Dora Damen' })
await oeffneReiter(page, 'leistungen')
const v0 = version()
// zwei kuenftige Termine ⇒ Mehrzahl, die Leistung bleibt
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await knopf(form, 'Entfernen').click()
const frage = page.getByRole('form', { name: 'Leistung „Herrenschnitt“ entfernen?' })
await frage.waitFor()
assert((await textVon(frage)).includes('Das kann nicht rückgängig gemacht werden.'), 'Rueckfrage in der Seite')
await knopf(frage, 'Ja, entfernen').click()
await frage.locator('.tm-fehler:not(:empty)').waitFor()
eq(await textVon(frage.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.', 'Anzahl am Eintrag')
eq(gespeichert().leistungen.some((l) => l.id === S_HERREN), true, 'Leistung bleibt')
eq(version(), v0, 'nichts gespeichert')
await shot(page, 'leistung-entfernen-mit-termin')
// Abbrechen schliesst die Rueckfrage
await knopf(frage, 'Abbrechen').click()
eq(await frage.count(), 0, 'Rueckfrage zu')
// ein Termin ⇒ Einzahl
await knopf(eintrag(page, 'Damenschnitt'), 'Bearbeiten: Damenschnitt').click()
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Entfernen').click()
const frageDamen = page.getByRole('form', { name: 'Leistung „Damenschnitt“ entfernen?' })
await knopf(frageDamen, 'Ja, entfernen').click()
await frageDamen.locator('.tm-fehler:not(:empty)').waitFor()
eq(await textVon(frageDamen.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.', 'Einzahl')
// Deaktivieren geht, die Termine bleiben
await knopf(frageDamen, 'Abbrechen').click()
await formular(page, 'Leistung bearbeiten: Damenschnitt').getByLabel(/^Aktiv/).uncheck()
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Speichern').click()
await eintrag(page, 'Damenschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
eq(srv.buchungen().filter((b) => b.leistungId === S_DAMEN && b.status === 'bestaetigt').length, 1, 'Termin der inaktiven Leistung bleibt')
// ohne Termin ⇒ weg
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
await knopf(formular(page, 'Leistung bearbeiten: Kinderschnitt'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: 'Leistung „Kinderschnitt“ entfernen?' }), 'Ja, entfernen').click()
await eintrag(page, 'Kinderschnitt').waitFor({ state: 'detached' })
eq(gespeichert().leistungen.some((l) => l.id === S_KINDER), false, 'Kinderschnitt entfernt')
assert((await textVon(meldung(page))).includes('Leistung „Kinderschnitt“ entfernt.'), 'Meldung')
})
// ------------------------------------------------------------ Team & Plätze: Wochenplan ändern, neuer Platz mit Zuordnung
await check('echt_team_wochenplan_und_neuer_platz', async (page) => {
await oeffneReiter(page, 'team')
const kopfzeile = await page.locator('#tm-panel li.tm-eintrag .tm-meta').allInnerTexts()
assert(kopfzeile[0].includes('Person') && kopfzeile[0].includes('Zeiten an 6 Tagen') && kopfzeile[0].includes('6 Leistungen'), `Kurzfassung: ${kopfzeile[0]}`)
await shot(page, 'team')
// Wochenplan von Anna: Montag ändern
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const form = formular(page, 'Bearbeiten: Anna')
const montag = form.getByLabel('Montag', { exact: true })
eq(await montag.inputValue(), '09:00–12:30, 13:30–18:00', 'Wochenplan als Text')
eq(await form.getByLabel('Sonntag', { exact: true }).inputValue(), '', 'Sonntag geschlossen')
await shot(page, 'team-bearbeiten')
// ungueltige Eingaben ⇒ Fehler am Tag, nichts wird gesendet
const v0 = version()
await montag.fill('10-25')
await form.getByLabel('Dienstag', { exact: true }).fill('09:00–12:00, 11:00–13:00')
await knopf(form, 'Speichern').click()
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.1'))).includes('09:00–12:30, 13:30–18:00'), 'Format-Hinweis am Montag')
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.2'))).includes('ohne Überlappung'), 'Überlappung am Dienstag')
eq(await montag.getAttribute('aria-invalid'), 'true', 'Montag markiert')
eq(version(), v0, 'nichts gespeichert')
// gueltig: Montag 10:00–14:00 (Schreibweise wird vereinheitlicht), Dienstag und Sonntag
await montag.fill('10:00 - 14:00')
await form.getByLabel('Dienstag', { exact: true }).fill('9:00–12:00 und 14:00 bis 24:00')
await form.getByLabel('Sonntag', { exact: true }).fill('10:00–11:00')
await knopf(form, 'Speichern').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_anna0001"] .tm-meta')?.textContent.includes('Zeiten an 7 Tagen'))
eq(version(), v0 + 1, 'ein Speichern')
const plan = gespeichert().ressourcen[0].plan
eq([plan['1'], plan['2'], plan['7'], plan['3']], [[['10:00', '14:00']], [['09:00', '12:00'], ['14:00', '24:00']], [['10:00', '11:00']], [['09:00', '12:30'], ['13:30', '18:00']]], 'Plan gespeichert')
// Gaeste sehen die neuen Zeiten: Montag 2026-10-12 hat bei Anna nichts vor 10:00
const tage = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-12`)).json()
eq([tage.zeiten?.[0], tage.zeiten?.at(-1)], ['10:00', '13:15'], 'erste und letzte Startzeit am Montag (15-Minuten-Raster, 30 Min. + 5 Pause bis 14:00)')
// neuer Platz mit Zeiten und Zuordnung in EINEM Speichern
const v1 = version()
await knopf(page, '+ Person oder Platz hinzufügen').click()
const neu = formular(page, 'Person oder Platz hinzufügen')
await neu.getByLabel('Name', { exact: true }).fill('Kabine 1')
await neu.getByLabel('Art', { exact: true }).selectOption('platz')
await neu.getByLabel('Montag', { exact: true }).fill('09:00–12:00')
await neu.getByLabel('Herrenschnitt').check()
await neu.getByLabel('Färben').check()
await knopf(neu, 'Hinzufügen').click()
await eintrag(page, 'Kabine 1').waitFor()
eq(version(), v1 + 1, 'ein Speichern für Platz und Zuordnung')
const e = gespeichert()
const platz = e.ressourcen.at(-1)
assert(/^r_[a-z0-9]{8}$/.test(platz.id) && platz.typ === 'platz' && platz.aktiv === true, `Platz: ${JSON.stringify(platz)}`)
assert(/^[A-Za-z0-9_-]{43}$/.test(platz.feedToken), 'Kalender-Token vergibt der Server')
eq(e.leistungen.filter((l) => l.ressourcen.includes(platz.id)).map((l) => l.name), ['Herrenschnitt', 'Färben'], 'Zuordnung bei den Leistungen')
eq(await textVon(eintrag(page, 'Kabine 1').locator('.tm-meta').first()), 'Platz · Zeiten an 1 Tag · 2 Leistungen', 'Kurzfassung')
// der neue Platz zeigt seinen Kalender-Link, sobald die Daten neu geladen sind (kein Neuladen der Seite noetig)
await knopf(eintrag(page, 'Kabine 1'), 'Bearbeiten: Kabine 1').click()
await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').waitFor()
assert((await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').inputValue()).endsWith(`/${platz.feedToken}.ics`), 'Link mit dem Token des Servers')
// Person entfernen: Zeiten, Zuordnung und Abwesenheiten gehen mit
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
await knopf(formular(page, 'Bearbeiten: Ben'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: '„Ben“ entfernen?' }), 'Ja, entfernen').click()
await zeileMitId(page, R_BEN).waitFor({ state: 'detached' })
const nach = gespeichert()
eq(nach.ressourcen.map((r) => r.name), ['Anna', 'Kabine 1'], 'Ben entfernt')
eq(nach.leistungen.every((l) => !l.ressourcen.includes(R_BEN)), true, 'aus allen Zuordnungen entfernt')
})
// ------------------------------------------------------------ Team: Fehler des Servers stehen an der Leistung (nicht in der Person)
await check('echt_team_fehler_zuordnung', async (page) => {
await oeffneReiter(page, 'team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const form = formular(page, 'Bearbeiten: Anna')
// Anna und Ben bieten alles an; zuerst bei Ben alles abwaehlen ⇒ dann haengt jede Leistung nur an Anna
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
const ben = formular(page, 'Bearbeiten: Ben')
for (const kasten of await ben.locator('fieldset input[type=checkbox]').all()) await kasten.uncheck()
await knopf(ben, 'Speichern').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_ben00002"] .tm-meta')?.textContent.includes('keine Leistung'))
// jetzt Anna auf „Inaktiv“: alle aktiven Leistungen haben dann niemanden
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const anna = formular(page, 'Bearbeiten: Anna')
await anna.getByLabel(/^Aktiv/).first().uncheck()
const v0 = version()
await knopf(anna, 'Speichern').click()
const slot = fehlerAm(anna, 'leistungen.0.ressourcen')
await slot.filter({ hasText: 'mindestens eine aktive Person' }).waitFor()
eq(await anna.locator('[data-fehler^="leistungen."]:not(:empty)').count(), 6, 'ein Fehler an jeder der sechs Leistungen')
eq(await anna.locator('input[aria-invalid="true"]').count(), 6, 'sechs Haken markiert')
eq(version(), v0, 'nichts gespeichert')
await shot(page, 'team-fehler-zuordnung')
})
// ------------------------------------------------------------ Team: Abwesenheiten, Feiertage, Sonderöffnungen
await check('echt_team_ausnahmen', async (page) => {
await oeffneReiter(page, 'team')
// Abwesenheit
const v0 = version()
await knopf(page, '+ Abwesenheit eintragen').click()
const neu = formular(page, 'Abwesenheit eintragen')
await neu.getByLabel('Von (erster Tag)').fill('2026-11-06')
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-02')
await knopf(neu, 'Abwesenheit eintragen').click()
await fehlerAm(neu, 'abwesenheiten.0.bis').filter({ hasText: 'Das Ende darf nicht vor dem Beginn liegen.' }).waitFor() // Fehler des Servers am Feld
eq(version(), v0, 'nichts gespeichert')
await neu.getByLabel('Von (erster Tag)').fill('2026-11-02')
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-06')
await neu.getByLabel('Titel (optional)').fill('Betriebsurlaub')
await knopf(neu, 'Abwesenheit eintragen').click()
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Betriebsurlaub' }).waitFor()
const a = gespeichert().abwesenheiten[0]
assert(/^a_[a-z0-9]{8}$/.test(a.id), `ID a_ + 8: ${a.id}`)
eq({ ...a, id: 'x' }, { id: 'x', ressourceId: null, von: '2026-11-02', bis: '2026-11-06', titel: 'Betriebsurlaub' }, 'Abwesenheit gespeichert')
eq(await textVon(page.locator('#tm-panel [aria-label="Abwesenheiten"] li .tm-name')), '02.11.2026 – 06.11.2026', 'Zeitraum')
// ... und fuer Anna allein, vergangene stehen eingeklappt
await knopf(page, '+ Abwesenheit eintragen').click()
const anna = formular(page, 'Abwesenheit eintragen')
await anna.getByLabel('Wer ist weg?').selectOption(R_ANNA)
await anna.getByLabel('Von (erster Tag)').fill('2026-09-01')
await anna.getByLabel('Bis (letzter Tag)').fill('2026-09-05')
await knopf(anna, 'Abwesenheit eintragen').click()
await page.locator('#tm-panel summary', { hasText: 'Vergangene Abwesenheiten (1)' }).waitFor()
eq(gespeichert().abwesenheiten.length, 2, 'zwei Abwesenheiten')
// Gaeste: am 28.12. ist niemand buchbar
const tag = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=egal&tag=2026-11-04`)).json()
eq(tag.zeiten, [], 'in der Abwesenheit keine Zeiten')
// bearbeiten und entfernen
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Betriebsurlaub/).click()
const bearbeiten = formular(page, 'Abwesenheit bearbeiten')
await bearbeiten.getByLabel('Titel (optional)').fill('Winterpause')
await knopf(bearbeiten, 'Speichern').click()
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor()
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Winterpause/).click()
await knopf(formular(page, 'Abwesenheit bearbeiten'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: 'Abwesenheit entfernen?' }), 'Ja, entfernen').click()
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor({ state: 'detached' })
eq(gespeichert().abwesenheiten.map((x) => x.titel), [''], 'nur die vergangene bleibt')
// Feiertage
const feiertage = page.getByRole('form', { name: 'Feiertage' })
eq(await feiertage.getByLabel('Bundesland').inputValue(), 'RP', 'Bundesland')
eq(await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).isChecked(), false, 'Haken wie gespeichert (Testdaten: aus)')
await feiertage.getByLabel('Bundesland').selectOption('BY')
await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).check()
await knopf(feiertage, 'Speichern').click()
await page.waitForFunction(() => document.body.textContent.includes('Feiertage gespeichert.'))
eq([gespeichert().bundesland, gespeichert().feiertageGeschlossen], ['BY', true], 'Feiertage gespeichert')
// Sonderöffnungen
await knopf(page, '+ Sonderöffnung eintragen').click()
const sonder = formular(page, 'Sonderöffnung eintragen')
await sonder.getByLabel('Tag', { exact: true }).fill('2026-10-18')
await sonder.getByLabel(/^Geöffnet von/).fill('09:00 bis 13')
await knopf(sonder, 'Sonderöffnung eintragen').click()
assert((await textVon(fehlerAm(sonder, 'zeiten'))).includes('09:00–12:30, 13:30–18:00'), 'Zeiten ungültig (am Feld)')
await sonder.getByLabel(/^Geöffnet von/).fill('09:00–13:00')
await knopf(sonder, 'Sonderöffnung eintragen').click()
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor()
eq(gespeichert().sonderoeffnungen['2026-10-18'], [['09:00', '13:00']], 'Sonderöffnung gespeichert')
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-18"] .tm-name')), 'Sonntag, 18. Oktober 2026', 'Tag')
// ein Tag ohne Zeiten = geschlossen; doppelt ⇒ Fehler
await knopf(page, '+ Sonderöffnung eintragen').click()
const zu = formular(page, 'Sonderöffnung eintragen')
await zu.getByLabel('Tag', { exact: true }).fill('2026-10-19')
await knopf(zu, 'Sonderöffnung eintragen').click()
await page.locator('#tm-panel li[data-tag="2026-10-19"]').waitFor()
eq(gespeichert().sonderoeffnungen['2026-10-19'], [], 'geschlossen = leere Liste')
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-19"] .tm-meta')), 'Geschlossen', 'Anzeige')
await knopf(page, '+ Sonderöffnung eintragen').click()
const doppelt = formular(page, 'Sonderöffnung eintragen')
await doppelt.getByLabel('Tag', { exact: true }).fill('2026-10-18')
await knopf(doppelt, 'Sonderöffnung eintragen').click()
assert((await textVon(fehlerAm(doppelt, 'datum'))).includes('schon eine Sonderöffnung'), 'doppelter Tag')
await knopf(doppelt, 'Abbrechen').click()
// Gaeste: der Sonntag nur bis 13 Uhr, 25.12. zu (Feiertag-Haken + Sonderöffnung)
const hs = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-18`)).json()
eq([hs.zeiten[0], hs.zeiten.at(-1)], ['09:00', '12:25'].map((x, i) => (i === 1 ? hs.zeiten.at(-1) : x)), 'der Sonntag ab 09:00')
assert(hs.zeiten.at(-1) < '13:00', `der Sonntag endet vor 13 Uhr: ${hs.zeiten.at(-1)}`)
// Sonderöffnung bearbeiten und entfernen
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
const sb = formular(page, 'Sonderöffnung 18.10.2026')
await sb.getByLabel(/^Geöffnet von/).fill('10:00–12:00')
await knopf(sb, 'Speichern').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-tag="2026-10-18"] .tm-meta')?.textContent === '10:00–12:00')
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
await knopf(formular(page, 'Sonderöffnung 18.10.2026'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: 'Sonderöffnung am 18.10.2026 entfernen?' }), 'Ja, entfernen').click()
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor({ state: 'detached' })
eq(Object.keys(gespeichert().sonderoeffnungen), ['2026-10-19'], 'nur der geschlossene Tag bleibt')
await shot(page, 'team-ausnahmen')
})
// ------------------------------------------------------------ Kalender-Abo-Link: kopieren, neu erzeugen
await check('echt_feed_link_kopieren_und_neu', async (page, ctx) => {
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
await oeffneReiter(page, 'team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
const link = feed.locator('.tm-link-feld')
const token0 = gespeichert().ressourcen[0].feedToken
const url0 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token0}.ics`
eq(await link.inputValue(), url0, 'Link des Servers')
eq(await link.getAttribute('readonly'), '', 'nur lesbar')
const hinweis = await textVon(feed)
assert(hinweis.includes('alle paar Stunden') && hinweis.includes('Der Link ist geheim'), `Hinweise: ${hinweis}`)
await shot(page, 'team-kalender-link')
const r0 = await fetch(url0)
assert(r0.status === 200 && (r0.headers.get('content-type') || '').startsWith('text/calendar'), `Link liefert den Kalender (${r0.status})`)
// kopieren
await knopf(feed, 'Link kopieren').click()
await feed.getByText('Der Link ist kopiert.').waitFor()
eq(await page.evaluate(() => navigator.clipboard.readText()), url0, 'Zwischenablage')
// neu erzeugen: erst Rueckfrage in der Seite, Abbrechen aendert nichts
const v0 = version()
await knopf(feed, 'Neuen Link erzeugen').click()
const frage = page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' })
assert((await textVon(frage)).includes('Der bisherige Link funktioniert danach nicht mehr.'), 'Rückfrage nennt die Folge')
await knopf(frage, 'Abbrechen').click()
eq([gespeichert().ressourcen[0].feedToken, version()], [token0, v0], 'Abbrechen ändert nichts')
await knopf(feed, 'Neuen Link erzeugen').click()
await shot(page, 'team-kalender-link-frage')
await knopf(frage, 'Neuen Link erzeugen').click()
await page.waitForFunction((alt) => document.querySelector('#tm-panel .tm-link-feld')?.value !== alt, url0)
const token1 = gespeichert().ressourcen[0].feedToken
assert(token1 !== token0 && /^[A-Za-z0-9_-]{43}$/.test(token1), 'neues Token vom Server')
const url1 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token1}.ics`
eq(await link.inputValue(), url1, 'neuer Link steht im Feld')
eq(version(), v0 + 1, 'das Erzeugen zählt die Version hoch')
eq((await fetch(url0)).status, 404, 'der alte Link gilt nicht mehr')
eq((await fetch(url1)).status, 200, 'der neue Link gilt')
assert((await textVon(feed)).includes('Der neue Link ist da. Der alte funktioniert nicht mehr.'), 'Hinweis am Link')
eq(gespeichert().ressourcen[1].feedToken.length, 43, 'Ben behält seinen Link')
// ein Speichern direkt danach kollidiert nicht mit dem Erzeugen (Version aus der Antwort)
const form = formular(page, 'Bearbeiten: Anna')
await form.getByLabel('Name', { exact: true }).fill('Anna M.')
await knopf(form, 'Speichern').click()
await eintrag(page, 'Anna M.').waitFor()
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
eq([gespeichert().ressourcen[0].name, gespeichert().ressourcen[0].feedToken], ['Anna M.', token1], 'Name gespeichert, Link bleibt der neue')
})
await check('echt_feed_link_ohne_zwischenablage', async (page, ctx) => {
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
await oeffneReiter(page, 'team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
await knopf(feed, 'Link kopieren').click()
const status = feed.locator('[role="status"]')
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
assert((await textVon(status)).includes('markiert'), 'Hinweis: Link ist markiert')
const auswahl = await page.evaluate(() => {
const el = document.activeElement
return { link: el?.classList.contains('tm-link-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
})
eq(auswahl.link, true, 'Fokus im Link-Feld')
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Link ist markiert')
})
// ------------------------------------------------------------ Konflikt mit zweitem Tab
await check('echt_konflikt_zweiter_tab', async (page, ctx) => {
await oeffneReiter(page, 'einstellungen')
const zweiter = await ctx.newPage()
await oeffneReiter(zweiter, 'einstellungen')
// der zweite Tab speichert zuerst
await zweiter.getByLabel('Telefon (Pflicht)').fill('06301 9999')
await knopf(zweiter, 'Einstellungen speichern').click()
await zweiter.getByText('Einstellungen gespeichert.').waitFor()
eq(gespeichert().betrieb.telefon, '06301 9999', 'zweiter Tab gespeichert')
// der erste hat noch die alte Version
const v1 = version()
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
await knopf(page, 'Einstellungen speichern').click()
const box = page.locator('#tm-panel .tm-box-warn')
await box.waitFor()
assert((await textVon(box)).includes('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), `Konflikt-Text: ${await textVon(box)}`)
eq(await box.getAttribute('role'), 'alert', 'als Hinweis ausgezeichnet')
eq([gespeichert().betrieb.name, version()], ['Salon Müller', v1], 'nichts überschrieben')
await shot(page, 'konflikt')
// neu laden holt den Stand des anderen Tabs, danach geht das Speichern
await knopf(box, 'Neu laden').click()
await box.waitFor({ state: 'hidden' })
eq(await page.getByLabel('Telefon (Pflicht)').inputValue(), '06301 9999', 'Stand des anderen Tabs')
eq(await page.getByLabel('Name des Betriebs', { exact: true }).inputValue(), 'Salon Müller', 'eigene ungespeicherte Änderung ist weg')
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
await knopf(page, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
eq([gespeichert().betrieb.name, gespeichert().betrieb.telefon], ['Salon Müller & Söhne', '06301 9999'], 'beide Änderungen da')
// dasselbe im Reiter Leistungen: der zweite Tab benennt eine Leistung um, der erste hat die alte Version
await page.locator('[data-reiter="leistungen"]').click()
await page.locator('#tm-panel li[data-id="s_herren01"]').waitFor()
await zweiter.reload() // der zweite Tab ist seit dem Speichern des ersten veraltet: frisch laden
await oeffneReiter(zweiter, 'leistungen')
await knopf(zweiter.locator('#tm-panel li[data-id="s_herren01"]'), 'Bearbeiten: Herrenschnitt').click()
const f2 = zweiter.getByRole('form', { name: 'Leistung bearbeiten: Herrenschnitt' })
await f2.getByLabel('Name', { exact: true }).fill('Herrenschnitt klassisch')
await knopf(f2, 'Speichern').click()
await zweiter.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
const f1 = formular(page, 'Leistung bearbeiten: Kinderschnitt')
await f1.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
await knopf(f1, 'Speichern').click()
await page.locator('#tm-panel .tm-box-warn:not([hidden])').waitFor()
eq(gespeichert().leistungen.find((l) => l.id === S_KINDER).dauer, 30, 'Kinderschnitt unverändert')
await knopf(page.locator('#tm-panel .tm-box-warn'), 'Neu laden').click()
await page.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'Hinweis weg')
})
// ------------------------------------------------------------ Feldfehler am Feld (javascript:-URL, Zahl, Name)
await check('echt_einstellungen_feldfehler', async (page) => {
await oeffneReiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen' })
const url = form.getByLabel('Link zum Impressum', { exact: true })
const v0 = version()
await url.fill('javascript:alert(1)')
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('5')
await form.getByLabel('Name des Betriebs', { exact: true }).fill('')
await knopf(form, 'Einstellungen speichern').click()
const fehlerUrl = fehlerAm(form, 'betrieb.impressumUrl')
await fehlerUrl.filter({ hasText: 'Bitte gib eine vollständige Adresse mit https:// an' }).waitFor()
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.', 'Zahl außerhalb: Text des Servers am Feld')
eq(await textVon(fehlerAm(form, 'betrieb.name')), 'Bitte gib den Namen deines Betriebs an.', 'Name fehlt (Server)')
eq(await url.getAttribute('aria-invalid'), 'true', 'URL-Feld markiert')
eq(await page.evaluate(() => document.activeElement?.dataset.feld), 'betrieb.name', 'Fokus auf dem ersten fehlerhaften Feld (Reihenfolge der Seite)')
eq([version(), gespeichert().betrieb.impressumUrl], [v0, 'https://salon-mueller.example/impressum'], 'nichts gespeichert')
await shot(page, 'einstellungen-feldfehler')
// die Adresse darf nie als Link oder Skript landen: gespeichert ist sie auch nicht
eq(await page.locator('#tm-panel a[href^="javascript:"]').count(), 0, 'kein javascript:-Link in der Seite')
// eine leere Zahl wird schon im Feld erkannt
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('')
await knopf(form, 'Einstellungen speichern').click()
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Bitte gib eine ganze Zahl an.', 'leere Zahl')
// alles korrigieren: die Fehler verschwinden mit dem Speichern
await url.fill('https://salon-mueller.example/impressum-neu')
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('120')
await form.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller')
await knopf(form, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
eq(await page.locator('#tm-panel .tm-fehler:not(:empty)').count(), 0, 'keine Fehlertexte mehr')
eq([gespeichert().betrieb.impressumUrl, gespeichert().aufbewahrungTage], ['https://salon-mueller.example/impressum-neu', 120], 'gespeichert')
})
// ------------------------------------------------------------ Einstellungen: alle Abschnitte
await check('echt_einstellungen_speichern', async (page) => {
await oeffneReiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen' })
const feld = (label) => form.getByLabel(label, { exact: true })
// vorbefuellt aus der gespeicherten Einstellung
eq([await feld('Name des Betriebs').inputValue(), await feld('E-Mail des Betriebs').inputValue(), await feld('Telefon (Pflicht)').inputValue()], ['Salon Müller', 'info@salon-mueller.example', '06301 1234'], 'Betrieb vorbefüllt')
eq(await feld('Akzentfarbe').inputValue(), '#0f766e', 'Farbe')
eq(await feld('Selbst absagen bis (Stunden vorher)').inputValue(), '24', 'Regeln')
// Hinweise der Spezifikation
const text = await textVon(page.locator('#tm-panel'))
assert(text.includes('Der Widerruf schlägt die Stornofrist.'), 'Hinweis: Widerruf schlägt Stornofrist')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
const online = form.getByRole('radio', { name: /Online bezahlen/ })
eq(await online.isDisabled(), true, 'Online bezahlen ausgegraut')
assert((await textVon(page.locator('.tm-optionen'))).includes('kommt bald'), '„kommt bald“')
eq(await form.getByRole('radio', { name: /Vor Ort bezahlen/ }).isChecked(), true, 'vor Ort gewählt')
eq(await page.getByRole('link', { name: /CSV/ }).getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
const csv = await page.evaluate(async () => { const r = await fetch('/api/termine/export.csv?projectId=p1'); return { status: r.status, typ: r.headers.get('content-type') } })
assert(csv.status === 200 && csv.typ.startsWith('text/csv'), `Export liefert CSV: ${JSON.stringify(csv)}`)
await shot(page, 'einstellungen')
// aendern
const stornoVorher = gespeichert().storno.version
await feld('Telefon (Pflicht)').fill('06301 9999')
await feld('Wie sprichst du deine Gäste an?').selectOption('du')
await feld('Akzentfarbe').fill('#112233')
await feld('Startzeiten alle').selectOption('30')
await feld('Mindestvorlauf (Minuten)').fill('60')
await feld('Buchungszeitraum (Tage)').fill('90')
await feld('Selbst absagen bis (Stunden vorher)').fill('12')
await feld('Offene Termine je E-Mail-Adresse').fill('5')
await form.getByLabel(/Gäste dürfen die Person oder den Platz selbst wählen/).uncheck()
await feld('Telefonnummer des Gastes').selectOption('pflicht')
await feld('Nachricht des Gastes').selectOption('optional')
await knopf(form, '+ Zusatzfeld').click()
await knopf(form, '+ Zusatzfeld').click()
const zeilen = form.locator('.tm-zusatz-zeile')
await zeilen.nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe')
await zeilen.nth(1).getByLabel('Beschriftung', { exact: true }).fill('Haarlänge')
eq(await zeilen.nth(0).getByLabel('Auswahlmöglichkeiten').isVisible(), false, 'Auswahl nur bei „Auswahl aus Liste“')
await zeilen.nth(1).getByLabel('Art', { exact: true }).selectOption('auswahl')
await zeilen.nth(1).getByLabel('Auswahlmöglichkeiten').fill('kurz\nmittel\nlang')
await zeilen.nth(1).getByLabel('Pflichtfeld').check()
await feld('Die Erinnerung kommt').selectOption('12')
await knopf(form, 'Muster in das Textfeld übernehmen').click()
assert((await feld('Dein Text').inputValue()).includes('bis 12 Stunden vorher kostenlos absagen'), 'Muster mit der Frist aus dem Feld darüber')
await feld('Deine Gäste sind').selectOption('gemischt')
await form.getByLabel('Kreditkarte', { exact: true }).uncheck()
await form.getByLabel('PayPal', { exact: true }).check()
await feld('Aufbewahrung (Tage)').fill('120')
const v0 = version()
await knopf(form, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
eq(version(), v0 + 1, 'ein Speichern')
const e = gespeichert()
eq(e.betrieb.telefon, '06301 9999', 'Telefon')
eq([e.anrede, e.farbe], ['du', '#112233'], 'Anrede, Farbe')
eq(e.regeln, { raster: 30, vorlaufMin: 60, horizontTage: 90, absageBisStd: 12, maxOffeneJeEmail: 5, personWaehlbar: false }, 'Regeln')
eq(e.gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [
{ key: 'wunschfarbe', label: 'Wunschfarbe', typ: 'text', optionen: [], pflicht: false },
{ key: 'haarlaenge', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
] }, 'Gastfelder')
eq(e.erinnerung, { anbieten: true, stundenVorher: 12 }, 'Erinnerung')
assert(e.storno.text.includes('bis 12 Stunden vorher') && e.storno.version !== stornoVorher, `Storno mit neuer Fassung: ${e.storno.version}`)
eq(e.recht, { gaesteSind: 'gemischt', freizeit: false }, 'Rechtliches')
eq(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'paypal'] }, 'Zahlung')
eq(e.aufbewahrungTage, 120, 'Aufbewahrung')
// Gaeste sehen es
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
eq([conf.anrede, conf.zahlung.text, conf.gastFelder.zusatz.length, conf.gastFelder.telefon], ['du', 'Bezahlung vor Ort: bar, EC-Karte, PayPal', 2, 'pflicht'], 'öffentliche Config')
// die Seite zeigt den gespeicherten Stand; ein Zusatzfeld bleibt bei einer Umbenennung unter demselben Schluessel
eq(await form.locator('.tm-zusatz-zeile').count(), 2, 'zwei Zusatzfelder')
await form.locator('.tm-zusatz-zeile').nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe der Haare')
await knopf(form, 'Einstellungen speichern').click()
await page.waitForFunction(() => document.querySelector('[data-zusatz="0"] input')?.value === 'Wunschfarbe der Haare')
await page.getByText('Einstellungen gespeichert.').waitFor()
eq(gespeichert().gastFelder.zusatz.map((z) => [z.key, z.label]), [['wunschfarbe', 'Wunschfarbe der Haare'], ['haarlaenge', 'Haarlänge']], 'Schlüssel bleibt')
// Zusatzfeld entfernen
await knopf(form, 'Zusatzfeld entfernen: Wunschfarbe der Haare').click()
eq(gespeichert().gastFelder.zusatz.length, 2, 'bis zum Speichern bleibt alles')
await knopf(form, 'Einstellungen speichern').click()
await warteBis(() => gespeichert().gastFelder.zusatz.length === 1, 'Zusatzfeld entfernt')
eq(gespeichert().gastFelder.zusatz.map((z) => z.key), ['haarlaenge'], 'eins entfernt')
})
// ------------------------------------------------------------ Telefon und Anschrift fehlen (Entwurf aus Vorlage, Ruling R41)
await check('echt_pflichtangaben_aus_vorlage', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.getByLabel('Friseur und Salon').check()
await knopf(page, 'Mit dieser Vorlage starten').click()
await page.locator('.tm-reiterleiste').waitFor()
const leiste = page.locator('#termine-body .tm-hinweise .tm-box-warn')
assert((await textVon(leiste)).includes('Noch nicht buchbar'), 'Hinweisleiste')
// Speichern in Leistungen: der Server verlangt zuerst die Angaben, der Hinweis sagt wo
await page.locator('[data-reiter="leistungen"]').click()
const zeile = page.locator('#tm-panel li.tm-eintrag').first()
await knopf(zeile, /^Bearbeiten:/).click()
const form = page.locator('#tm-panel form').first()
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
await knopf(form, 'Speichern').click()
const fehler = form.locator('[data-fehler="_"]')
await fehler.filter({ hasText: 'Telefonnummer' }).waitFor()
assert((await textVon(fehler)).includes('Das änderst du im Reiter „Einstellungen“.'), `Hinweis auf den Reiter: ${await textVon(fehler)}`)
// Einstellungen: Hinweis in der Karte und Felder fuellen
await page.locator('[data-reiter="einstellungen"]').click()
const einstellungen = page.getByRole('form', { name: 'Einstellungen' })
const warnung = page.locator('#tm-panel .tm-abschnitt .tm-box-warn')
eq(await textVon(warnung), 'Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: deine Telefonnummer und deine Anschrift.', 'Hinweis in den Einstellungen')
await shot(page, 'einstellungen-pflichtangaben')
await einstellungen.getByLabel('Telefon (Pflicht)').fill('06301 555')
await knopf(einstellungen, 'Einstellungen speichern').click()
await fehlerAm(einstellungen, 'betrieb.adresse').filter({ hasText: 'Bitte gib die Anschrift deines Betriebs an.' }).waitFor()
await einstellungen.getByLabel('Anschrift', { exact: true }).fill('Hauptstr. 1, 67722 Winnweiler')
await knopf(einstellungen, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
// der Hinweis oben verschwindet sofort, ohne die Seite neu zu laden
eq(await page.locator('#termine-body .tm-hinweise .tm-box-warn').count(), 0, 'Hinweisleiste weg')
eq(await page.locator('#tm-panel .tm-abschnitt .tm-box-warn').count(), 0, 'Hinweis in der Karte weg')
eq([gespeichert().betrieb.telefon, gespeichert().betrieb.adresse], ['06301 555', 'Hauptstr. 1, 67722 Winnweiler'], 'gespeichert')
// jetzt geht das Speichern in Leistungen
await page.locator('[data-reiter="leistungen"]').click()
await knopf(page.locator('#tm-panel li.tm-eintrag').first(), /^Bearbeiten:/).click()
const form2 = page.locator('#tm-panel form').first()
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
await knopf(form2, 'Speichern').click()
await page.getByText('Leistung gespeichert.').waitFor()
eq(gespeichert().leistungen[0].dauer, 40, 'Dauer gespeichert')
}, { reset: { ohneEinstellung: true } })
// ------------------------------------------------------------ Abgeschaltet: alle neuen Reiter nur lesen
await check('echt_abgeschaltet_reiter_nur_lesen', async (page) => {
await srv.setzeFunktion('disabled')
const v0 = version()
const alleGesperrt = (loc) => loc.locator('input, select, textarea').evaluateAll((els) => els.length > 0 && els.every((e) => e.disabled || e.closest('fieldset[disabled]')))
// Leistungen
await oeffneReiter(page, 'leistungen')
eq(await knopf(page, '+ Leistung anlegen').count(), 0, 'Leistungen: kein Anlegen')
eq(await page.locator('#tm-panel [data-aktion="hoch"], #tm-panel [data-aktion="runter"]').count(), 0, 'Leistungen: keine Reihenfolge')
await knopf(eintrag(page, 'Herrenschnitt'), 'Ansehen: Herrenschnitt').click()
const leistung = formular(page, 'Leistung: Herrenschnitt')
eq(await alleGesperrt(leistung), true, 'Leistungen: Felder gesperrt')
eq(await leistung.getByLabel('Name', { exact: true }).inputValue(), 'Herrenschnitt', 'Leistungen: Werte stehen da')
eq(await leistung.getByRole('button').allInnerTexts(), ['Schließen'], 'Leistungen: nur Schließen')
await shot(page, 'abgeschaltet-leistungen')
await knopf(leistung, 'Schließen').click()
// Team
await page.locator('[data-reiter="team"]').click()
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
for (const knopfText of ['+ Person oder Platz hinzufügen', '+ Abwesenheit eintragen', '+ Sonderöffnung eintragen']) eq(await knopf(page, knopfText).count(), 0, `Team: ${knopfText} fehlt`)
eq(await alleGesperrt(page.getByRole('form', { name: 'Feiertage' })), true, 'Team: Feiertage gesperrt')
eq(await knopf(page.getByRole('form', { name: 'Feiertage' }), 'Speichern').count(), 0, 'Team: kein Speichern bei den Feiertagen')
await knopf(zeileMitId(page, R_ANNA), 'Ansehen: Anna').click()
const anna = formular(page, 'Person oder Platz: Anna')
eq(await alleGesperrt(anna), true, 'Team: Person gesperrt')
eq(await anna.getByLabel('Montag', { exact: true }).inputValue(), '09:00–12:30, 13:30–18:00', 'Team: Wochenplan sichtbar')
eq(await anna.getByRole('button').allInnerTexts(), ['Schließen'], 'Team: nur Schließen')
eq(await page.locator('#tm-panel .tm-feed').count(), 0, 'Team: kein Kalender-Link (Server liefert keine)')
await shot(page, 'abgeschaltet-team')
// Einstellungen
await page.locator('[data-reiter="einstellungen"]').click()
const form = page.getByRole('form', { name: 'Einstellungen' })
await form.waitFor()
eq(await alleGesperrt(form), true, 'Einstellungen: alle Felder gesperrt')
eq(await knopf(page, 'Einstellungen speichern').count(), 0, 'Einstellungen: kein Speichern')
eq(await knopf(form, 'Muster in das Textfeld übernehmen').count() + await knopf(form, '+ Zusatzfeld').count(), 0, 'Einstellungen: keine Änderungsknöpfe')
eq(await page.getByRole('link', { name: /CSV/ }).count(), 1, 'Einstellungen: Export bleibt')
assert((await textVon(page.locator('#tm-panel .tm-status'))).includes('Abgeschaltet'), 'Einstellungen: Status')
await shot(page, 'abgeschaltet-einstellungen')
// die Schnittstelle verweigert Schreiben auch direkt
const status = await page.evaluate(async ([projectId, v, einstellung]) => (await fetch('/api/termine/einstellungen', {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId, version: v, einstellung }),
})).status, ['p1', v0, gespeichert()])
eq(status, 409, 'Server: Speichern bei abgeschalteter Funktion')
eq(version(), v0, 'nichts gespeichert')
})
// ------------------------------------------------------------ App-Ansicht 390 px: die drei neuen Reiter
await check('echt_390_leistungen_team_einstellungen', async (page) => {
await oeffne(page, '#funktionen')
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
await warteAnsicht(page)
// jeder Knopf, jedes Auswahlfeld und jede Zeile zum Ankreuzen ist mindestens 44 px hoch (Touch)
const kleineZiele = () => page.evaluate(() => [...document.querySelectorAll('#tm-panel button, #tm-panel select, #tm-panel textarea, #tm-panel label.tm-haken, #tm-panel input:not([type=checkbox]):not([type=radio])')]
.filter((e) => e.offsetParent !== null)
.map((e) => [e.tagName.toLowerCase(), (e.textContent.trim() || e.getAttribute('aria-label') || e.className).slice(0, 40), Math.round(e.getBoundingClientRect().height)])
.filter(([, , hoehe]) => hoehe < 44))
const pruefe = async (was) => {
eq(await passtInBreite(page), true, `${was}: kein Streifen rechts`)
eq(await kleineZiele(), [], `${was}: Touch-Ziele mindestens 44 px`)
}
// Leistungen
await page.locator('[data-reiter="leistungen"]').click()
await page.locator('#tm-panel li.tm-eintrag').first().waitFor()
await pruefe('Leistungen')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Reiter markiert')
await shot(page, '390-8-leistungen')
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
await formular(page, 'Leistung bearbeiten: Herrenschnitt').waitFor()
await pruefe('Leistung bearbeiten')
await zeileMitId(page, S_HERREN).scrollIntoViewIfNeeded()
await shot(page, '390-9-leistung-bearbeiten')
// Team & Plaetze
await page.locator('[data-reiter="team"]').click()
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
await pruefe('Team')
await shot(page, '390-10-team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
await zeileMitId(page, R_ANNA).locator('.tm-feed').waitFor()
await pruefe('Person bearbeiten mit Kalender-Link')
await knopf(zeileMitId(page, R_ANNA), 'Neuen Link erzeugen').click()
await page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }).waitFor()
await pruefe('Rückfrage zum Kalender-Link')
await zeileMitId(page, R_ANNA).scrollIntoViewIfNeeded()
await shot(page, '390-11-team-bearbeiten')
await knopf(page, '+ Sonderöffnung eintragen').click()
await formular(page, 'Sonderöffnung eintragen').waitFor()
await knopf(page, '+ Abwesenheit eintragen').click()
await formular(page, 'Abwesenheit eintragen').waitFor()
await pruefe('Formulare für Ausnahmen')
// Einstellungen
await page.locator('[data-reiter="einstellungen"]').click()
await page.getByRole('form', { name: 'Einstellungen' }).waitFor()
await pruefe('Einstellungen')
await knopf(page, '+ Zusatzfeld').click()
await pruefe('Einstellungen mit Zusatzfeld')
await shot(page, '390-12-einstellungen')
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'App-Ansicht Terminplaner')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ------------------------------------------------------------ Meldung kommt nach dem Wechsel der Website (Fixture, Ruling R50)
await check('fx_meldung_nach_projektwechsel', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
const zeile = reihe(page, 'Lea Braun')
await knopf(zeile, 'Annehmen').click()
await knopf(zeile.locator('.tm-frage-box'), 'Annehmen').click()
// die Antwort braucht 1,2 s; in der Zwischenzeit zur anderen Website wechseln
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
await page.waitForFunction(() => document.querySelector('#termine-project')?.value === 'p2')
await page.waitForTimeout(2000)
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 1, 'die Aktion wurde abgeschickt')
eq(await meldung(page).count(), 0, 'keine Meldung der alten Website unter der neuen')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl p2')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Erste Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Zweite Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
]
fx.buchungen = alleStatus()
fx.aktionVerzoegerung = 1200
return fx
})(),
})
}
// ---------------------------------------------------------------- Lauf