Files
Webklar-Kundenbereich/test/ui/termine-portal.ui.mjs
T

2395 lines
162 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.
// Playwright-Checks der Portal-Ansicht Terminplaner (public/termine.js, termine-view.js, termine.css, kachel in funktionen.js) – Aufgabe 21a:
// Kachel, Vorlagenauswahl, Reiter-Geruest und Reiter "Termine". Nicht Teil von npm test.
// Aufruf: nice node test/ui/termine-portal.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/portal-*.png (bei Fehlern fehler-portal-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Zwei Arten von Pruefungen auf demselben UI-Testserver (test/ui/termineTestServer.mjs):
// - "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); 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_KINDER, S_ANFRAGE,
} from './termineTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const SHOTS = '/root/reports/terminplaner-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(portal|fehler-portal)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startTermineUiServer()
const KEY = srv.SITE_KEY
const API = `${srv.url}/api/public/termine/v1`
const JETZT = new Date('2026-10-05T08:00:00.000Z')
// ---------------------------------------------------------------- Helfer
function assert(cond, msg) { if (!cond) throw new Error(msg) }
function eq(ist, soll, msg) {
const a = JSON.stringify(ist)
const b = JSON.stringify(soll)
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
}
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
async function shot(page, name) {
// Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
const fenster = page.viewportSize()
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
await page.setViewportSize({ width: fenster.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/portal-${name}.png` })
await page.setViewportSize(fenster)
}
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: Knoepfe in Zeilen tragen den Kontext im aria-label
const reihe = (page, text) => page.locator('li.tm-eintrag', { hasText: text })
const meldung = (page) => page.locator('.tm-meldung:not(:empty)')
const pillen = (page) => page.locator('.tm-pille').allInnerTexts()
const textVon = (loc) => loc.innerText()
/** Kein Streifen rechts: die Seite passt in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
// Berliner Wanduhr als ISO-Zeit: Sommerzeit (+02:00) bis 25.10.2026 und ab 28.03.2027, dazwischen +01:00
const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00${tag >= '2026-10-25' && tag < '2027-03-28' ? '+01:00' : '+02:00'}`).toISOString()
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
}
// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture
const FX_BRANCHEN = [
{ key: 'salon', name: 'Friseur und Salon', gesundheit: false },
{ key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false },
{ key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true },
]
const FX_BESTELLUNG = {
preis: '149 €',
preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
einrichtung: 'meist innerhalb von 1–2 Wochen',
consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] },
dokumente: [],
belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
}
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
function neueFixture() {
const fx = {
verfuegbar: true,
bestellbar: true, // Abo des Kunden (GET /api/funktionen, termine.bestellbar / termine.grund): true | false | null
aboGrund: null,
projekte: [{ projectId: 'p1', projectName: 'Salon Müller', liveUrl: 'https://salon-mueller.example', features: { news: { state: 'off' }, termine: { state: 'active' } } }],
einstellung: srv.einstellung(),
vorlagen: [
{ key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben, Styling.' },
{ key: 'kosmetik', name: 'Kosmetik und Wellness', beschreibung: 'Behandlungen und Massagen.' },
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
],
fehlt: [],
fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } },
offen: [],
checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout
anfragenOffen: 0,
buchungen: [],
aufrufe: [],
}
// GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110)
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
branchen: FX_BRANCHEN,
fragebogen: fx.fragebogen,
kundenansicht: false,
...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }),
abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null,
offen: state === 'off' || state === 'ordered' ? fx.offen : null,
bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null,
})
return fx
}
/** Buchung in der Form von GET /api/termine/buchungen. */
function buchung(id, tag, zeit, dauer, o = {}) {
const startsAt = utc(tag, zeit)
const b = {
id, nummer: id.toUpperCase().padEnd(8, 'X').slice(0, 8), status: 'bestaetigt', quelle: 'online', startsAt, endsAt: new Date(new Date(startsAt).getTime() + dauer * 60000).toISOString(),
datum: tag, zeit, leistungId: S_DAMEN, leistung: 'Damenschnitt', dauer, titel: '', person: 'Anna', ressourceId: R_ANNA, gastName: 'Max Mustermann', gastEmail: 'max@example.com',
gastTelefon: '06301 555', gastNachricht: '', erinnerung: false, zustellFehler: false, angenommenAt: null, widerrufenAt: null, abgesagtVon: '', absageGrund: '', ...o,
}
b.titel = b.quelle === 'sperre' ? (o.titel || 'Gesperrt') : b.gastName ? `${b.leistung}: ${b.gastName}` : b.leistung
return b
}
/** Alle Status in einer Woche (Mo 2026-10-05 bis So 2026-10-11) plus eine Sperre. */
function alleStatus() {
return [
buchung('bnichter', '2026-10-05', '07:00', 30, { status: 'nichterschienen', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Jonas Weber' }),
buchung('berledigt', '2026-10-05', '08:00', 45, { status: 'erledigt', gastName: 'Petra Klein' }),
buchung('bbegonnen', '2026-10-05', '09:00', 30, { leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Karl Ohr' }),
buchung('bsperre', '2026-10-05', '12:00', 60, { quelle: 'sperre', status: 'bestaetigt', leistung: '', person: 'Ben', gastName: '', gastEmail: '', gastTelefon: '', titel: 'Mittagspause' }),
buchung('bkunft', '2026-10-05', '14:00', 45, { zustellFehler: true, gastName: 'Hans Gast' }),
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun', gastNachricht: 'Hochzeit am 24.10., bitte mit Probetermin.' }),
buchung('bgehalten', '2026-10-05', '17:00', 30, { status: 'gehalten', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Tim Berg' }),
buchung('babgesagt', '2026-10-06', '10:00', 30, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krankheit', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Fritz Alt' }),
buchung('bwiderruf', '2026-10-06', '11:00', 45, { status: 'abgesagt', abgesagtVon: 'gast', widerrufenAt: utc('2026-10-04', '12:00'), absageGrund: 'Widerruf erklärt von: Eva Rot (eingegangen 04.10.2026 12:00)', gastName: 'Eva Rot' }),
buchung('babgelehnt', '2026-10-06', '14:00', 90, { status: 'abgelehnt', leistung: 'Brautfrisur', gastName: 'Uta Neu' }),
buchung('bverfallen', '2026-10-06', '15:00', 30, { status: 'verfallen', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Nils Spät' }),
buchung('bvermerk', '2026-10-06', '16:00', 45, { absageGrund: 'Widerruf nach Ablauf der Frist erklärt von: Anke Zu', gastName: 'Anke Zu' }),
buchung('bmanuell', '2026-10-08', '09:00', 30, { quelle: 'manuell', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Walter Laufkunde', gastEmail: '', gastTelefon: '' }),
]
}
// Der Abo-Text kommt in der Antwort von /api/funktionen vom Server (termine.hinweis, aboMeldung in featureOrder.js); die Fixture
// liefert feste Texte, die Kachel zeigt sie nur an
const FX_ABO_HINWEISE = {
kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.',
gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.',
nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
unbekannt: 'Das Abo konnte gerade nicht geprüft werden.',
}
/** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */
async function fixtureAntwort(fx, route, req, url, json) {
const p = url.pathname
const q = url.searchParams
const m = req.method()
if (p === '/api/funktionen') {
const termine = fx.verfuegbar
? {
verfuegbar: true,
bestellbar: fx.bestellbar,
grund: fx.aboGrund,
preis: '149 €',
einrichtung: 'meist innerhalb von 1–2 Wochen',
hinweis: fx.bestellbar === true ? null : FX_ABO_HINWEISE[fx.aboGrund ?? 'unbekannt'],
}
: { verfuegbar: false }
return json({ projects: fx.projekte, termine })
}
if (p === '/api/funktionen/termine/checkout' && m === 'POST') {
fx.aufrufe.push(['checkout', req.postDataJSON()])
return json(fx.checkout.body, fx.checkout.status)
}
if (p === '/api/termine/einstellungen' && m === 'GET') {
const projekt = q.get('projectId')
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state)
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
return json(daten)
}
if (p === '/api/termine/einstellungen/vorlage' && m === 'POST') {
fx.aufrufe.push(['vorlage', req.postDataJSON()])
fx.einstellung = srv.einstellung()
fx.fehlt = ['betrieb.telefon', 'betrieb.adresse']
return json({ einstellung: fx.einstellung, version: 1, fehlt: fx.fehlt }, 201)
}
if (p === '/api/termine/buchungen' && m === 'GET') {
fx.aufrufe.push(['liste', Object.fromEntries(q)])
const status = q.get('status') ? q.get('status').split(',') : null
const liste = fx.buchungen.filter((b) => b.datum >= q.get('von') && b.datum <= q.get('bis') && (!status || status.includes(b.status)))
return json({ buchungen: liste.sort((a, b) => (a.startsAt < b.startsAt ? -1 : 1)) })
}
const aktion = /^\/api\/termine\/buchungen\/([^/]+)\/aktion$/.exec(p)
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() }
if (body.aktion === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund })
return json({ status: b.status })
}
return json({})
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
/**
* Eine Pruefung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture() ⇒ alle
* Schnittstellen aus der Fixture; sonst die echten Router mit Portal-Cookie), reset (Optionen fuer srv.zuruecksetzen).
*/
async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt = JETZT } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
await srv.zuruecksetzen(reset)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
await ctx.clock.setFixedTime(jetzt)
if (!fixture) await ctx.addCookies([await srv.portalCookie()])
ctx.setDefaultTimeout(8000)
const fehler = []
// 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
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
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(' | '))
results.push([name, true])
console.log(`ok ${name}`)
} 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()
}
}
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}`)
}
/** Zaehlt die PATCH-Aufrufe der Seite auf die Einstellungen (Liste der Adressen). */
const zaehlePatches = (seite) => {
const liste = []
seite.on('request', (r) => { if (r.method() === 'PATCH' && r.url().includes('/api/termine/einstellungen')) liste.push(r.url()) })
return liste
}
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, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor()
}
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
const JSON_KOPF = { 'content-type': 'application/json', origin: srv.url }
async function buche(o = {}) {
const v = { leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o }
srv.limitsZuruecksetzen()
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
const res = await fetch(`${API}/${KEY}/buchungen`, {
method: 'POST',
headers: JSON_KOPF,
body: JSON.stringify({
leistung: v.leistung, ressource: v.ressource, tag: v.tag, zeit: v.zeit, name: v.name, email: v.email, telefon: '', nachricht: '', zusatz: {},
erinnerung: false, zustimmungen: { storno: true, vorzeitig: true }, kontakt_url: '', formToken: conf.formToken,
}),
})
eq(res.status, 201, `Buchung ${v.tag} ${v.zeit} (${await res.clone().text()})`)
await srv.warteAufVersand()
const mails = srv.mails().filter((m) => m.to.some((t) => t.address === v.email)).reverse()
let token = null
for (const mail of mails) {
const treffer = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(`${mail.text}\n${mail.html}`)
if (treffer) { token = treffer[1]; break }
}
assert(token, `kein Bestaetigungslink fuer ${v.email}`)
const best = await fetch(`${API}/termin/bestaetigen`, { method: 'POST', headers: JSON_KOPF, body: JSON.stringify({ token }) })
eq(best.status, 200, 'Bestaetigung')
await srv.warteAufVersand()
return srv.buchungen().find((b) => b.gastEmail === v.email && gleichZeit(b.startsAt, utc(v.tag, v.zeit)))
}
const gleichZeit = (a, b) => new Date(a).getTime() === new Date(b).getTime() // der Nachbau speichert Datumswerte als +00:00
const tagNach = (tag) => new Date(Date.parse(`${tag}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
const buchungMit = (id) => srv.buchungen().find((b) => b.$id === id)
async function pruefungen() {
// ============================================================ Fixture
// ------------------------------------------------------------ Kachel in Funktionen
await check('fx_kachel_zustaende', async (page) => {
// Schalter aus ⇒ „Kommt bald“
await oeffne(page, '#funktionen')
await page.locator('.fn-tiles').waitFor()
const soon = page.locator('.fn-tile-soon', { hasText: 'Terminplaner' })
eq(await soon.count(), 1, 'Kommt-bald-Kachel')
assert((await textVon(soon)).includes('Kommt bald'), 'Pille Kommt bald')
eq(await page.locator('[data-fn-tile="termine"]').count(), 0, 'keine echte Kachel')
eq(await page.locator('[data-view="termine"]').count(), 0, 'kein Knopf zur Ansicht')
}, { fixture: Object.assign(neueFixture(), { verfuegbar: false }) })
await check('fx_kachel_aus_aktiv_abgeschaltet', async (page) => {
const fx = page.fx
// Aus mit Abo: Weg in die Bestellung
fx.projekte[0].features.termine = { state: 'off' }
await oeffne(page, '#funktionen')
let kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
let t = await textVon(kachel)
assert(t.includes('Aus') && t.includes('Terminplaner einrichten · 149 €'), `Aus: ${t}`)
assert(!t.includes('Wir schalten den Terminplaner für dich frei.'), 'der alte Text ist weg')
await knopf(kachel, 'Terminplaner einrichten · 149 €').waitFor()
eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p1', 'Aus: Knopf fuehrt in die Ansicht')
eq(await kachel.locator('[data-view="chat"]').count(), 0, 'Aus: kein Chat-Knopf')
await shot(page, 'kachel-aus')
// Entwurf vorhanden (Funktionsdokument mit siteKey)
fx.projekte[0].features.termine = { state: 'off', siteKey: 'abcdefghijklmnop' }
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await knopf(kachel, 'Fragebogen fortsetzen').waitFor()
// Aus ohne Abo: kein Knopf, Hinweis; die Hinweise der uebrigen Gruende
const faelle = [
['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
['gekuendigt', 'Dein Wartungs-Abo ist gekündigt.'],
['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv'],
]
for (const [grund, text] of faelle) {
fx.bestellbar = false
fx.aboGrund = grund
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
t = await textVon(kachel)
assert(t.includes('Aus') && t.includes(text), `Aus ohne Abo (${grund}): ${t}`)
eq(await kachel.locator('.fn-tile-cta').count(), 0, `${grund}: kein Knopf`)
eq(await kachel.locator('[data-view="termine"]').count(), 0, `${grund}: kein Weg in die Ansicht`)
}
eq(await kachel.locator('[data-view="vertraege"]').count(), 0, 'nur kein_abo verweist auf die Vertraege')
fx.aboGrund = 'kein_abo'
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await knopf(kachel, 'Zu Verträge').waitFor()
await shot(page, 'kachel-ohne-abo')
fx.bestellbar = null
fx.aboGrund = null
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.getByText('Das Abo konnte gerade nicht geprüft werden.').waitFor()
eq(await kachel.locator('.fn-tile-cta').count(), 0, 'nicht lesbar: kein Knopf')
// bestellt: Zahlung offen und in Einrichtung (das Abo spielt dann keine Rolle mehr)
fx.bestellbar = false
fx.aboGrund = 'gekuendigt'
for (const [state, pille, text] of [['ordered', 'Zahlung offen', 'Status ansehen'], ['setup', 'In Einrichtung', 'Status ansehen']]) {
fx.projekte[0].features.termine = { state }
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
t = await textVon(kachel)
assert(t.includes(pille) && t.includes(text), `${state}: ${t}`)
eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p1', `${state}: Knopf fuehrt in die Ansicht`)
}
await shot(page, 'kachel-in-einrichtung')
fx.bestellbar = true
fx.aboGrund = null
// Aktiv mit zwei Anfragen
fx.projekte[0].features.termine = { state: 'active' }
fx.buchungen = alleStatus().map((b) => (b.id === 'bkunft' ? { ...b, status: 'angefragt' } : b))
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
await kachel.getByText('2 Anfragen warten').waitFor()
t = await textVon(kachel)
assert(t.includes('Aktiv') && t.includes('Termine öffnen'), `Aktiv: ${t}`)
await shot(page, 'kachel-aktiv')
// eine Anfrage ⇒ Einzahl
fx.buchungen = alleStatus()
await page.reload()
await page.locator('[data-fn-tile="termine"]').getByText('1 Anfrage wartet').waitFor()
// keine Anfrage ⇒ keine Zeile
fx.buchungen = []
await page.reload()
await page.locator('[data-fn-tile="termine"]').waitFor()
await page.waitForTimeout(150)
eq(await page.locator('[data-fn-tile="termine"] [data-termine-count]:not([hidden])').count(), 0, 'ohne Anfragen keine Zahl')
// Abgeschaltet
fx.projekte[0].features.termine = { state: 'disabled' }
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
t = await textVon(kachel)
assert(t.includes('Abgeschaltet') && t.includes('abgeschaltet') && t.includes('Termine ansehen'), `Abgeschaltet: ${t}`)
await shot(page, 'kachel-abgeschaltet')
}, { fixture: neueFixture() })
// ------------------------------------------------------------ Vorlage waehlen
await check('fx_vorlage_waehlen', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
// erstes Oeffnen ohne Einstellung: Auswahl statt Reiter
eq(await page.locator('.tm-reiterleiste').count(), 0, 'noch keine Reiter')
eq(await page.locator('input[name="termine-vorlage"]').count(), 3, 'Vorlagen aus der Antwort')
const start = knopf(page, 'Mit dieser Vorlage starten')
eq(await start.isDisabled(), true, 'Start erst nach der Wahl')
await shot(page, 'vorlage-waehlen')
await page.getByLabel('Friseur und Salon').check()
eq(await start.isDisabled(), false, 'Start nach der Wahl')
await start.click()
await page.locator('.tm-reiterleiste').waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'vorlage'), [['vorlage', { projectId: 'p1', vorlage: 'salon' }]], 'POST der Vorlage')
assert((await textVon(meldung(page))).includes('„Friseur und Salon“'), 'Meldung nennt die Vorlage')
// Pflichtangaben fehlen ⇒ Hinweis mit Weg zu den Einstellungen
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('Noch nicht buchbar') && hinweis.includes('deine Telefonnummer und deine Anschrift'), `Hinweis: ${hinweis}`)
await knopf(page.locator('.tm-box-warn'), 'Zu den Einstellungen').click()
eq(await page.locator('#tm-tab-einstellungen').getAttribute('aria-selected'), 'true', 'Reiter Einstellungen gewaehlt')
await shot(page, 'vorlage-angelegt')
}, { fixture: Object.assign(neueFixture(), { einstellung: null }) })
// ------------------------------------------------------------ Projektauswahl
await check('fx_projektwahl', async (page) => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
assert((await textVon(kachel)).includes('aktiv für 1 von 2 Websites'), 'Kachel zaehlt die Websites')
eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p2', 'Kachel zeigt auf die aktive Website')
await knopf(kachel, 'Termine öffnen').click()
await warteAnsicht(page)
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
// andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“)
await page.locator('#termine-project').selectOption('p1')
await page.locator('#termine-body .tm-fragebogen').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg')
eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht')
await shot(page, 'projektwahl')
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
// Auswahl bleibt beim erneuten Oeffnen
await page.evaluate(() => { window.location.hash = '#funktionen' })
await page.locator('#view-funktionen.active').waitFor()
await page.evaluate(() => { window.location.hash = '#termine' })
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl bleibt')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'off' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
]
return fx
})(),
})
// ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b)
/** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */
const imZustand = (state, extra = {}) => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state, ...extra }
return fx
}
const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null }
await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => {
const fx = page.fx
// das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('#termine-body nav.tm-fortschritt').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis')
eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt')
await shot(page, 'ansicht-fragebogen')
// ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen
fx.bestellbar = false
for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) {
fx.aboGrund = grund === 'unbekannt' ? null : grund
fx.bestellbar = grund === 'unbekannt' ? null : false
await page.reload()
await page.locator('[data-hinweis="abo"]').waitFor()
const hinweis = page.locator('[data-hinweis="abo"]')
assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`)
eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`)
eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`)
eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`)
eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`)
if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo')
}
// der Link führt zu den Verträgen
fx.bestellbar = false
fx.aboGrund = 'kein_abo'
await page.reload()
await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click()
await page.locator('#view-vertraege.active').waitFor()
}, { fixture: imZustand('off') })
for (const [state, pille, extra] of [
['ordered', 'Zahlung offen', GEORDERT],
['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }],
]) {
await check(`fx_ansicht_${state}`, async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const karte = page.locator('.tm-statuskarte')
await karte.waitFor()
eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille')
assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum')
if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer')
else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine')
eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine')
eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
await shot(page, `ansicht-${state}`)
// schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export
await page.locator('#tm-tab-leistungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen')
assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“')
await page.locator('#tm-tab-einstellungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt')
eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern')
eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung')
eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung')
eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen')
}, { fixture: imZustand(state, extra) })
}
await check('fx_ansicht_bestellt_390', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await passtInBreite(page), true, 'kein Streifen rechts')
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')]
.filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
eq(kleine, [], 'Knöpfe mindestens 44 px hoch')
await shot(page, '390-ansicht-bestellt')
}, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } })
await check('fx_rueckkehr_zahlung', async (page) => {
await oeffne(page, '#termine?bestellt=1&projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte [data-danke]').waitFor()
eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
await shot(page, 'rueckkehr-zahlung')
// neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber
await page.reload()
await warteAnsicht(page)
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_rueckkehr_abbruch', async (page) => {
await oeffne(page, '#termine?projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung')
eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
// die Karte weicht dem Bestellformular (R110), die Reiter bleiben
await page.locator('.tm-bestellen').waitFor()
eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr')
eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung')
assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle')
const bestellen = page.locator('[data-aktion="bestellen"]')
eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken')
await page.locator('.tm-bestellen input[type="checkbox"]').check()
eq(await bestellen.isDisabled(), false, 'frei nach dem Haken')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben')
await shot(page, 'zahlung-fortsetzen-bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen')
// inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400)
fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 }
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor()
assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste')
eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr')
eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
// ------------------------------------------------------------ Reiter-Geruest
await check('fx_reiter_geruest', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const tabs = page.getByRole('tab')
eq(await tabs.allInnerTexts(), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'], 'vier Reiter')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Termine zuerst')
eq(await page.getByRole('tabpanel').getAttribute('aria-labelledby'), 'tm-tab-termine', 'Panel gehoert zum Reiter')
// Tastatur: Pfeil rechts wechselt (Platzhalter), Pos1 zurueck
await page.locator('#tm-tab-termine').focus()
await page.keyboard.press('ArrowRight')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Pfeil rechts')
assert((await textVon(page.getByRole('tabpanel'))).includes('Leistungen'), 'Platzhalter-Panel')
await page.keyboard.press('End')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Einstellungen', 'Ende')
await page.keyboard.press('ArrowRight')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Pfeil rechts vom letzten Reiter springt zum ersten')
// Auswahl bleibt beim Verlassen der Ansicht erhalten
await page.getByRole('tab', { name: 'Team & Plätze' }).click()
await page.evaluate(() => { window.location.hash = '#funktionen' })
await page.locator('#view-funktionen.active').waitFor()
await page.evaluate(() => { window.location.hash = '#termine' })
await page.locator('#view-termine.active').waitFor()
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Team & Plätze', 'Reiter bleibt')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Liste mit allen Status (Fixture)
await check('fx_liste_alle_status', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
// Tag: heute, Montag 5. Oktober, in Zeitfolge
eq((await textVon(page.locator('.tm-zeitraum'))), 'Montag, 5. Oktober 2026', 'Titel Tag')
await page.locator('li.tm-eintrag').first().waitFor()
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['bnichter', 'berledigt', 'bbegonnen', 'bsperre', 'bkunft', 'banfrage', 'bgehalten'], 'Tagesliste in Zeitfolge')
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-05' }, 'Abfrage nur fuer heute')
// Woche: alle acht Status und die Sperre
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-11' }, 'Abfrage fuer die Woche')
eq(await textVon(page.locator('.tm-zeitraum')), '5.–11. Oktober 2026', 'Titel Woche')
const alle = await pillen(page)
for (const status of ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen', 'Gesperrt']) assert(alle.includes(status), `Pille ${status} fehlt: ${alle.join(', ')}`)
eq(await page.locator('.tm-tag').count(), 7, 'sieben Tage, auch leere')
const leer = await textVon(page.locator('.tm-tag[data-tag="2026-10-07"]'))
assert(leer.includes('Mittwoch, 7. Oktober') && leer.includes('Keine Termine'), `leerer Tag: ${leer}`)
assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-05"] .tm-tag-kopf'))).startsWith('Heute · Montag, 5. Oktober'), 'Heute-Kopf')
assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-06"] .tm-tag-kopf'))).startsWith('Morgen · Dienstag, 6. Oktober'), 'Morgen-Kopf')
// Sperre: Block „Gesperrt“ mit Titel, ohne Gastaktionen und ohne Details
const sperre = reihe(page, 'Mittagspause')
const st = await textVon(sperre)
assert(st.includes('Gesperrt') && st.includes('Ben') && st.includes('12:00–13:00'), `Sperre: ${st}`)
eq(await sperre.locator('[data-aktion]').allInnerTexts(), ['Sperre aufheben'], 'Sperre: nur aufheben')
eq(await sperre.locator('details').count(), 0, 'Sperre ohne Gastdetails')
// Zeilen: Uhrzeit mit Ende, Leistung, Person, Gast
const kunft = await textVon(reihe(page, 'Hans Gast'))
assert(kunft.includes('14:00–14:45') && kunft.includes('Damenschnitt') && kunft.includes('Anna · Hans Gast') && kunft.includes('Bestätigt'), `Zeile: ${kunft}`)
assert(kunft.includes('Bestätigung nicht zugestellt'), 'Hinweis Bestätigung nicht zugestellt')
eq(await page.locator('li.tm-eintrag:not([data-id="bkunft"]) .tm-warn').count(), 0, 'Hinweis nur an der betroffenen Zeile')
// absageGrund nur bei Status abgesagt; Vermerk an einem bestehenden Termin bleibt unsichtbar
const abgesagt = await textVon(reihe(page, 'Fritz Alt'))
assert(abgesagt.includes('Von dir abgesagt') && abgesagt.includes('Grund: Krankheit'), `Absage: ${abgesagt}`)
const widerruf = await textVon(reihe(page, 'Eva Rot'))
assert(widerruf.includes('Vom Gast widerrufen'), `Widerruf: ${widerruf}`)
const vermerk = await textVon(reihe(page, 'Anke Zu'))
assert(!vermerk.includes('Widerruf nach Ablauf') && !vermerk.includes('Grund'), `Vermerk darf nicht erscheinen: ${vermerk}`)
assert((await textVon(reihe(page, 'Walter Laufkunde'))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
// Knoepfe je Status
const aktionen = async (id) => page.locator(`li.tm-eintrag[data-id="${id}"] [data-aktion]`).evaluateAll((l) => l.map((e) => e.dataset.aktion))
eq(await aktionen('banfrage'), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren'], 'Anfrage')
eq(await aktionen('bkunft'), ['verschieben', 'absagen', 'anonymisieren'], 'bestaetigt in der Zukunft')
eq(await aktionen('bbegonnen'), ['nichterschienen', 'anonymisieren'], 'bestaetigt, begonnen')
eq(await aktionen('bgehalten'), [], 'Reservierung')
eq(await aktionen('bverfallen'), ['anonymisieren'], 'verfallen')
await shot(page, 'liste-woche-desktop')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Filter, Navigation, Monat
await check('fx_filter_navigation', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.getByRole('button', { name: 'Monat', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="bmanuell"]').waitFor()
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-01', bis: '2026-10-31' }, 'Abfrage Monat')
eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Titel Monat')
eq(await page.locator('.tm-tag').count(), 3, 'Monat zeigt nur Tage mit Terminen')
// Filter ⇒ Statusliste an der Schnittstelle
const filter = async (name, status, erwartet) => {
await page.getByRole('button', { name, exact: true }).click()
await page.waitForFunction((n) => document.querySelector('.tm-chip[aria-pressed="true"]')?.textContent === n, name)
await page.waitForTimeout(100)
eq(fx.aufrufe.at(-1)[1].status, status, `Filter ${name}`)
eq((await pillen(page)).filter((p) => p !== 'Gesperrt').every((p) => erwartet.includes(p)), true, `Pillen unter ${name}: ${(await pillen(page)).join(', ')}`)
}
await filter('Angefragt', 'angefragt', ['Angefragt'])
await filter('Bestätigt', 'bestaetigt', ['Bestätigt'])
await filter('Abgesagt', 'abgesagt,abgelehnt,verfallen', ['Abgesagt', 'Abgelehnt', 'Verfallen'])
await filter('Offen', 'angefragt,bestaetigt', ['Angefragt', 'Bestätigt'])
await page.getByRole('button', { name: 'Alle', exact: true }).click()
await page.waitForTimeout(100)
assert(fx.aufrufe.at(-1)[1].status === undefined, 'Alle: keine Statusliste')
// Navigation: Monat vor, Heute, Datumssprung
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
await page.locator('.tm-leer').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'November 2026', 'naechster Monat')
eq(await textVon(page.locator('.tm-leer')), 'Keine Termine in diesem Monat.', 'Leermeldung Monat')
await page.getByRole('button', { name: 'Heute', exact: true }).click()
await page.locator('li.tm-eintrag').first().waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Heute')
await page.getByRole('button', { name: 'Tag', exact: true }).click()
await page.locator('#tm-sprung').fill('2026-10-06')
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Datumssprung')
await page.getByRole('button', { name: 'Zurück', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="bkunft"]').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'ein Tag zurueck')
// Ansicht und Filter bleiben beim Wechsel der Ansicht gesetzt
eq(await page.getByRole('button', { name: 'Alle', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter Alle gedrueckt')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Anfrage annehmen (Fixture)
await check('fx_anfrage_annehmen', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
// Banner „1 Anfrage wartet“ und Weg zu allen Anfragen
const banner = page.locator('.tm-box-info')
await banner.getByText('1 Anfrage wartet').waitFor()
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')
// 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
await zeile.locator('summary').click()
const details = await textVon(zeile.locator('dl'))
assert(details.includes('max@example.com') && details.includes('Hochzeit am 24.10., bitte mit Probetermin.') && details.includes('90 Minuten'), `Details: ${details}`)
await shot(page, 'anfrage-offen')
// Annehmen schliesst den Vertrag und schickt die Bestaetigung: erst eine Rueckfrage in der Seite
await knopf(zeile, 'Annehmen: Brautfrisur').click()
const frage = zeile.locator('.tm-frage-box')
await frage.waitFor()
const ft = await textVon(frage)
assert(ft.includes('Anfrage annehmen?') && ft.includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), `Rückfrage: ${ft}`)
eq(await page.evaluate(() => document.activeElement === document.querySelector('.tm-frage-box')), true, 'Fokus auf der Rückfrage, Enter bestätigt nichts')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'vor der Rückfrage nichts gesendet')
await shot(page, 'anfrage-annehmen-frage')
await knopf(frage, 'Abbrechen').click()
eq(await zeile.locator('.tm-frage-box').count(), 0, 'Abbrechen schließt die Rückfrage')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'nach Abbrechen nichts gesendet')
await knopf(zeile, 'Annehmen: Brautfrisur').click()
await zeile.locator('.tm-frage-box').getByRole('button', { name: 'Annehmen', exact: true }).click()
await meldung(page).waitFor()
assert((await textVon(meldung(page))).startsWith('Anfrage angenommen.'), 'Meldung nach dem Annehmen')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'banfrage', { projectId: 'p1', aktion: 'annehmen' }]], 'POST annehmen')
// Liste neu geladen: keine Anfrage mehr, Banner weg
await page.getByText('Es warten keine Anfragen.').waitFor()
eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
await knopf(page, 'Zurück zur Übersicht').click()
await page.locator('li.tm-eintrag[data-id="banfrage"][data-status="bestaetigt"]').waitFor()
await shot(page, 'anfrage-angenommen')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Rueckfrage in der Seite, Fremdtext nur als Text
await check('fx_rueckfrage_und_text', async (page) => {
const fx = page.fx
fx.buchungen = [
buchung('bxss', '2026-10-05', '14:00', 45, { gastName: '<img src=x onerror=window.__xss=1>Max', gastNachricht: '<b>fett</b> & <script>window.__xss=2</script>', person: '<i>Anna</i>', leistung: 'Schnitt <u>1</u>' }),
buchung('bziel', '2026-10-05', '16:00', 30, { gastName: 'Zoe Ziel' }),
]
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('li.tm-eintrag[data-id="bxss"]').waitFor()
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt')
eq(await page.locator('#termine-body img, #termine-body b, #termine-body i, #termine-body u, #termine-body script').count(), 0, 'Fremdtext erzeugt keine Elemente')
assert((await textVon(page.locator('li.tm-eintrag[data-id="bxss"]'))).includes('<img src=x onerror=window.__xss=1>Max'), 'Text sichtbar als Text')
// Absagen: Rueckfrage in der Seite (kein Browserdialog), Grund ist Pflicht
const zeile = page.locator('li.tm-eintrag[data-id="bziel"]')
await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
const frage = zeile.locator('.tm-frage-box')
await frage.waitFor()
assert((await textVon(frage)).includes('Termin absagen?'), 'Rueckfrage')
eq(await page.evaluate(() => document.activeElement.tagName), 'SELECT', 'Fokus im Grund-Feld')
await knopf(frage, 'Termin absagen').click()
assert((await textVon(frage)).includes('Bitte wähle einen Grund aus.'), 'Grund ist Pflicht')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'noch nichts gesendet')
await frage.getByLabel('Grund').selectOption('Krankheit')
await shot(page, 'rueckfrage-absagen')
// Abbrechen: Rueckfrage weg, Fokus zurueck am Knopf
await knopf(frage, 'Abbrechen').click()
eq(await zeile.locator('.tm-frage-box').count(), 0, 'Rueckfrage zu')
eq(await page.evaluate(() => document.activeElement.dataset.aktion), 'absagen', 'Fokus zurueck am Knopf')
// Nur eine Rueckfrage zugleich
await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
await knopf(page.locator('li.tm-eintrag[data-id="bxss"]'), 'Absagen: Schnitt <u>1</u>, 05.10.2026 um 14:00 Uhr').click()
eq(await page.locator('.tm-frage-box').count(), 1, 'eine Rueckfrage')
// Absagen mit Grund
await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Grund').selectOption('Betriebliche Gründe')
await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Nachricht an den Gast (optional)').fill('Tut uns leid.')
await knopf(page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box'), 'Termin absagen').click()
await meldung(page).waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'bxss', { projectId: 'p1', aktion: 'absagen', grund: 'Betriebliche Gründe', nachricht: 'Tut uns leid.' }]], 'POST absagen')
}, { fixture: neueFixture() })
// ------------------------------------------------------------ App-Ansicht 390 px
await check('fx_app_390', async (page) => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
await kachel.getByText('1 Anfrage wartet').waitFor()
eq(await passtInBreite(page), true, 'Funktionen: kein Streifen rechts')
await shot(page, '390-1-funktionen')
await knopf(kachel, 'Termine öffnen').click()
await warteAnsicht(page)
await page.locator('li.tm-eintrag').first().waitFor()
// Unterseite von Funktionen: Titel „Terminplaner“, Zurueck-Pfeil „Funktionen“, Tab Funktionen markiert
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse #termine')
eq(await textVon(page.locator('#view-termine .kb-head h1')), 'Terminplaner', 'Ueberschrift')
eq(await textVon(page.locator('#kb-app-title')), 'Terminplaner', 'Titel der App-Leiste')
eq(await page.locator('#kb-back').isVisible(), true, 'Zurueck-Pfeil sichtbar')
eq(await textVon(page.locator('#kb-back span')), 'Funktionen', 'Zurueck zu Funktionen')
eq(await page.locator('.kb-nav-item.active').getAttribute('data-view'), 'funktionen', 'Tab Funktionen markiert')
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'Ansicht termine')
eq(await passtInBreite(page), true, 'Termine: kein Streifen rechts')
// Reiter als 2×2-Raster, grosse Touch-Ziele
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button, #view-termine summary')]
.filter((e) => e.offsetParent !== null && !e.classList.contains('tm-link-knopf') && e.getBoundingClientRect().height < 43.5)
.map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
eq(kleine, [], 'Knoepfe mindestens 44 px hoch')
const tabs = await page.locator('.tm-reiter').evaluateAll((l) => l.map((e) => Math.round(e.getBoundingClientRect().top)))
eq(tabs[0] === tabs[1] && tabs[2] === tabs[3] && tabs[2] > tabs[0], true, 'Reiter in zwei Zeilen')
await shot(page, '390-2-termine')
// Woche, Anfrage mit Rueckfrage
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
eq(await passtInBreite(page), true, 'Woche: kein Streifen rechts')
await shot(page, '390-3-woche')
const zeile = page.locator('li.tm-eintrag[data-id="banfrage"]')
await knopf(zeile, 'Ablehnen: Brautfrisur, 05.10.2026 um 15:00 Uhr').click()
await zeile.locator('.tm-frage-box').waitFor()
eq(await passtInBreite(page), true, 'Rueckfrage: kein Streifen rechts')
await zeile.scrollIntoViewIfNeeded()
await shot(page, '390-4-ablehnen')
// Zurueck-Pfeil fuehrt zu Funktionen
await page.locator('#kb-back').click()
await page.locator('#view-funktionen.active').waitFor()
eq(await page.evaluate(() => document.body.dataset.kbView), 'funktionen', 'zurueck in Funktionen')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }), context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ------------------------------------------------------------ Offene Anfragen: alle, auch weit in der Zukunft
await check('fx_anfragen_ueber_mehrere_fenster', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
const banner = page.locator('.tm-box-info')
await banner.getByText('4 Anfragen warten').waitFor()
await knopf(banner, 'Anfragen ansehen').click()
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 (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, 6, 'sechs Aufrufe')
eq(fenster[0].von, '2026-10-05', 'beginnt heute')
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`)
})
await shot(page, 'anfragen-fern')
}, {
fixture: (() => {
const fx = neueFixture()
fx.einstellung.regeln.horizontTage = 180
fx.buchungen = [
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
})(),
})
// ------------------------------------------------------------ Website wechseln, Antwort der alten kommt spaet
await check('fx_projektwechsel_antwort_kommt_spaet', async (page) => {
await oeffne(page, '#termine')
await page.locator('#termine-project').waitFor()
// p1 (ohne Einstellung ⇒ Vorlagenauswahl) antwortet erst nach 1,5 s; in der Zwischenzeit zu p2 wechseln, das sofort antwortet
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
await page.waitForTimeout(2000)
eq(await page.locator('.tm-vorlagen').count(), 0, 'die späte Antwort von p1 erscheint nicht')
eq(await page.locator('.tm-reiterleiste').count(), 1, 'p2 bleibt stehen')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl p2')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Reiter von p2')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Langsame Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Schnelle Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
]
fx.ohneEinstellungFuer = ['p1']
fx.verzoegerung = { p1: 1500 }
return fx
})(),
})
// ------------------------------------------------------------ Abgeschaltet und nie eingerichtet
await check('fx_abgeschaltet_ohne_einstellung', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
assert((await textVon(hinweis)).includes('abgeschaltet'), 'Hinweis abgeschaltet')
eq(await hinweis.locator('a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link auch ohne Einstellung')
eq(await hinweis.locator('a').innerText(), 'exportieren', 'Linktext')
eq(await page.locator('.tm-vorlagen').count(), 0, 'keine Vorlagenauswahl')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knöpfe')
assert((await textVon(page.locator('.tm-info'))).includes('Keine Termine eingerichtet'), 'Info-Karte')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state: 'disabled' }
fx.einstellung = null
return fx
})(),
})
// ------------------------------------------------------------ Abgeschaltet nach Grund (E11)
await check('fx_abgeschaltet_abo_ende', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
const text = await textVon(hinweis)
assert(text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner nennt Abo und Löschdatum: ${text}`)
assert(text.includes('nur noch ansehen'), `Banner: ${text}`)
eq(await page.locator('.tm-box-warn a').count(), 1, 'genau ein Link im Banner')
eq(await page.locator('.tm-box-warn a').innerText(), 'exportieren', 'Linktext')
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knöpfe')
await shot(page, 'fx-abgeschaltet-abo-ende')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state: 'disabled' }
fx.abgeschaltet = { grund: 'abo_ende', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }
return fx
})(),
})
await check('fx_abgeschaltet_vor_einrichtung', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
const text = await textVon(hinweis)
assert(text.includes('eingerichtet') && text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner: ${text}`)
assert(!text.includes('exportieren'), `Banner ohne Export-Satz: ${text}`)
eq(await hinweis.locator('a').count(), 0, 'kein Link: es gibt nichts zu exportieren')
assert((await textVon(page.locator('.tm-info'))).includes('Keine Termine eingerichtet'), 'Info-Karte')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state: 'disabled' }
fx.einstellung = null
fx.abgeschaltet = { grund: 'abo_ende_vor_einrichtung', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }
return fx
})(),
})
// ------------------------------------------------------------ Datumssprung mit Unsinn
await check('fx_datumssprung_unsinn', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('li.tm-eintrag').first().waitFor()
// fuenfstelliges Jahr: gueltiger Wert des Datumsfelds, aber kein Tag der Ansicht
const wert = await page.locator('#tm-sprung').evaluate((el) => { el.value = '12345-01-01'; el.dispatchEvent(new Event('change', { bubbles: true })); return el.value })
eq(wert, '12345-01-01', 'das Feld nimmt das Datum an')
await page.waitForTimeout(200)
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'Ansicht bleibt stehen')
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Ansicht reagiert weiter')
// Datumsfeld im dunklen Farbschema und im Stil der anderen Felder
eq(await page.locator('#tm-sprung').evaluate((el) => getComputedStyle(el).colorScheme), 'dark', 'color-scheme dark')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Rueckmeldung ist im Bild (390 px)
await check('fx_meldung_im_bild', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="bkonflikt"]').waitFor()
const imBild = () => page.evaluate(() => {
const m = document.querySelector('.tm-meldung:not(:empty)')
if (!m) return 'keine Meldung'
const r = m.getBoundingClientRect()
const kopf = document.querySelector('.kb-header').getBoundingClientRect().bottom
return r.top >= kopf - 1 && r.bottom <= window.innerHeight ? 'ja' : `nein (oben ${Math.round(r.top)}, unten ${Math.round(r.bottom)}, Kopf ${Math.round(kopf)}, Fenster ${window.innerHeight})`
})
// Erfolg: weit unten abgesagt ⇒ die Meldung (ueber den Reitern) wird ins Bild geholt
const unten = page.locator('li.tm-eintrag[data-id="bvermerk"]')
await unten.scrollIntoViewIfNeeded()
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await knopf(unten, 'Absagen: Damenschnitt, 06.10.2026 um 16:00 Uhr').click()
await unten.getByLabel('Grund').selectOption('Krankheit')
await knopf(unten.locator('.tm-frage-box'), 'Termin absagen').click()
await meldung(page).waitFor()
eq(await imBild(), 'ja', 'Erfolgsmeldung im Bild')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 1, 'eine Absage gesendet')
// Fehler: Meldung im Bild und mit Fokus (Screenreader, Tastatur)
const konflikt = page.locator('li.tm-eintrag[data-id="bkonflikt"]')
await konflikt.scrollIntoViewIfNeeded()
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await knopf(konflikt, 'Absagen: Damenschnitt, 06.10.2026 um 17:00 Uhr').click()
await konflikt.getByLabel('Grund').selectOption('Krankheit')
await knopf(konflikt.locator('.tm-frage-box'), 'Termin absagen').click()
await page.locator('.tm-meldung-fehler').waitFor()
eq(await imBild(), 'ja', 'Fehlermeldung im Bild')
eq(await page.evaluate(() => document.activeElement?.classList.contains('tm-meldung-fehler')), true, 'Fehlermeldung hat den Fokus')
eq(await page.locator('.tm-meldung-fehler').getAttribute('role'), 'alert', 'role alert')
// genau eine Live-Region: die Meldung; Liste und Titel melden nichts selbst
eq(await page.locator('#termine-body [aria-live]:not([data-feld] *)').evaluateAll((l) => l.map((e) => e.className)), ['tm-meldung tm-meldung-fehler'], 'aria-live nur an der Meldung')
await shot(page, '390-5-fehlermeldung')
}, {
fixture: (() => {
const fx = neueFixture()
fx.buchungen = [...alleStatus(), buchung('bkonflikt', '2026-10-06', '17:00', 45, { gastName: 'Konflikt Test' })]
return fx
})(),
context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
})
// ============================================================ Echter Server
await check('echt_kachel_vorlage_und_liste', async (page) => {
// aktiv, aber noch ohne Einstellung
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
assert((await textVon(kachel)).includes('Aktiv') && (await textVon(kachel)).includes('Termine öffnen'), 'Kachel aktiv')
await knopf(kachel, 'Termine öffnen').click()
await warteAnsicht(page)
// neun Vorlagen des Servers
eq(await page.locator('input[name="termine-vorlage"]').count(), 9, 'neun Vorlagen')
await shot(page, 'echt-vorlage')
await page.locator('input[name="termine-vorlage"][value="salon"]').check()
await knopf(page, 'Mit dieser Vorlage starten').click()
await page.locator('.tm-reiterleiste').waitFor()
const e = srv.einstellung()
eq(e.vorlage, 'salon', 'Einstellung aus der Vorlage gespeichert')
assert(e.betrieb.name.includes('Silke') || e.betrieb.name !== '', 'Betrieb aus dem Kundenkonto')
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('Noch nicht buchbar'), `Hinweis fehlende Angaben: ${hinweis}`)
await shot(page, 'echt-nach-vorlage')
// zweites Oeffnen: keine Auswahl mehr
await page.reload()
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('.tm-vorlagen').count(), 0, 'Vorlage nur beim ersten Oeffnen')
}, { reset: { ohneEinstellung: true } })
await check('echt_anfragen_annehmen_ablehnen_absagen_verschieben', async (page) => {
// Gaeste buchen ueber die oeffentliche Schnittstelle (echte Daten, echte Mails)
const bestaetigt = await buche({ name: 'Max Mustermann', email: 'max@example.com', tag: '2026-10-12', zeit: '10:00' })
const zweiter = await buche({ name: 'Maria Muster', email: 'maria@example.com', tag: '2026-10-12', zeit: '11:00', leistung: S_HERREN, ressource: R_BEN })
const anfrage1 = await buche({ name: 'Lea Braun', email: 'lea@example.com', tag: '2026-10-13', zeit: '14:00', leistung: S_ANFRAGE })
const anfrage2 = await buche({ name: 'Uta Neu', email: 'uta@example.com', tag: '2026-10-14', zeit: '09:00', leistung: S_ANFRAGE })
eq([anfrage1.status, anfrage2.status, bestaetigt.status], ['angefragt', 'angefragt', 'bestaetigt'], 'Status nach dem Bestaetigen')
// eine Zeile mit gescheiterter Mail
srv.aw.seed('termineOutbox', { $id: 'o_fehler01', featureId: srv.FEATURE, bookingId: bestaetigt.$id, art: 'G02', an: 'max@example.com', status: 'fehler', versuche: 3, naechsterVersuch: JETZT.toISOString(), createdAt: JETZT.toISOString() })
await oeffne(page, '#termine')
await warteAnsicht(page)
// Banner mit der Zahl; alle Anfragen ab heute
await page.locator('.tm-box-info').getByText('2 Anfragen warten').waitFor()
await knopf(page, 'Anfragen ansehen').click()
await page.locator('li.tm-eintrag[data-status="angefragt"]').first().waitFor()
eq(await page.locator('li.tm-eintrag').count(), 2, 'zwei Anfragen')
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), [anfrage1.$id, anfrage2.$id], 'Anfragen in Zeitfolge')
await shot(page, 'echt-anfragen')
// annehmen: G02 an die Anfragende
const mailsVorher = mailsAn('lea@example.com').length
await knopf(reihe(page, 'Lea Braun'), 'Annehmen: Brautfrisur, 13.10.2026 um 14:00 Uhr').click()
eq(buchungMit(anfrage1.$id).status, 'angefragt', 'Rückfrage: noch nichts geändert')
await reihe(page, 'Lea Braun').locator('.tm-frage-box').getByRole('button', { name: 'Annehmen', exact: true }).click()
await meldung(page).waitFor()
assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen')
await srv.warteAufVersand()
eq(buchungMit(anfrage1.$id).status, 'bestaetigt', 'Server: angenommen')
assert(buchungMit(anfrage1.$id).angenommenAt, 'angenommenAt gesetzt')
eq(mailsAn('lea@example.com').length, mailsVorher + 1, 'G02 an den Gast')
// ablehnen mit Nachricht: G04
const zeile2 = reihe(page, 'Uta Neu')
await knopf(zeile2, 'Ablehnen: Brautfrisur, 14.10.2026 um 09:00 Uhr').click()
await zeile2.getByLabel('Nachricht an den Gast (optional)').fill('Leider ausgebucht.')
await knopf(zeile2, 'Anfrage ablehnen').click()
await page.getByText('Es warten keine Anfragen.').waitFor()
await srv.warteAufVersand()
eq(buchungMit(anfrage2.$id).status, 'abgelehnt', 'Server: abgelehnt')
const g04 = mailsAn('uta@example.com').at(-1)
assert(`${g04.text}${g04.html}`.includes('Leider ausgebucht.'), 'G04 mit Nachricht des Betriebs')
eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
// zurueck zur Uebersicht, Woche des 12. Oktober
await knopf(page, 'Zurück zur Übersicht').click()
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('#tm-sprung').fill('2026-10-12')
await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), '12.–18. Oktober 2026', 'Woche ab 12.10.')
const t1 = await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))
assert(t1.includes('10:00–10:45') && t1.includes('Damenschnitt') && t1.includes('Anna · Max Mustermann') && t1.includes('Bestätigt') && t1.includes('Bestätigung nicht zugestellt'), `Zeile Server: ${t1}`)
await shot(page, 'echt-woche')
// absagen mit Pflichtgrund: G06, Status, Grund in der Liste
const zeile3 = page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`)
await knopf(zeile3, 'Absagen: Herrenschnitt, 12.10.2026 um 11:00 Uhr').click()
await zeile3.getByLabel('Grund').selectOption('Krankheit')
await knopf(zeile3, 'Termin absagen').click()
await meldung(page).waitFor()
await srv.warteAufVersand()
const abgesagt = buchungMit(zweiter.$id)
eq([abgesagt.status, abgesagt.abgesagtVon, abgesagt.absageGrund], ['abgesagt', 'betrieb', 'Krankheit'], 'Server: abgesagt')
const abTxt = await textVon(page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`))
assert(abTxt.includes('Abgesagt') && abTxt.includes('Von dir abgesagt') && abTxt.includes('Grund: Krankheit'), `Zeile abgesagt: ${JSON.stringify(abTxt)}`)
assert(mailsAn('maria@example.com').some((m) => /Krankheit/.test(`${m.text}${m.html}`)), 'G06 nennt den Grund')
eq((await page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"] [data-aktion]`).allTextContents()), ['Gastdaten löschen'], 'nach der Absage nur noch Gastdaten loeschen')
// verschieben: neuer Tag, freie Zeit der Schnittstelle
const zeile4 = page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`)
await knopf(zeile4, 'Verschieben: Damenschnitt, 12.10.2026 um 10:00 Uhr').click()
const frage = zeile4.locator('.tm-frage-box')
await frage.getByLabel('Neuer Tag').fill('2026-10-14')
await frage.locator('.tm-zeit-knopf').first().waitFor()
const zeiten = await frage.locator('.tm-zeit-knopf').allInnerTexts()
assert(zeiten.includes('09:00') && zeiten.includes('16:45') && !zeiten.includes('12:30'), `freie Zeiten: ${zeiten.join(' ')}`)
await shot(page, 'echt-verschieben')
await knopf(frage, 'Verschieben').click() // ohne Zeit: Hinweis
assert((await textVon(frage)).includes('Bitte wähle eine Uhrzeit aus.'), 'Zeit ist Pflicht')
await frage.locator('.tm-zeit-knopf', { hasText: '15:00' }).click()
eq(await frage.locator('.tm-zeit-knopf[aria-pressed="true"]').allInnerTexts(), ['15:00'], 'eine Zeit gewaehlt')
await knopf(frage, 'Verschieben').click()
await meldung(page).waitFor()
assert((await textVon(meldung(page))).includes('Termin verschoben auf 14.10.2026, 15:00 Uhr.'), 'Meldung verschieben')
await srv.warteAufVersand()
assert(gleichZeit(buchungMit(bestaetigt.$id).startsAt, utc('2026-10-14', '15:00')), `Server: neue Zeit ${buchungMit(bestaetigt.$id).startsAt}`)
assert(mailsAn('max@example.com').length >= 3, 'G07 an den Gast')
// der verschobene Termin steht in der Woche
await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))).includes('15:00–15:45'), 'verschobene Zeit in der Liste')
})
await check('echt_termin_eintragen_sperre_nichterschienen', async (page) => {
// begonnener Termin (09:00, jetzt ist 10:00) und eine Anfrage direkt im Nachbau
const basis = (id, o) => ({
$id: id, featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
startsAt: utc('2026-10-05', '09:00'), endsAt: utc('2026-10-05', '09:30'), blockEndsAt: utc('2026-10-05', '09:35'), status: 'bestaetigt', quelle: 'online',
gastName: 'Karl Ohr', gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
preisCent: 2800, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0, ...o,
})
srv.aw.seed('termineBookings', basis('b_begonnen', {}))
await oeffne(page, '#termine')
await warteAnsicht(page)
const begonnen = page.locator('li.tm-eintrag[data-id="b_begonnen"]')
await begonnen.waitFor()
eq(await begonnen.locator('[data-aktion]').evaluateAll((l) => l.map((e) => e.dataset.aktion)), ['nichterschienen', 'anonymisieren'], 'begonnener Termin')
await knopf(begonnen, 'Nicht erschienen: Herrenschnitt, 05.10.2026 um 09:00 Uhr').click()
await knopf(begonnen.locator('.tm-frage-box'), 'Ja, nicht erschienen').click()
await meldung(page).waitFor()
await page.locator('li.tm-eintrag[data-id="b_begonnen"][data-status="nichterschienen"]').waitFor()
eq(buchungMit('b_begonnen').status, 'nichterschienen', 'Server: nicht erschienen')
// Gastdaten loeschen
await begonnen.locator('summary').click()
await knopf(begonnen, 'Gastdaten löschen').click()
assert((await textVon(begonnen.locator('.tm-frage-box'))).includes('lässt sich nicht rückgängig machen'), 'Warnung')
await knopf(begonnen.locator('.tm-frage-box'), 'Gastdaten löschen').click()
await page.locator('li.tm-eintrag[data-id="b_begonnen"] .tm-meta', { hasText: 'Gelöscht' }).waitFor()
eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht')
// + Termin eintragen: Pflichtfelder, Feldfehler am Feld, dann Erfolg
await page.getByRole('button', { name: 'Angefragt', exact: true }).click() // Statusfilter gesetzt: der neue Eintrag (bestaetigt) darf dadurch nicht verschwinden
eq(await page.getByRole('button', { name: 'Angefragt', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter Angefragt gesetzt')
await knopf(page, '+ Termin eintragen').click()
const form = page.getByRole('form', { name: 'Termin eintragen' })
await form.waitFor()
await shot(page, 'echt-termin-eintragen')
await form.getByLabel('Leistung', { exact: true }).selectOption(S_HERREN)
await form.getByLabel('Tag', { exact: true }).fill('2026-10-06')
await form.locator('.tm-zeit-knopf').first().waitFor()
await form.locator('.tm-zeit-knopf', { hasText: '09:30' }).click()
await knopf(form, 'Termin eintragen').click() // ohne Namen
await form.locator('[data-fehler="name"]:not(:empty)').waitFor()
eq(await textVon(form.locator('[data-fehler="name"]')), 'Bitte einen Namen angeben.', 'Feldfehler am Namen')
eq(await form.getByLabel('Name des Gastes').getAttribute('aria-invalid'), 'true', 'Namensfeld markiert')
eq(srv.buchungen().filter((b) => b.quelle === 'manuell').length, 0, 'nichts gespeichert')
await form.getByLabel('Name des Gastes').fill('Walter Laufkunde')
eq(await form.getByLabel('Bestätigung an den Gast senden').isDisabled(), true, 'Haken nur mit E-Mail')
await form.getByLabel('Telefon (optional)').fill('06301 999')
await knopf(form, 'Termin eintragen').click()
await meldung(page).waitFor()
eq(await textVon(meldung(page)), 'Termin eingetragen.', 'Meldung eintragen')
const manuell = srv.buchungen().find((b) => b.quelle === 'manuell')
eq([manuell.gastName, manuell.status, manuell.leistungName], ['Walter Laufkunde', 'bestaetigt', 'Herrenschnitt'], 'Server: Handeintrag')
assert(gleichZeit(manuell.startsAt, utc('2026-10-06', '09:30')), `Server: Zeit des Handeintrags ${manuell.startsAt}`)
// Ansicht springt auf den Tag des neuen Termins
await page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`).waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Tag des Eintrags')
eq(await page.getByRole('button', { name: 'Alle', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter nach dem Eintragen wieder auf Alle')
eq(await page.locator('.tm-formular').count(), 0, 'Formular zu')
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
// Zeit sperren: Hinweis 500 Minuten, zu lang ⇒ Fehler am Feld, dann Erfolg
await knopf(page, 'Zeit sperren').click()
const sperre = page.getByRole('form', { name: 'Zeit sperren' })
await sperre.waitFor()
assert((await textVon(sperre)).includes('höchstens 8 Stunden 20 Minuten (500 Minuten)'), 'Hinweis 500 Minuten')
await sperre.getByLabel('Person', { exact: true }).selectOption(R_BEN)
await sperre.getByLabel('Tag', { exact: true }).fill('2026-10-06')
await sperre.getByLabel('Von (Uhrzeit)').fill('8:00')
await sperre.getByLabel('Bis (Uhrzeit)').fill('18:00')
await knopf(sperre, 'Zeit sperren').click()
await sperre.locator('[data-fehler="bis"]:not(:empty)').waitFor()
assert((await textVon(sperre.locator('[data-fehler="bis"]'))).includes('höchstens 8 Stunden 20 Minuten'), 'Fehler vom Server am Feld Bis')
await sperre.getByLabel('Von (Uhrzeit)').fill('abc')
await knopf(sperre, 'Zeit sperren').click()
await sperre.locator('[data-fehler="von"]:not(:empty)').waitFor()
await sperre.getByLabel('Von (Uhrzeit)').fill('12:00')
await sperre.getByLabel('Bis (Uhrzeit)').fill('13:00')
await sperre.getByLabel('Titel (optional)').fill('Mittagspause')
await knopf(sperre, 'Zeit sperren').click()
await meldung(page).waitFor()
eq(await textVon(meldung(page)), 'Zeit gesperrt.', 'Meldung sperren')
const sperreDoc = srv.buchungen().find((b) => b.quelle === 'sperre')
assert(sperreDoc.leistungName === 'Mittagspause' && gleichZeit(sperreDoc.startsAt, utc('2026-10-06', '12:00')) && gleichZeit(sperreDoc.endsAt, utc('2026-10-06', '13:00')), 'Server: Sperre')
const sperreZeile = page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`)
await sperreZeile.waitFor()
assert((await textVon(sperreZeile)).includes('Gesperrt') && (await textVon(sperreZeile)).includes('Mittagspause'), 'Sperre in der Liste')
// ueberschneidende Sperre ⇒ belegt
await knopf(page, 'Zeit sperren').click()
const zweite = page.getByRole('form', { name: 'Zeit sperren' })
await zweite.getByLabel('Person', { exact: true }).selectOption(R_BEN)
await zweite.getByLabel('Tag', { exact: true }).fill('2026-10-06')
await zweite.getByLabel('Von (Uhrzeit)').fill('12:30')
await zweite.getByLabel('Bis (Uhrzeit)').fill('14:00')
await knopf(zweite, 'Zeit sperren').click()
await zweite.locator('[data-fehler="von"]:not(:empty)').waitFor()
assert((await textVon(zweite.locator('[data-fehler="von"]'))).includes('schon einen Termin oder eine Sperre'), 'belegte Zeit am Feld Von')
await knopf(zweite, 'Abbrechen').click()
// Sperre aufheben (Rueckfrage)
await knopf(sperreZeile, 'Sperre aufheben: Sperre, 06.10.2026 um 12:00 Uhr').click()
await knopf(sperreZeile.locator('.tm-frage-box'), 'Sperre aufheben').click()
await page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`).waitFor({ state: 'detached' })
eq(srv.buchungen().filter((b) => b.quelle === 'sperre').length, 0, 'Server: Sperre geloescht')
await shot(page, 'echt-nach-sperre')
})
await check('echt_abgeschaltet_nur_lesen', async (page) => {
srv.aw.seed('termineBookings', {
$id: 'b_alt', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
startsAt: utc('2026-10-06', '09:00'), endsAt: utc('2026-10-06', '09:30'), blockEndsAt: utc('2026-10-06', '09:35'), status: 'angefragt', quelle: 'online', gastName: 'Karl Ohr',
gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
})
await srv.setzeFunktion('disabled')
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
assert((await textVon(kachel)).includes('Abgeschaltet'), 'Kachel abgeschaltet')
await knopf(kachel, 'Termine ansehen').click()
await warteAnsicht(page)
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('abgeschaltet') && hinweis.includes('nur noch ansehen'), `Banner: ${hinweis}`)
assert(!hinweis.includes('Wartungs-Abo') && !hinweis.includes('löschen'), `ohne Grund und Zeitpunkt der bisherige Satz: ${hinweis}`)
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knoepfe oben')
await page.getByRole('button', { name: 'Tag', exact: true }).click()
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="b_alt"]').waitFor()
eq(await page.locator('li.tm-eintrag [data-aktion]').count(), 0, 'keine Aktionen an den Zeilen')
await shot(page, 'echt-abgeschaltet')
// die Schnittstelle verweigert Schreiben auch direkt
const r = await page.evaluate(async () => (await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) })).status)
eq(r, 409, 'Server: Schreiben bei abgeschalteter Funktion')
})
await check('echt_abgeschaltet_abo_ende', async (page) => {
await srv.setzeFunktion('disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' })
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
const text = await textVon(hinweis)
assert(text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner nennt Abo und Löschdatum: ${text}`)
eq(await page.locator('.tm-box-warn a').count(), 1, 'genau ein Link im Banner')
eq(await page.locator('.tm-box-warn a').innerText(), 'exportieren', 'Linktext')
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
// die Schnittstelle nennt dasselbe beim Schreiben
const antwort = await page.evaluate(async () => {
const res = await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) })
return { status: res.status, body: await res.json() }
})
eq(antwort.status, 409, 'Server: Schreiben bei abgeschalteter Funktion')
eq(antwort.body.code, 'abgeschaltet', 'Code bleibt')
assert(antwort.body.error.includes('Wartungs-Abo') && antwort.body.error.includes('04.11.2026'), `Meldung: ${antwort.body.error}`)
await shot(page, 'echt-abgeschaltet-abo-ende')
})
await check('echt_390_liste_und_formular', async (page) => {
srv.aw.seed('termineBookings', {
$id: 'b_390', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_DAMEN, leistungName: 'Damenschnitt', ressourceId: R_ANNA, ressourceName: 'Anna',
startsAt: utc('2026-10-05', '14:00'), endsAt: utc('2026-10-05', '14:45'), blockEndsAt: utc('2026-10-05', '14:50'), status: 'angefragt', quelle: 'online', gastName: 'Katharina von und zu Hohenlohe-Langenburg',
gastEmail: 'katharina@example.com', gastTelefon: '+49 6301 123456', gastNachricht: 'Bitte nach Möglichkeit mit der Stylistin vom letzten Mal, Frau Schneider.', gastZusatz: '', erinnerung: true,
createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(), preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
})
await oeffne(page, '#funktionen')
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
await warteAnsicht(page)
await page.locator('li.tm-eintrag').waitFor()
eq(await passtInBreite(page), true, 'echt: Liste ohne Streifen')
await shot(page, '390-5-echt-liste')
await knopf(page, '+ Termin eintragen').click()
const form = page.getByRole('form', { name: 'Termin eintragen' })
await form.locator('.tm-zeit-knopf').first().waitFor()
eq(await passtInBreite(page), true, 'echt: Formular ohne Streifen')
await shot(page, '390-6-echt-termin-eintragen')
await knopf(form, 'Abbrechen').click()
await knopf(page, 'Zeit sperren').click()
await page.getByRole('form', { name: 'Zeit sperren' }).waitFor()
eq(await passtInBreite(page), true, 'echt: Sperre ohne Streifen')
await shot(page, '390-7-echt-zeit-sperren')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ------------------------------------------------------------ Zeitzone des Browsers ist egal: Berliner Tage und Uhrzeiten
// Browser-Uhr 2026-10-05T23:30Z: in Berlin schon Dienstag 01:30, in New York noch Montag 19:30. Drei Termine rund um Mitternacht.
for (const [name, zone] of [['echt_zeitzone_new_york', 'America/New_York'], ['echt_zeitzone_berlin', 'Europe/Berlin']]) {
await check(name, async (page) => {
const doc = (id, tag, von, bis, gast) => ({
$id: id, featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
startsAt: utc(tag, von), endsAt: bis === '24:00' ? utc(tagNach(tag), '00:00') : utc(tag, bis), blockEndsAt: bis === '24:00' ? utc(tagNach(tag), '00:00') : utc(tag, bis),
status: 'bestaetigt', quelle: 'online', gastName: gast, gastEmail: 'gast@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false,
createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(), preisCent: 2800, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
})
srv.aw.seed('termineBookings', doc('b_spaet', '2026-10-05', '23:30', '24:00', 'Spät Abend'))
srv.aw.seed('termineBookings', doc('b_nacht', '2026-10-06', '00:30', '01:00', 'Nacht Eins'))
srv.aw.seed('termineBookings', doc('b_frueh', '2026-10-06', '09:00', '09:30', 'Früh Neun'))
await oeffne(page, '#termine')
await warteAnsicht(page)
eq(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone), zone, 'Zeitzone des Browsers')
// heute ist in Berlin Dienstag, auch wenn es in New York noch Montag ist
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Titel: Berliner Tag')
await page.locator('li.tm-eintrag[data-id="b_frueh"]').waitFor()
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['b_nacht', 'b_frueh'], 'Dienstag: die beiden Termine')
const zeiten = await page.locator('li.tm-eintrag .tm-zeit').allInnerTexts()
eq(zeiten, ['00:30–01:00', '09:00–09:30'], 'Uhrzeiten wie in Berlin')
// ein Tag zurueck: der Termin bis Mitternacht endet um 24:00
await page.getByRole('button', { name: 'Zurück', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="b_spaet"]').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'ein Tag zurück')
eq(await page.locator('li.tm-eintrag .tm-zeit').allInnerTexts(), ['23:30–24:00'], 'Ende um Mitternacht als 24:00')
// Woche: Ueberschriften „Heute“ und „Gestern“ nach Berliner Datum
await page.getByRole('button', { name: 'Heute', exact: true }).click()
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="b_spaet"]').waitFor()
eq(await page.locator('.tm-tag-kopf').allInnerTexts().then((l) => l.slice(0, 2)), ['Gestern · Montag, 5. Oktober', 'Heute · Dienstag, 6. Oktober'], 'Gestern und Heute nach Berliner Datum')
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) => {
const patches = zaehlePatches(page)
await oeffneReiter(page, 'leistungen')
assert((await textVon(page.locator('#tm-panel .tm-box-info[role="note"]'))).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 (Ruling R51): die Pfeile aendern nur eine gemerkte Reihenfolge, gespeichert wird sie in EINEM Speichern
const leiste = page.locator('#tm-panel .tm-reihenfolge')
eq(await leiste.isHidden(), true, 'ohne Aenderung keine Leiste')
const patchesVorher = patches.length
const vR = version()
await knopf(page, 'Nach oben: Damenschnitt').click()
await leiste.waitFor()
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').first().innerText(), 'Damenschnitt', 'Anzeige geändert')
const stand = await textVon(leiste.locator('[role="status"]'))
assert(stand.includes('Stelle 1 von 7') && stand.includes('noch nicht gespeichert'), `Stand der Reihenfolge: ${stand}`)
eq([gespeichert().leistungen[0].name, version(), patches.length], ['Herrenschnitt', vR, patchesVorher], 'kein Speichern durch den Pfeil')
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
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
await shot(page, 'leistungen-reihenfolge')
// verwerfen: alles wie gespeichert
await knopf(leiste, 'Verwerfen').click()
await leiste.waitFor({ state: 'hidden' })
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').first().innerText(), 'Herrenschnitt', 'verworfen')
eq([version(), patches.length], [vR, patchesVorher], 'verwerfen speichert nichts')
// zwei Aenderungen der Reihenfolge, ein Speichern
await knopf(page, 'Nach oben: Damenschnitt').click()
await knopf(page, 'Nach oben: Kinderschnitt').click()
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts().then((l) => l.slice(0, 3)), ['Damenschnitt', 'Kinderschnitt', 'Herrenschnitt'], 'Anzeige')
await knopf(leiste, 'Reihenfolge speichern').click()
await leiste.waitFor({ state: 'hidden' })
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 3), [['Damenschnitt', 1], ['Kinderschnitt', 2], ['Herrenschnitt', 3]], 'Reihenfolge gespeichert')
eq([version(), patches.length], [vR + 1, patchesVorher + 1], 'ein Speichern für beide Änderungen')
assert((await textVon(meldung(page))).includes('Reihenfolge gespeichert.'), 'Meldung')
// 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')
})
// ------------------------------------------------------------ Reihenfolge-Entwurf und Bearbeiten: Eintraege per ID, nicht per Stelle (R51)
await check('echt_reihenfolge_entwurf_und_bearbeiten', async (page) => {
const patches = zaehlePatches(page)
await oeffneReiter(page, 'leistungen')
// ein offener Editor mit ungespeicherter Eingabe ueberlebt eine Aenderung der Reihenfolge
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
await kind.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
await knopf(page, 'Nach oben: Damenschnitt').click()
eq(await kind.getByLabel('Dauer (Minuten)', { exact: true }).inputValue(), '25', 'offener Editor bleibt mit seiner Eingabe')
await knopf(kind, 'Abbrechen').click()
// Anzeige: Damenschnitt, Herrenschnitt, ... ; gespeichert ist Herrenschnitt zuerst (Stelle 0), Damenschnitt an Stelle 1
const herren = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
await herren.getByLabel('Name', { exact: true }).fill('')
const vorher = version()
await knopf(herren, 'Speichern').click()
// Fehler des Servers heissen nach der gespeicherten Stelle (leistungen.0) und stehen im Formular der richtigen Leistung
await fehlerAm(herren, 'leistungen.0.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
eq([patches.length, version()], [1, vorher], 'abgelehnt, nichts gespeichert')
await herren.getByLabel('Name', { exact: true }).fill('Herrenschnitt neu')
await herren.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
await knopf(herren, 'Speichern').click()
await warteBis(() => gespeichert().leistungen[0].name === 'Herrenschnitt neu', 'Herrenschnitt gespeichert')
const e = gespeichert()
eq([e.leistungen[0].name, e.leistungen[0].dauer, e.leistungen[1].name, e.leistungen[1].dauer], ['Herrenschnitt neu', 35, 'Damenschnitt', 45], 'die richtige Leistung wurde geändert, die Reihenfolge ist unverändert')
// die gemerkte Reihenfolge ueberlebt das Speichern einer Leistung (Anzeige und Leiste)
await page.locator('#tm-panel .tm-reihenfolge').waitFor()
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts().then((l) => l.slice(0, 2)), ['Damenschnitt', 'Herrenschnitt neu'], 'Anzeige bleibt')
await knopf(page, 'Reihenfolge speichern').click()
await page.locator('#tm-panel .tm-reihenfolge').waitFor({ state: 'hidden' })
eq(gespeichert().leistungen.map((l) => l.name).slice(0, 2), ['Damenschnitt', 'Herrenschnitt neu'], 'Reihenfolge nach IDs auf den aktuellen Stand angewendet')
eq(gespeichert().leistungen.find((l) => l.id === S_HERREN).dauer, 35, 'die Änderung ging nicht verloren')
})
// ------------------------------------------------------------ Nur ein Speichern gleichzeitig
await check('echt_speichern_nur_ein_vorgang', async (page) => {
const patches = zaehlePatches(page)
// jede Antwort auf ein Speichern braucht 0,9 s: so lange ist der Reiter beschaeftigt
await page.route('**/api/termine/einstellungen', async (route) => {
if (route.request().method() === 'PATCH') await new Promise((fertig) => { setTimeout(fertig, 900) })
await route.fallback()
})
await oeffneReiter(page, 'leistungen')
const tafel = page.locator('#tm-panel')
const freieKnoepfe = () => page.locator('#tm-panel .tm-tab button:not(:disabled)').count()
const v0 = version()
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
await knopf(form, 'Speichern').click()
await tafel.and(page.locator('[aria-busy="true"]')).waitFor()
eq(await freieKnoepfe(), 0, 'beschäftigt: alle Knöpfe des Reiters gesperrt')
await shot(page, 'leistungen-beschaeftigt')
// ein zweites Absenden (z. B. Enter im Formular) wird abgewiesen, nicht mit alter Version gesendet
await form.evaluate((f) => f.requestSubmit())
await form.evaluate((f) => f.requestSubmit())
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
eq(patches.length, 1, 'genau ein PATCH')
eq(version(), v0 + 1, 'genau ein Speichern')
eq(gespeichert().leistungen[0].dauer, 35, 'Wirkung da')
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
assert((await freieKnoepfe()) > 5, 'danach sind die Knöpfe wieder frei')
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), false, 'frei wie vorher (Pfeil nicht am Rand)')
eq(await knopf(page, 'Nach oben: Herrenschnitt').isDisabled(), true, 'am Rand bleibt gesperrt')
// Doppelklick auf Speichern: ein Speichern, kein Konflikt
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
const form2 = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
await knopf(form2, 'Speichern').click({ clickCount: 2, delay: 20 })
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
await warteBis(() => gespeichert().leistungen[0].dauer === 40, 'zweites Speichern')
eq([patches.length, version()], [2, v0 + 2], 'der Doppelklick sendet einmal')
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
assert((await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())).includes('40 Min.'), 'Wirkung sichtbar')
})
// ------------------------------------------------------------ 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
assert((await textVon(page.locator('#tm-panel .tm-abschnitt', { hasText: 'Sonderöffnungen' }).locator('.tm-abschnitt-text'))).endsWith('und geht dem Wochenplan und den Feiertagen vor.'), 'Satz: „geht … vor“')
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')
})
// ------------------------------------------------------------ Fokus nach „Neuen Link erzeugen“
await check('echt_feed_link_fokus_nach_erzeugen', async (page) => {
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 vorher = await link.inputValue()
await knopf(feed, 'Neuen Link erzeugen').click()
await knopf(page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }), 'Neuen Link erzeugen').click()
await page.waitForFunction((alt) => document.querySelector('#tm-panel .tm-link-feld')?.value !== alt, vorher)
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
// der Fokus steht wieder auf dem Knopf, der die Rueckfrage geoeffnet hat (und der ist frei), nicht verloren auf der Seite
const aktiv = await page.evaluate(() => ({ aktion: document.activeElement?.dataset?.aktion, gesperrt: document.activeElement?.disabled, text: document.activeElement?.textContent }))
eq(aktiv, { aktion: 'link-neu', gesperrt: false, text: 'Neuen Link erzeugen' }, 'Fokus nach dem Erzeugen')
// ... und per Tastatur geht es weiter: Enter oeffnet die naechste Rueckfrage
await page.keyboard.press('Enter')
await page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }).waitFor()
})
// ------------------------------------------------------------ Reiter wechseln, waehrend ein Speichern laeuft: aria-busy faellt
await check('echt_busy_nach_reiterwechsel', async (page) => {
// die Antwort auf ein Speichern braucht 0,9 s
await page.route('**/api/termine/einstellungen', async (route) => {
if (route.request().method() === 'PATCH') await new Promise((fertig) => { setTimeout(fertig, 900) })
await route.fallback()
})
await oeffneReiter(page, 'leistungen')
const v0 = version()
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
await knopf(form, 'Speichern').click()
await page.locator('#tm-panel[aria-busy="true"]').waitFor()
// mitten im Speichern zu Team & Plaetze wechseln: der neue Reiter hat sein eigenes, freies Gerueest
await page.locator('[data-reiter="team"]').click()
await page.locator('#tm-tab-team[aria-selected="true"]').waitFor()
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
// das Speichern geht zu Ende, danach darf aria-busy nicht stehen bleiben
await warteBis(() => version() === v0 + 1, 'Speichern angekommen')
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'), null, { timeout: 4000 })
eq(await page.locator('#tm-panel[aria-busy]').count(), 0, 'aria-busy nach dem Speichern weg')
eq(await page.locator('#tm-panel .tm-tab button:disabled').count() > 0, false, 'die Knöpfe des neuen Reiters sind frei')
eq(await knopf(page, '+ Person oder Platz hinzufügen').isEnabled(), true, 'neuer Reiter bedienbar')
// das Ergebnis des Speicherns ist trotzdem da und der Reiter Leistungen zeigt es
await page.locator('[data-reiter="leistungen"]').click()
assert((await textVon(zeileMitId(page, S_HERREN).locator('.tm-meta').first())).includes('35 Min.'), 'Leistungen zeigen die gespeicherte Dauer')
eq(await page.locator('#tm-panel[aria-busy]').count(), 0, 'weiter kein aria-busy')
})
// ------------------------------------------------------------ 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'))
// Ruling R52: Wortlaut der rechtlichen Hinweise
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
eq(await textVon(page.locator('#tm-panel [data-hinweis="storno-muster"]')), 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.', 'Hinweis „Muster“ beim Stornofeld')
assert(text.includes('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'), 'Storno nur bei Preis')
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
// ------------------------------------------------------------ Einbau-Code der Website (Abschlussrunde C4)
await check('echt_einbau_code_sichtbar_und_kopierbar', async (page, ctx) => {
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
await oeffneReiter(page, 'einstellungen')
const box = page.locator('#tm-panel section', { has: page.getByRole('heading', { name: 'Einbau auf deiner Website' }) })
await box.waitFor()
const code = box.locator('textarea.tm-code-feld')
const antwort = await page.evaluate(async () => (await (await fetch('/api/termine/einstellungen?projectId=p1')).json()).einbau.snippet)
eq(await code.inputValue(), antwort, 'der Code ist der Einbau-Code des Servers')
assert(antwort.includes(`data-webklar-termine="${KEY}"`) && antwort.includes('/embed/termine.js'), `Code mit siteKey und Skript: ${antwort}`)
eq(await code.getAttribute('readonly'), '', 'nur lesbar')
eq(await code.getAttribute('aria-label'), 'Einbau-Code für deine Website', 'Name des Feldes')
const hoehe = await knopf(box, 'Code kopieren').boundingBox()
assert(hoehe.height >= 44 || (await page.viewportSize()).width > 880, `Knopf gut treffbar: ${hoehe.height}`)
await shot(page, 'einstellungen-einbau')
await knopf(box, 'Code kopieren').click()
await box.getByText('Der Code ist kopiert.').waitFor()
eq(await page.evaluate(() => navigator.clipboard.readText()), antwort, 'Zwischenablage')
assert(await passtInBreite(page), 'kein seitliches Scrollen')
})
await check('echt_einbau_code_ohne_zwischenablage', async (page, ctx) => {
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
await oeffneReiter(page, 'einstellungen')
const box = page.locator('#tm-panel section', { has: page.getByRole('heading', { name: 'Einbau auf deiner Website' }) })
await knopf(box, 'Code kopieren').click()
const status = box.locator('[role="status"]')
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
assert((await textVon(status)).includes('markiert'), 'Hinweis: Code ist markiert')
const auswahl = await page.evaluate(() => {
const el = document.activeElement
return { code: el?.classList.contains('tm-code-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
})
eq(auswahl.code, true, 'Fokus im Code-Feld')
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Code ist markiert')
})
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.locator('#tm-panel [data-aktion="einbau-kopieren"], #tm-panel .tm-code-feld').count(), 0, 'Einstellungen: kein Einbau-Code bei abgeschalteter Funktion')
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(page, 'Nach oben: Damenschnitt').click()
await page.locator('#tm-panel .tm-reihenfolge').waitFor()
await pruefe('Leistungen mit Reihenfolge-Leiste')
await shot(page, '390-8b-leistungen-reihenfolge')
await knopf(page.locator('#tm-panel .tm-reihenfolge'), 'Verwerfen').click()
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
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
const fremd = [...hosts].filter((h) => h !== new URL(srv.url).host)
results.push(['nur_eigene_herkunft', fremd.length === 0])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
}
const fehlgeschlagen = results.filter(([, ok]) => !ok)
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)