1854 lines
126 KiB
JavaScript
1854 lines
126 KiB
JavaScript
// 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({})
|
||
}
|
||
|
||
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
|
||
function neueFixture() {
|
||
const fx = {
|
||
verfuegbar: true,
|
||
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: [],
|
||
anfragenOffen: 0,
|
||
buchungen: [],
|
||
aufrufe: [],
|
||
}
|
||
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,
|
||
})
|
||
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: '' }),
|
||
]
|
||
}
|
||
|
||
/** 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') return json({ projects: fx.projekte, termine: { verfuegbar: fx.verfuegbar } })
|
||
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()
|
||
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}`)
|
||
}
|
||
const zeileMitId = (page, id) => page.locator(`#tm-panel li[data-id="${id}"]`)
|
||
const eintrag = (page, text) => page.locator('#tm-panel li.tm-eintrag', { hasText: text })
|
||
const formular = (page, name) => page.getByRole('form', { name })
|
||
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
|
||
const warteAnsicht = async (page) => {
|
||
await page.locator('#view-termine.active').waitFor()
|
||
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
|
||
}
|
||
|
||
// ---- 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
|
||
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('Wir schalten den Terminplaner für dich frei.'), `Aus: ${t}`)
|
||
await knopf(kachel, 'Chat öffnen').waitFor()
|
||
eq(await kachel.locator('[data-view="termine"]').count(), 0, 'Aus: kein Weg in die Ansicht')
|
||
// 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 ohne Terminplaner: Hinweis statt Reiter
|
||
await page.locator('#termine-project').selectOption('p1')
|
||
await page.getByText('noch nicht freigeschaltet').waitFor()
|
||
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
|
||
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
|
||
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
|
||
})(),
|
||
})
|
||
|
||
// ------------------------------------------------------------ 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
|
||
})(),
|
||
})
|
||
|
||
// ------------------------------------------------------------ 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}`)
|
||
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_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) => {
|
||
await oeffneReiter(page, 'leistungen')
|
||
assert((await textVon(page.locator('#tm-panel .tm-box-info'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
|
||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts(), ['Herrenschnitt', 'Damenschnitt', 'Kinderschnitt', 'Färben', 'Kennenlern-Beratung', 'Brautfrisur'], 'Reihenfolge wie gespeichert')
|
||
const meta = await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())
|
||
assert(['30 Min.', '5 Min. Pause danach', '28,00 €', 'Sofort buchbar', 'Anna, Ben'].every((t) => meta.includes(t)), `Kurzfassung: ${meta}`)
|
||
assert((await textVon(eintrag(page, 'Brautfrisur'))).includes('Nur als Anfrage') && (await textVon(eintrag(page, 'Kennenlern-Beratung'))).includes('ohne Preis'), 'Anfrage und ohne Preis')
|
||
await shot(page, 'leistungen')
|
||
|
||
// Leistung anlegen: erst Fehler (Dauer leer: im Feld erkannt; ohne Name und Person: vom Server), dann ein gueltiger Eintrag
|
||
const v0 = version()
|
||
await knopf(page, '+ Leistung anlegen').click()
|
||
const neu = formular(page, 'Leistung anlegen')
|
||
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('')
|
||
await knopf(neu, 'Leistung anlegen').click()
|
||
eq(await textVon(fehlerAm(neu, 'leistungen.6.dauer')), 'Bitte gib die Dauer in ganzen Minuten an.', 'Dauer leer')
|
||
eq(version(), v0, 'nichts gespeichert')
|
||
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('20')
|
||
await knopf(neu, 'Leistung anlegen').click()
|
||
await fehlerAm(neu, 'leistungen.6.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
|
||
assert((await textVon(fehlerAm(neu, 'leistungen.6.ressourcen'))).includes('mindestens eine aktive Person'), 'Zuordnung fehlt (Server)')
|
||
eq(await neu.getByLabel('Name', { exact: true }).getAttribute('aria-invalid'), 'true', 'Feld markiert')
|
||
eq(version(), v0, 'auch der Server hat nichts gespeichert')
|
||
await neu.getByLabel('Name', { exact: true }).fill('Bartpflege')
|
||
await neu.getByLabel('Beschreibung (optional)').fill('Trimmen und Konturen.')
|
||
await neu.getByLabel('Pause danach (Minuten)').fill('5')
|
||
await neu.getByLabel('Preis', { exact: true }).selectOption('fest')
|
||
await neu.getByLabel('Betrag in Euro').fill('19,90')
|
||
await neu.getByLabel('Anna · Person').check()
|
||
await shot(page, 'leistung-anlegen')
|
||
await knopf(neu, 'Leistung anlegen').click()
|
||
await eintrag(page, 'Bartpflege').waitFor()
|
||
assert((await textVon(meldung(page))).includes('Leistung angelegt.'), 'Meldung')
|
||
eq(version(), v0 + 1, 'ein Speichern')
|
||
const angelegt = gespeichert().leistungen.at(-1)
|
||
assert(/^s_[a-z0-9]{8}$/.test(angelegt.id), `ID im Muster s_ + 8: ${angelegt.id}`)
|
||
eq({ ...angelegt, id: 'x' }, { id: 'x', name: 'Bartpflege', beschreibung: 'Trimmen und Konturen.', dauer: 20, pufferNach: 5, preisArt: 'fest', preisCent: 1990, buchungsart: 'sofort', ressourcen: [R_ANNA], aktiv: true, reihenfolge: 7 }, 'gespeicherte Leistung')
|
||
const meta2 = await textVon(eintrag(page, 'Bartpflege').locator('.tm-meta').first())
|
||
assert(meta2.includes('19,90 €') && meta2.includes('Anna') && !meta2.includes('Ben'), `Zeile: ${meta2}`)
|
||
// Gaeste sehen sie sofort
|
||
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
|
||
const gast = conf.leistungen.find((l) => l.name === 'Bartpflege')
|
||
assert(gast && gast.preis === '19,90 €' && gast.id === angelegt.id, 'oeffentliche Config nennt die neue Leistung')
|
||
|
||
// bearbeiten, zweimal hintereinander ohne Neuladen (die Version wird mitgefuehrt)
|
||
for (const dauer of ['35', '40']) {
|
||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill(dauer)
|
||
await knopf(form, 'Speichern').click()
|
||
await page.waitForFunction((d) => document.querySelector('#tm-panel li[data-id="s_herren01"] .tm-meta')?.textContent.includes(`${d} Min.`), dauer)
|
||
eq(gespeichert().leistungen[0].dauer, Number(dauer), `Dauer ${dauer} gespeichert`)
|
||
}
|
||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt mit sich selbst')
|
||
assert((await textVon(meldung(page))).includes('Leistung gespeichert.'), 'Meldung Speichern')
|
||
|
||
// Reihenfolge: Damenschnitt nach oben
|
||
await knopf(page, 'Nach oben: Damenschnitt').click()
|
||
await page.waitForFunction(() => document.querySelector('#tm-panel li.tm-eintrag .tm-name')?.textContent === 'Damenschnitt')
|
||
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 2), [['Damenschnitt', 1], ['Herrenschnitt', 2]], 'Reihenfolge gespeichert')
|
||
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), true, 'ganz oben: nicht weiter hoch')
|
||
eq(await knopf(page, 'Nach unten: Bartpflege').isDisabled(), true, 'ganz unten: nicht weiter runter')
|
||
// der Fokus bleibt in der verschobenen Zeile (kein Verlust nach dem Neuzeichnen)
|
||
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
|
||
// Deaktivieren (Leistung bleibt gespeichert), Gaeste sehen sie nicht mehr
|
||
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
|
||
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
|
||
await kind.getByLabel(/^Aktiv/).uncheck()
|
||
await knopf(kind, 'Speichern').click()
|
||
await eintrag(page, 'Kinderschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
|
||
assert(!(await (await fetch(`${API}/${KEY}/config`)).json()).leistungen.some((l) => l.name === 'Kinderschnitt'), 'inaktive Leistung nicht oeffentlich')
|
||
await shot(page, 'leistungen-nach-aenderung')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Leistung mit kuenftigem Termin entfernen ⇒ Anzahl am Eintrag
|
||
await check('echt_leistung_entfernen_mit_termin', async (page) => {
|
||
await buche({ leistung: S_HERREN, tag: '2026-10-12', zeit: '10:00', email: 'a@example.com' })
|
||
await buche({ leistung: S_HERREN, tag: '2026-10-13', zeit: '11:00', email: 'b@example.com' })
|
||
await buche({ leistung: S_DAMEN, tag: '2026-10-14', zeit: '09:00', email: 'c@example.com', name: 'Dora Damen' })
|
||
await oeffneReiter(page, 'leistungen')
|
||
const v0 = version()
|
||
// zwei kuenftige Termine ⇒ Mehrzahl, die Leistung bleibt
|
||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||
await knopf(form, 'Entfernen').click()
|
||
const frage = page.getByRole('form', { name: 'Leistung „Herrenschnitt“ entfernen?' })
|
||
await frage.waitFor()
|
||
assert((await textVon(frage)).includes('Das kann nicht rückgängig gemacht werden.'), 'Rueckfrage in der Seite')
|
||
await knopf(frage, 'Ja, entfernen').click()
|
||
await frage.locator('.tm-fehler:not(:empty)').waitFor()
|
||
eq(await textVon(frage.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.', 'Anzahl am Eintrag')
|
||
eq(gespeichert().leistungen.some((l) => l.id === S_HERREN), true, 'Leistung bleibt')
|
||
eq(version(), v0, 'nichts gespeichert')
|
||
await shot(page, 'leistung-entfernen-mit-termin')
|
||
// Abbrechen schliesst die Rueckfrage
|
||
await knopf(frage, 'Abbrechen').click()
|
||
eq(await frage.count(), 0, 'Rueckfrage zu')
|
||
// ein Termin ⇒ Einzahl
|
||
await knopf(eintrag(page, 'Damenschnitt'), 'Bearbeiten: Damenschnitt').click()
|
||
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Entfernen').click()
|
||
const frageDamen = page.getByRole('form', { name: 'Leistung „Damenschnitt“ entfernen?' })
|
||
await knopf(frageDamen, 'Ja, entfernen').click()
|
||
await frageDamen.locator('.tm-fehler:not(:empty)').waitFor()
|
||
eq(await textVon(frageDamen.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.', 'Einzahl')
|
||
// Deaktivieren geht, die Termine bleiben
|
||
await knopf(frageDamen, 'Abbrechen').click()
|
||
await formular(page, 'Leistung bearbeiten: Damenschnitt').getByLabel(/^Aktiv/).uncheck()
|
||
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Speichern').click()
|
||
await eintrag(page, 'Damenschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
|
||
eq(srv.buchungen().filter((b) => b.leistungId === S_DAMEN && b.status === 'bestaetigt').length, 1, 'Termin der inaktiven Leistung bleibt')
|
||
// ohne Termin ⇒ weg
|
||
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
|
||
await knopf(formular(page, 'Leistung bearbeiten: Kinderschnitt'), 'Entfernen').click()
|
||
await knopf(page.getByRole('form', { name: 'Leistung „Kinderschnitt“ entfernen?' }), 'Ja, entfernen').click()
|
||
await eintrag(page, 'Kinderschnitt').waitFor({ state: 'detached' })
|
||
eq(gespeichert().leistungen.some((l) => l.id === S_KINDER), false, 'Kinderschnitt entfernt')
|
||
assert((await textVon(meldung(page))).includes('Leistung „Kinderschnitt“ entfernt.'), 'Meldung')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Team & Plätze: Wochenplan ändern, neuer Platz mit Zuordnung
|
||
await check('echt_team_wochenplan_und_neuer_platz', async (page) => {
|
||
await oeffneReiter(page, 'team')
|
||
const kopfzeile = await page.locator('#tm-panel li.tm-eintrag .tm-meta').allInnerTexts()
|
||
assert(kopfzeile[0].includes('Person') && kopfzeile[0].includes('Zeiten an 6 Tagen') && kopfzeile[0].includes('6 Leistungen'), `Kurzfassung: ${kopfzeile[0]}`)
|
||
await shot(page, 'team')
|
||
// Wochenplan von Anna: Montag ändern
|
||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||
const form = formular(page, 'Bearbeiten: Anna')
|
||
const montag = form.getByLabel('Montag', { exact: true })
|
||
eq(await montag.inputValue(), '09:00–12:30, 13:30–18:00', 'Wochenplan als Text')
|
||
eq(await form.getByLabel('Sonntag', { exact: true }).inputValue(), '', 'Sonntag geschlossen')
|
||
await shot(page, 'team-bearbeiten')
|
||
// ungueltige Eingaben ⇒ Fehler am Tag, nichts wird gesendet
|
||
const v0 = version()
|
||
await montag.fill('10-25')
|
||
await form.getByLabel('Dienstag', { exact: true }).fill('09:00–12:00, 11:00–13:00')
|
||
await knopf(form, 'Speichern').click()
|
||
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.1'))).includes('09:00–12:30, 13:30–18:00'), 'Format-Hinweis am Montag')
|
||
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.2'))).includes('ohne Überlappung'), 'Überlappung am Dienstag')
|
||
eq(await montag.getAttribute('aria-invalid'), 'true', 'Montag markiert')
|
||
eq(version(), v0, 'nichts gespeichert')
|
||
// gueltig: Montag 10:00–14:00 (Schreibweise wird vereinheitlicht), Dienstag und Sonntag
|
||
await montag.fill('10:00 - 14:00')
|
||
await form.getByLabel('Dienstag', { exact: true }).fill('9:00–12:00 und 14:00 bis 24:00')
|
||
await form.getByLabel('Sonntag', { exact: true }).fill('10:00–11:00')
|
||
await knopf(form, 'Speichern').click()
|
||
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_anna0001"] .tm-meta')?.textContent.includes('Zeiten an 7 Tagen'))
|
||
eq(version(), v0 + 1, 'ein Speichern')
|
||
const plan = gespeichert().ressourcen[0].plan
|
||
eq([plan['1'], plan['2'], plan['7'], plan['3']], [[['10:00', '14:00']], [['09:00', '12:00'], ['14:00', '24:00']], [['10:00', '11:00']], [['09:00', '12:30'], ['13:30', '18:00']]], 'Plan gespeichert')
|
||
// Gaeste sehen die neuen Zeiten: Montag 2026-10-12 hat bei Anna nichts vor 10:00
|
||
const tage = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-12`)).json()
|
||
eq([tage.zeiten?.[0], tage.zeiten?.at(-1)], ['10:00', '13:15'], 'erste und letzte Startzeit am Montag (15-Minuten-Raster, 30 Min. + 5 Pause bis 14:00)')
|
||
|
||
// neuer Platz mit Zeiten und Zuordnung in EINEM Speichern
|
||
const v1 = version()
|
||
await knopf(page, '+ Person oder Platz hinzufügen').click()
|
||
const neu = formular(page, 'Person oder Platz hinzufügen')
|
||
await neu.getByLabel('Name', { exact: true }).fill('Kabine 1')
|
||
await neu.getByLabel('Art', { exact: true }).selectOption('platz')
|
||
await neu.getByLabel('Montag', { exact: true }).fill('09:00–12:00')
|
||
await neu.getByLabel('Herrenschnitt').check()
|
||
await neu.getByLabel('Färben').check()
|
||
await knopf(neu, 'Hinzufügen').click()
|
||
await eintrag(page, 'Kabine 1').waitFor()
|
||
eq(version(), v1 + 1, 'ein Speichern für Platz und Zuordnung')
|
||
const e = gespeichert()
|
||
const platz = e.ressourcen.at(-1)
|
||
assert(/^r_[a-z0-9]{8}$/.test(platz.id) && platz.typ === 'platz' && platz.aktiv === true, `Platz: ${JSON.stringify(platz)}`)
|
||
assert(/^[A-Za-z0-9_-]{43}$/.test(platz.feedToken), 'Kalender-Token vergibt der Server')
|
||
eq(e.leistungen.filter((l) => l.ressourcen.includes(platz.id)).map((l) => l.name), ['Herrenschnitt', 'Färben'], 'Zuordnung bei den Leistungen')
|
||
eq(await textVon(eintrag(page, 'Kabine 1').locator('.tm-meta').first()), 'Platz · Zeiten an 1 Tag · 2 Leistungen', 'Kurzfassung')
|
||
// der neue Platz zeigt seinen Kalender-Link, sobald die Daten neu geladen sind (kein Neuladen der Seite noetig)
|
||
await knopf(eintrag(page, 'Kabine 1'), 'Bearbeiten: Kabine 1').click()
|
||
await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').waitFor()
|
||
assert((await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').inputValue()).endsWith(`/${platz.feedToken}.ics`), 'Link mit dem Token des Servers')
|
||
|
||
// Person entfernen: Zeiten, Zuordnung und Abwesenheiten gehen mit
|
||
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
|
||
await knopf(formular(page, 'Bearbeiten: Ben'), 'Entfernen').click()
|
||
await knopf(page.getByRole('form', { name: '„Ben“ entfernen?' }), 'Ja, entfernen').click()
|
||
await zeileMitId(page, R_BEN).waitFor({ state: 'detached' })
|
||
const nach = gespeichert()
|
||
eq(nach.ressourcen.map((r) => r.name), ['Anna', 'Kabine 1'], 'Ben entfernt')
|
||
eq(nach.leistungen.every((l) => !l.ressourcen.includes(R_BEN)), true, 'aus allen Zuordnungen entfernt')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Team: Fehler des Servers stehen an der Leistung (nicht in der Person)
|
||
await check('echt_team_fehler_zuordnung', async (page) => {
|
||
await oeffneReiter(page, 'team')
|
||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||
const form = formular(page, 'Bearbeiten: Anna')
|
||
// Anna und Ben bieten alles an; zuerst bei Ben alles abwaehlen ⇒ dann haengt jede Leistung nur an Anna
|
||
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
|
||
const ben = formular(page, 'Bearbeiten: Ben')
|
||
for (const kasten of await ben.locator('fieldset input[type=checkbox]').all()) await kasten.uncheck()
|
||
await knopf(ben, 'Speichern').click()
|
||
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_ben00002"] .tm-meta')?.textContent.includes('keine Leistung'))
|
||
// jetzt Anna auf „Inaktiv“: alle aktiven Leistungen haben dann niemanden
|
||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||
const anna = formular(page, 'Bearbeiten: Anna')
|
||
await anna.getByLabel(/^Aktiv/).first().uncheck()
|
||
const v0 = version()
|
||
await knopf(anna, 'Speichern').click()
|
||
const slot = fehlerAm(anna, 'leistungen.0.ressourcen')
|
||
await slot.filter({ hasText: 'mindestens eine aktive Person' }).waitFor()
|
||
eq(await anna.locator('[data-fehler^="leistungen."]:not(:empty)').count(), 6, 'ein Fehler an jeder der sechs Leistungen')
|
||
eq(await anna.locator('input[aria-invalid="true"]').count(), 6, 'sechs Haken markiert')
|
||
eq(version(), v0, 'nichts gespeichert')
|
||
await shot(page, 'team-fehler-zuordnung')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Team: Abwesenheiten, Feiertage, Sonderöffnungen
|
||
await check('echt_team_ausnahmen', async (page) => {
|
||
await oeffneReiter(page, 'team')
|
||
// Abwesenheit
|
||
const v0 = version()
|
||
await knopf(page, '+ Abwesenheit eintragen').click()
|
||
const neu = formular(page, 'Abwesenheit eintragen')
|
||
await neu.getByLabel('Von (erster Tag)').fill('2026-11-06')
|
||
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-02')
|
||
await knopf(neu, 'Abwesenheit eintragen').click()
|
||
await fehlerAm(neu, 'abwesenheiten.0.bis').filter({ hasText: 'Das Ende darf nicht vor dem Beginn liegen.' }).waitFor() // Fehler des Servers am Feld
|
||
eq(version(), v0, 'nichts gespeichert')
|
||
await neu.getByLabel('Von (erster Tag)').fill('2026-11-02')
|
||
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-06')
|
||
await neu.getByLabel('Titel (optional)').fill('Betriebsurlaub')
|
||
await knopf(neu, 'Abwesenheit eintragen').click()
|
||
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Betriebsurlaub' }).waitFor()
|
||
const a = gespeichert().abwesenheiten[0]
|
||
assert(/^a_[a-z0-9]{8}$/.test(a.id), `ID a_ + 8: ${a.id}`)
|
||
eq({ ...a, id: 'x' }, { id: 'x', ressourceId: null, von: '2026-11-02', bis: '2026-11-06', titel: 'Betriebsurlaub' }, 'Abwesenheit gespeichert')
|
||
eq(await textVon(page.locator('#tm-panel [aria-label="Abwesenheiten"] li .tm-name')), '02.11.2026 – 06.11.2026', 'Zeitraum')
|
||
// ... und fuer Anna allein, vergangene stehen eingeklappt
|
||
await knopf(page, '+ Abwesenheit eintragen').click()
|
||
const anna = formular(page, 'Abwesenheit eintragen')
|
||
await anna.getByLabel('Wer ist weg?').selectOption(R_ANNA)
|
||
await anna.getByLabel('Von (erster Tag)').fill('2026-09-01')
|
||
await anna.getByLabel('Bis (letzter Tag)').fill('2026-09-05')
|
||
await knopf(anna, 'Abwesenheit eintragen').click()
|
||
await page.locator('#tm-panel summary', { hasText: 'Vergangene Abwesenheiten (1)' }).waitFor()
|
||
eq(gespeichert().abwesenheiten.length, 2, 'zwei Abwesenheiten')
|
||
// Gaeste: am 28.12. ist niemand buchbar
|
||
const tag = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=egal&tag=2026-11-04`)).json()
|
||
eq(tag.zeiten, [], 'in der Abwesenheit keine Zeiten')
|
||
// bearbeiten und entfernen
|
||
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Betriebsurlaub/).click()
|
||
const bearbeiten = formular(page, 'Abwesenheit bearbeiten')
|
||
await bearbeiten.getByLabel('Titel (optional)').fill('Winterpause')
|
||
await knopf(bearbeiten, 'Speichern').click()
|
||
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor()
|
||
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Winterpause/).click()
|
||
await knopf(formular(page, 'Abwesenheit bearbeiten'), 'Entfernen').click()
|
||
await knopf(page.getByRole('form', { name: 'Abwesenheit entfernen?' }), 'Ja, entfernen').click()
|
||
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor({ state: 'detached' })
|
||
eq(gespeichert().abwesenheiten.map((x) => x.titel), [''], 'nur die vergangene bleibt')
|
||
|
||
// Feiertage
|
||
const feiertage = page.getByRole('form', { name: 'Feiertage' })
|
||
eq(await feiertage.getByLabel('Bundesland').inputValue(), 'RP', 'Bundesland')
|
||
eq(await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).isChecked(), false, 'Haken wie gespeichert (Testdaten: aus)')
|
||
await feiertage.getByLabel('Bundesland').selectOption('BY')
|
||
await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).check()
|
||
await knopf(feiertage, 'Speichern').click()
|
||
await page.waitForFunction(() => document.body.textContent.includes('Feiertage gespeichert.'))
|
||
eq([gespeichert().bundesland, gespeichert().feiertageGeschlossen], ['BY', true], 'Feiertage gespeichert')
|
||
|
||
// Sonderöffnungen
|
||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||
const sonder = formular(page, 'Sonderöffnung eintragen')
|
||
await sonder.getByLabel('Tag', { exact: true }).fill('2026-10-18')
|
||
await sonder.getByLabel(/^Geöffnet von/).fill('09:00 bis 13')
|
||
await knopf(sonder, 'Sonderöffnung eintragen').click()
|
||
assert((await textVon(fehlerAm(sonder, 'zeiten'))).includes('09:00–12:30, 13:30–18:00'), 'Zeiten ungültig (am Feld)')
|
||
await sonder.getByLabel(/^Geöffnet von/).fill('09:00–13:00')
|
||
await knopf(sonder, 'Sonderöffnung eintragen').click()
|
||
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor()
|
||
eq(gespeichert().sonderoeffnungen['2026-10-18'], [['09:00', '13:00']], 'Sonderöffnung gespeichert')
|
||
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-18"] .tm-name')), 'Sonntag, 18. Oktober 2026', 'Tag')
|
||
// ein Tag ohne Zeiten = geschlossen; doppelt ⇒ Fehler
|
||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||
const zu = formular(page, 'Sonderöffnung eintragen')
|
||
await zu.getByLabel('Tag', { exact: true }).fill('2026-10-19')
|
||
await knopf(zu, 'Sonderöffnung eintragen').click()
|
||
await page.locator('#tm-panel li[data-tag="2026-10-19"]').waitFor()
|
||
eq(gespeichert().sonderoeffnungen['2026-10-19'], [], 'geschlossen = leere Liste')
|
||
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-19"] .tm-meta')), 'Geschlossen', 'Anzeige')
|
||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||
const doppelt = formular(page, 'Sonderöffnung eintragen')
|
||
await doppelt.getByLabel('Tag', { exact: true }).fill('2026-10-18')
|
||
await knopf(doppelt, 'Sonderöffnung eintragen').click()
|
||
assert((await textVon(fehlerAm(doppelt, 'datum'))).includes('schon eine Sonderöffnung'), 'doppelter Tag')
|
||
await knopf(doppelt, 'Abbrechen').click()
|
||
// Gaeste: der Sonntag nur bis 13 Uhr, 25.12. zu (Feiertag-Haken + Sonderöffnung)
|
||
const hs = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-18`)).json()
|
||
eq([hs.zeiten[0], hs.zeiten.at(-1)], ['09:00', '12:25'].map((x, i) => (i === 1 ? hs.zeiten.at(-1) : x)), 'der Sonntag ab 09:00')
|
||
assert(hs.zeiten.at(-1) < '13:00', `der Sonntag endet vor 13 Uhr: ${hs.zeiten.at(-1)}`)
|
||
// Sonderöffnung bearbeiten und entfernen
|
||
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
|
||
const sb = formular(page, 'Sonderöffnung 18.10.2026')
|
||
await sb.getByLabel(/^Geöffnet von/).fill('10:00–12:00')
|
||
await knopf(sb, 'Speichern').click()
|
||
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-tag="2026-10-18"] .tm-meta')?.textContent === '10:00–12:00')
|
||
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
|
||
await knopf(formular(page, 'Sonderöffnung 18.10.2026'), 'Entfernen').click()
|
||
await knopf(page.getByRole('form', { name: 'Sonderöffnung am 18.10.2026 entfernen?' }), 'Ja, entfernen').click()
|
||
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor({ state: 'detached' })
|
||
eq(Object.keys(gespeichert().sonderoeffnungen), ['2026-10-19'], 'nur der geschlossene Tag bleibt')
|
||
await shot(page, 'team-ausnahmen')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Kalender-Abo-Link: kopieren, neu erzeugen
|
||
await check('echt_feed_link_kopieren_und_neu', async (page, ctx) => {
|
||
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
|
||
await oeffneReiter(page, 'team')
|
||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
|
||
const link = feed.locator('.tm-link-feld')
|
||
const token0 = gespeichert().ressourcen[0].feedToken
|
||
const url0 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token0}.ics`
|
||
eq(await link.inputValue(), url0, 'Link des Servers')
|
||
eq(await link.getAttribute('readonly'), '', 'nur lesbar')
|
||
const hinweis = await textVon(feed)
|
||
assert(hinweis.includes('alle paar Stunden') && hinweis.includes('Der Link ist geheim'), `Hinweise: ${hinweis}`)
|
||
await shot(page, 'team-kalender-link')
|
||
const r0 = await fetch(url0)
|
||
assert(r0.status === 200 && (r0.headers.get('content-type') || '').startsWith('text/calendar'), `Link liefert den Kalender (${r0.status})`)
|
||
// kopieren
|
||
await knopf(feed, 'Link kopieren').click()
|
||
await feed.getByText('Der Link ist kopiert.').waitFor()
|
||
eq(await page.evaluate(() => navigator.clipboard.readText()), url0, 'Zwischenablage')
|
||
// neu erzeugen: erst Rueckfrage in der Seite, Abbrechen aendert nichts
|
||
const v0 = version()
|
||
await knopf(feed, 'Neuen Link erzeugen').click()
|
||
const frage = page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' })
|
||
assert((await textVon(frage)).includes('Der bisherige Link funktioniert danach nicht mehr.'), 'Rückfrage nennt die Folge')
|
||
await knopf(frage, 'Abbrechen').click()
|
||
eq([gespeichert().ressourcen[0].feedToken, version()], [token0, v0], 'Abbrechen ändert nichts')
|
||
await knopf(feed, 'Neuen Link erzeugen').click()
|
||
await shot(page, 'team-kalender-link-frage')
|
||
await knopf(frage, 'Neuen Link erzeugen').click()
|
||
await page.waitForFunction((alt) => document.querySelector('#tm-panel .tm-link-feld')?.value !== alt, url0)
|
||
const token1 = gespeichert().ressourcen[0].feedToken
|
||
assert(token1 !== token0 && /^[A-Za-z0-9_-]{43}$/.test(token1), 'neues Token vom Server')
|
||
const url1 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token1}.ics`
|
||
eq(await link.inputValue(), url1, 'neuer Link steht im Feld')
|
||
eq(version(), v0 + 1, 'das Erzeugen zählt die Version hoch')
|
||
eq((await fetch(url0)).status, 404, 'der alte Link gilt nicht mehr')
|
||
eq((await fetch(url1)).status, 200, 'der neue Link gilt')
|
||
assert((await textVon(feed)).includes('Der neue Link ist da. Der alte funktioniert nicht mehr.'), 'Hinweis am Link')
|
||
eq(gespeichert().ressourcen[1].feedToken.length, 43, 'Ben behält seinen Link')
|
||
// ein Speichern direkt danach kollidiert nicht mit dem Erzeugen (Version aus der Antwort)
|
||
const form = formular(page, 'Bearbeiten: Anna')
|
||
await form.getByLabel('Name', { exact: true }).fill('Anna M.')
|
||
await knopf(form, 'Speichern').click()
|
||
await eintrag(page, 'Anna M.').waitFor()
|
||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
|
||
eq([gespeichert().ressourcen[0].name, gespeichert().ressourcen[0].feedToken], ['Anna M.', token1], 'Name gespeichert, Link bleibt der neue')
|
||
})
|
||
|
||
await check('echt_feed_link_ohne_zwischenablage', async (page, ctx) => {
|
||
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
|
||
await oeffneReiter(page, 'team')
|
||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
|
||
await knopf(feed, 'Link kopieren').click()
|
||
const status = feed.locator('[role="status"]')
|
||
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
|
||
assert((await textVon(status)).includes('markiert'), 'Hinweis: Link ist markiert')
|
||
const auswahl = await page.evaluate(() => {
|
||
const el = document.activeElement
|
||
return { link: el?.classList.contains('tm-link-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
|
||
})
|
||
eq(auswahl.link, true, 'Fokus im Link-Feld')
|
||
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Link ist markiert')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Konflikt mit zweitem Tab
|
||
await check('echt_konflikt_zweiter_tab', async (page, ctx) => {
|
||
await oeffneReiter(page, 'einstellungen')
|
||
const zweiter = await ctx.newPage()
|
||
await oeffneReiter(zweiter, 'einstellungen')
|
||
// der zweite Tab speichert zuerst
|
||
await zweiter.getByLabel('Telefon (Pflicht)').fill('06301 9999')
|
||
await knopf(zweiter, 'Einstellungen speichern').click()
|
||
await zweiter.getByText('Einstellungen gespeichert.').waitFor()
|
||
eq(gespeichert().betrieb.telefon, '06301 9999', 'zweiter Tab gespeichert')
|
||
// der erste hat noch die alte Version
|
||
const v1 = version()
|
||
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
|
||
await knopf(page, 'Einstellungen speichern').click()
|
||
const box = page.locator('#tm-panel .tm-box-warn')
|
||
await box.waitFor()
|
||
assert((await textVon(box)).includes('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), `Konflikt-Text: ${await textVon(box)}`)
|
||
eq(await box.getAttribute('role'), 'alert', 'als Hinweis ausgezeichnet')
|
||
eq([gespeichert().betrieb.name, version()], ['Salon Müller', v1], 'nichts überschrieben')
|
||
await shot(page, 'konflikt')
|
||
// neu laden holt den Stand des anderen Tabs, danach geht das Speichern
|
||
await knopf(box, 'Neu laden').click()
|
||
await box.waitFor({ state: 'hidden' })
|
||
eq(await page.getByLabel('Telefon (Pflicht)').inputValue(), '06301 9999', 'Stand des anderen Tabs')
|
||
eq(await page.getByLabel('Name des Betriebs', { exact: true }).inputValue(), 'Salon Müller', 'eigene ungespeicherte Änderung ist weg')
|
||
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
|
||
await knopf(page, 'Einstellungen speichern').click()
|
||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||
eq([gespeichert().betrieb.name, gespeichert().betrieb.telefon], ['Salon Müller & Söhne', '06301 9999'], 'beide Änderungen da')
|
||
|
||
// dasselbe im Reiter Leistungen: der zweite Tab benennt eine Leistung um, der erste hat die alte Version
|
||
await page.locator('[data-reiter="leistungen"]').click()
|
||
await page.locator('#tm-panel li[data-id="s_herren01"]').waitFor()
|
||
await zweiter.reload() // der zweite Tab ist seit dem Speichern des ersten veraltet: frisch laden
|
||
await oeffneReiter(zweiter, 'leistungen')
|
||
await knopf(zweiter.locator('#tm-panel li[data-id="s_herren01"]'), 'Bearbeiten: Herrenschnitt').click()
|
||
const f2 = zweiter.getByRole('form', { name: 'Leistung bearbeiten: Herrenschnitt' })
|
||
await f2.getByLabel('Name', { exact: true }).fill('Herrenschnitt klassisch')
|
||
await knopf(f2, 'Speichern').click()
|
||
await zweiter.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
|
||
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
|
||
const f1 = formular(page, 'Leistung bearbeiten: Kinderschnitt')
|
||
await f1.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
|
||
await knopf(f1, 'Speichern').click()
|
||
await page.locator('#tm-panel .tm-box-warn:not([hidden])').waitFor()
|
||
eq(gespeichert().leistungen.find((l) => l.id === S_KINDER).dauer, 30, 'Kinderschnitt unverändert')
|
||
await knopf(page.locator('#tm-panel .tm-box-warn'), 'Neu laden').click()
|
||
await page.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
|
||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'Hinweis weg')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Feldfehler am Feld (javascript:-URL, Zahl, Name)
|
||
await check('echt_einstellungen_feldfehler', async (page) => {
|
||
await oeffneReiter(page, 'einstellungen')
|
||
const form = page.getByRole('form', { name: 'Einstellungen' })
|
||
const url = form.getByLabel('Link zum Impressum', { exact: true })
|
||
const v0 = version()
|
||
await url.fill('javascript:alert(1)')
|
||
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('5')
|
||
await form.getByLabel('Name des Betriebs', { exact: true }).fill('')
|
||
await knopf(form, 'Einstellungen speichern').click()
|
||
const fehlerUrl = fehlerAm(form, 'betrieb.impressumUrl')
|
||
await fehlerUrl.filter({ hasText: 'Bitte gib eine vollständige Adresse mit https:// an' }).waitFor()
|
||
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.', 'Zahl außerhalb: Text des Servers am Feld')
|
||
eq(await textVon(fehlerAm(form, 'betrieb.name')), 'Bitte gib den Namen deines Betriebs an.', 'Name fehlt (Server)')
|
||
eq(await url.getAttribute('aria-invalid'), 'true', 'URL-Feld markiert')
|
||
eq(await page.evaluate(() => document.activeElement?.dataset.feld), 'betrieb.name', 'Fokus auf dem ersten fehlerhaften Feld (Reihenfolge der Seite)')
|
||
eq([version(), gespeichert().betrieb.impressumUrl], [v0, 'https://salon-mueller.example/impressum'], 'nichts gespeichert')
|
||
await shot(page, 'einstellungen-feldfehler')
|
||
// die Adresse darf nie als Link oder Skript landen: gespeichert ist sie auch nicht
|
||
eq(await page.locator('#tm-panel a[href^="javascript:"]').count(), 0, 'kein javascript:-Link in der Seite')
|
||
// eine leere Zahl wird schon im Feld erkannt
|
||
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('')
|
||
await knopf(form, 'Einstellungen speichern').click()
|
||
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Bitte gib eine ganze Zahl an.', 'leere Zahl')
|
||
// alles korrigieren: die Fehler verschwinden mit dem Speichern
|
||
await url.fill('https://salon-mueller.example/impressum-neu')
|
||
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('120')
|
||
await form.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller')
|
||
await knopf(form, 'Einstellungen speichern').click()
|
||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||
eq(await page.locator('#tm-panel .tm-fehler:not(:empty)').count(), 0, 'keine Fehlertexte mehr')
|
||
eq([gespeichert().betrieb.impressumUrl, gespeichert().aufbewahrungTage], ['https://salon-mueller.example/impressum-neu', 120], 'gespeichert')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Einstellungen: alle Abschnitte
|
||
await check('echt_einstellungen_speichern', async (page) => {
|
||
await oeffneReiter(page, 'einstellungen')
|
||
const form = page.getByRole('form', { name: 'Einstellungen' })
|
||
const feld = (label) => form.getByLabel(label, { exact: true })
|
||
// vorbefuellt aus der gespeicherten Einstellung
|
||
eq([await feld('Name des Betriebs').inputValue(), await feld('E-Mail des Betriebs').inputValue(), await feld('Telefon (Pflicht)').inputValue()], ['Salon Müller', 'info@salon-mueller.example', '06301 1234'], 'Betrieb vorbefüllt')
|
||
eq(await feld('Akzentfarbe').inputValue(), '#0f766e', 'Farbe')
|
||
eq(await feld('Selbst absagen bis (Stunden vorher)').inputValue(), '24', 'Regeln')
|
||
// Hinweise der Spezifikation
|
||
const text = await textVon(page.locator('#tm-panel'))
|
||
assert(text.includes('Der Widerruf schlägt die Stornofrist.'), 'Hinweis: Widerruf schlägt Stornofrist')
|
||
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
|
||
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
|
||
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
|
||
const online = form.getByRole('radio', { name: /Online bezahlen/ })
|
||
eq(await online.isDisabled(), true, 'Online bezahlen ausgegraut')
|
||
assert((await textVon(page.locator('.tm-optionen'))).includes('kommt bald'), '„kommt bald“')
|
||
eq(await form.getByRole('radio', { name: /Vor Ort bezahlen/ }).isChecked(), true, 'vor Ort gewählt')
|
||
eq(await page.getByRole('link', { name: /CSV/ }).getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
|
||
const csv = await page.evaluate(async () => { const r = await fetch('/api/termine/export.csv?projectId=p1'); return { status: r.status, typ: r.headers.get('content-type') } })
|
||
assert(csv.status === 200 && csv.typ.startsWith('text/csv'), `Export liefert CSV: ${JSON.stringify(csv)}`)
|
||
await shot(page, 'einstellungen')
|
||
|
||
// aendern
|
||
const stornoVorher = gespeichert().storno.version
|
||
await feld('Telefon (Pflicht)').fill('06301 9999')
|
||
await feld('Wie sprichst du deine Gäste an?').selectOption('du')
|
||
await feld('Akzentfarbe').fill('#112233')
|
||
await feld('Startzeiten alle').selectOption('30')
|
||
await feld('Mindestvorlauf (Minuten)').fill('60')
|
||
await feld('Buchungszeitraum (Tage)').fill('90')
|
||
await feld('Selbst absagen bis (Stunden vorher)').fill('12')
|
||
await feld('Offene Termine je E-Mail-Adresse').fill('5')
|
||
await form.getByLabel(/Gäste dürfen die Person oder den Platz selbst wählen/).uncheck()
|
||
await feld('Telefonnummer des Gastes').selectOption('pflicht')
|
||
await feld('Nachricht des Gastes').selectOption('optional')
|
||
await knopf(form, '+ Zusatzfeld').click()
|
||
await knopf(form, '+ Zusatzfeld').click()
|
||
const zeilen = form.locator('.tm-zusatz-zeile')
|
||
await zeilen.nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe')
|
||
await zeilen.nth(1).getByLabel('Beschriftung', { exact: true }).fill('Haarlänge')
|
||
eq(await zeilen.nth(0).getByLabel('Auswahlmöglichkeiten').isVisible(), false, 'Auswahl nur bei „Auswahl aus Liste“')
|
||
await zeilen.nth(1).getByLabel('Art', { exact: true }).selectOption('auswahl')
|
||
await zeilen.nth(1).getByLabel('Auswahlmöglichkeiten').fill('kurz\nmittel\nlang')
|
||
await zeilen.nth(1).getByLabel('Pflichtfeld').check()
|
||
await feld('Die Erinnerung kommt').selectOption('12')
|
||
await knopf(form, 'Muster in das Textfeld übernehmen').click()
|
||
assert((await feld('Dein Text').inputValue()).includes('bis 12 Stunden vorher kostenlos absagen'), 'Muster mit der Frist aus dem Feld darüber')
|
||
await feld('Deine Gäste sind').selectOption('gemischt')
|
||
await form.getByLabel('Kreditkarte', { exact: true }).uncheck()
|
||
await form.getByLabel('PayPal', { exact: true }).check()
|
||
await feld('Aufbewahrung (Tage)').fill('120')
|
||
const v0 = version()
|
||
await knopf(form, 'Einstellungen speichern').click()
|
||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||
eq(version(), v0 + 1, 'ein Speichern')
|
||
const e = gespeichert()
|
||
eq(e.betrieb.telefon, '06301 9999', 'Telefon')
|
||
eq([e.anrede, e.farbe], ['du', '#112233'], 'Anrede, Farbe')
|
||
eq(e.regeln, { raster: 30, vorlaufMin: 60, horizontTage: 90, absageBisStd: 12, maxOffeneJeEmail: 5, personWaehlbar: false }, 'Regeln')
|
||
eq(e.gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [
|
||
{ key: 'wunschfarbe', label: 'Wunschfarbe', typ: 'text', optionen: [], pflicht: false },
|
||
{ key: 'haarlaenge', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
|
||
] }, 'Gastfelder')
|
||
eq(e.erinnerung, { anbieten: true, stundenVorher: 12 }, 'Erinnerung')
|
||
assert(e.storno.text.includes('bis 12 Stunden vorher') && e.storno.version !== stornoVorher, `Storno mit neuer Fassung: ${e.storno.version}`)
|
||
eq(e.recht, { gaesteSind: 'gemischt', freizeit: false }, 'Rechtliches')
|
||
eq(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'paypal'] }, 'Zahlung')
|
||
eq(e.aufbewahrungTage, 120, 'Aufbewahrung')
|
||
// Gaeste sehen es
|
||
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
|
||
eq([conf.anrede, conf.zahlung.text, conf.gastFelder.zusatz.length, conf.gastFelder.telefon], ['du', 'Bezahlung vor Ort: bar, EC-Karte, PayPal', 2, 'pflicht'], 'öffentliche Config')
|
||
// die Seite zeigt den gespeicherten Stand; ein Zusatzfeld bleibt bei einer Umbenennung unter demselben Schluessel
|
||
eq(await form.locator('.tm-zusatz-zeile').count(), 2, 'zwei Zusatzfelder')
|
||
await form.locator('.tm-zusatz-zeile').nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe der Haare')
|
||
await knopf(form, 'Einstellungen speichern').click()
|
||
await page.waitForFunction(() => document.querySelector('[data-zusatz="0"] input')?.value === 'Wunschfarbe der Haare')
|
||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||
eq(gespeichert().gastFelder.zusatz.map((z) => [z.key, z.label]), [['wunschfarbe', 'Wunschfarbe der Haare'], ['haarlaenge', 'Haarlänge']], 'Schlüssel bleibt')
|
||
// Zusatzfeld entfernen
|
||
await knopf(form, 'Zusatzfeld entfernen: Wunschfarbe der Haare').click()
|
||
eq(gespeichert().gastFelder.zusatz.length, 2, 'bis zum Speichern bleibt alles')
|
||
await knopf(form, 'Einstellungen speichern').click()
|
||
await warteBis(() => gespeichert().gastFelder.zusatz.length === 1, 'Zusatzfeld entfernt')
|
||
eq(gespeichert().gastFelder.zusatz.map((z) => z.key), ['haarlaenge'], 'eins entfernt')
|
||
})
|
||
|
||
// ------------------------------------------------------------ Telefon und Anschrift fehlen (Entwurf aus Vorlage, Ruling R41)
|
||
await check('echt_pflichtangaben_aus_vorlage', async (page) => {
|
||
await oeffne(page, '#termine')
|
||
await warteAnsicht(page)
|
||
await page.getByLabel('Friseur und Salon').check()
|
||
await knopf(page, 'Mit dieser Vorlage starten').click()
|
||
await page.locator('.tm-reiterleiste').waitFor()
|
||
const leiste = page.locator('#termine-body .tm-hinweise .tm-box-warn')
|
||
assert((await textVon(leiste)).includes('Noch nicht buchbar'), 'Hinweisleiste')
|
||
// Speichern in Leistungen: der Server verlangt zuerst die Angaben, der Hinweis sagt wo
|
||
await page.locator('[data-reiter="leistungen"]').click()
|
||
const zeile = page.locator('#tm-panel li.tm-eintrag').first()
|
||
await knopf(zeile, /^Bearbeiten:/).click()
|
||
const form = page.locator('#tm-panel form').first()
|
||
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
|
||
await knopf(form, 'Speichern').click()
|
||
const fehler = form.locator('[data-fehler="_"]')
|
||
await fehler.filter({ hasText: 'Telefonnummer' }).waitFor()
|
||
assert((await textVon(fehler)).includes('Das änderst du im Reiter „Einstellungen“.'), `Hinweis auf den Reiter: ${await textVon(fehler)}`)
|
||
// Einstellungen: Hinweis in der Karte und Felder fuellen
|
||
await page.locator('[data-reiter="einstellungen"]').click()
|
||
const einstellungen = page.getByRole('form', { name: 'Einstellungen' })
|
||
const warnung = page.locator('#tm-panel .tm-abschnitt .tm-box-warn')
|
||
eq(await textVon(warnung), 'Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: deine Telefonnummer und deine Anschrift.', 'Hinweis in den Einstellungen')
|
||
await shot(page, 'einstellungen-pflichtangaben')
|
||
await einstellungen.getByLabel('Telefon (Pflicht)').fill('06301 555')
|
||
await knopf(einstellungen, 'Einstellungen speichern').click()
|
||
await fehlerAm(einstellungen, 'betrieb.adresse').filter({ hasText: 'Bitte gib die Anschrift deines Betriebs an.' }).waitFor()
|
||
await einstellungen.getByLabel('Anschrift', { exact: true }).fill('Hauptstr. 1, 67722 Winnweiler')
|
||
await knopf(einstellungen, 'Einstellungen speichern').click()
|
||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||
// der Hinweis oben verschwindet sofort, ohne die Seite neu zu laden
|
||
eq(await page.locator('#termine-body .tm-hinweise .tm-box-warn').count(), 0, 'Hinweisleiste weg')
|
||
eq(await page.locator('#tm-panel .tm-abschnitt .tm-box-warn').count(), 0, 'Hinweis in der Karte weg')
|
||
eq([gespeichert().betrieb.telefon, gespeichert().betrieb.adresse], ['06301 555', 'Hauptstr. 1, 67722 Winnweiler'], 'gespeichert')
|
||
// jetzt geht das Speichern in Leistungen
|
||
await page.locator('[data-reiter="leistungen"]').click()
|
||
await knopf(page.locator('#tm-panel li.tm-eintrag').first(), /^Bearbeiten:/).click()
|
||
const form2 = page.locator('#tm-panel form').first()
|
||
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
|
||
await knopf(form2, 'Speichern').click()
|
||
await page.getByText('Leistung gespeichert.').waitFor()
|
||
eq(gespeichert().leistungen[0].dauer, 40, 'Dauer gespeichert')
|
||
}, { reset: { ohneEinstellung: true } })
|
||
|
||
// ------------------------------------------------------------ Abgeschaltet: alle neuen Reiter nur lesen
|
||
await check('echt_abgeschaltet_reiter_nur_lesen', async (page) => {
|
||
await srv.setzeFunktion('disabled')
|
||
const v0 = version()
|
||
const alleGesperrt = (loc) => loc.locator('input, select, textarea').evaluateAll((els) => els.length > 0 && els.every((e) => e.disabled || e.closest('fieldset[disabled]')))
|
||
// Leistungen
|
||
await oeffneReiter(page, 'leistungen')
|
||
eq(await knopf(page, '+ Leistung anlegen').count(), 0, 'Leistungen: kein Anlegen')
|
||
eq(await page.locator('#tm-panel [data-aktion="hoch"], #tm-panel [data-aktion="runter"]').count(), 0, 'Leistungen: keine Reihenfolge')
|
||
await knopf(eintrag(page, 'Herrenschnitt'), 'Ansehen: Herrenschnitt').click()
|
||
const leistung = formular(page, 'Leistung: Herrenschnitt')
|
||
eq(await alleGesperrt(leistung), true, 'Leistungen: Felder gesperrt')
|
||
eq(await leistung.getByLabel('Name', { exact: true }).inputValue(), 'Herrenschnitt', 'Leistungen: Werte stehen da')
|
||
eq(await leistung.getByRole('button').allInnerTexts(), ['Schließen'], 'Leistungen: nur Schließen')
|
||
await shot(page, 'abgeschaltet-leistungen')
|
||
await knopf(leistung, 'Schließen').click()
|
||
// Team
|
||
await page.locator('[data-reiter="team"]').click()
|
||
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
|
||
for (const knopfText of ['+ Person oder Platz hinzufügen', '+ Abwesenheit eintragen', '+ Sonderöffnung eintragen']) eq(await knopf(page, knopfText).count(), 0, `Team: ${knopfText} fehlt`)
|
||
eq(await alleGesperrt(page.getByRole('form', { name: 'Feiertage' })), true, 'Team: Feiertage gesperrt')
|
||
eq(await knopf(page.getByRole('form', { name: 'Feiertage' }), 'Speichern').count(), 0, 'Team: kein Speichern bei den Feiertagen')
|
||
await knopf(zeileMitId(page, R_ANNA), 'Ansehen: Anna').click()
|
||
const anna = formular(page, 'Person oder Platz: Anna')
|
||
eq(await alleGesperrt(anna), true, 'Team: Person gesperrt')
|
||
eq(await anna.getByLabel('Montag', { exact: true }).inputValue(), '09:00–12:30, 13:30–18:00', 'Team: Wochenplan sichtbar')
|
||
eq(await anna.getByRole('button').allInnerTexts(), ['Schließen'], 'Team: nur Schließen')
|
||
eq(await page.locator('#tm-panel .tm-feed').count(), 0, 'Team: kein Kalender-Link (Server liefert keine)')
|
||
await shot(page, 'abgeschaltet-team')
|
||
// Einstellungen
|
||
await page.locator('[data-reiter="einstellungen"]').click()
|
||
const form = page.getByRole('form', { name: 'Einstellungen' })
|
||
await form.waitFor()
|
||
eq(await alleGesperrt(form), true, 'Einstellungen: alle Felder gesperrt')
|
||
eq(await knopf(page, 'Einstellungen speichern').count(), 0, 'Einstellungen: kein Speichern')
|
||
eq(await knopf(form, 'Muster in das Textfeld übernehmen').count() + await knopf(form, '+ Zusatzfeld').count(), 0, 'Einstellungen: keine Änderungsknöpfe')
|
||
eq(await page.getByRole('link', { name: /CSV/ }).count(), 1, 'Einstellungen: Export bleibt')
|
||
assert((await textVon(page.locator('#tm-panel .tm-status'))).includes('Abgeschaltet'), 'Einstellungen: Status')
|
||
await shot(page, 'abgeschaltet-einstellungen')
|
||
// die Schnittstelle verweigert Schreiben auch direkt
|
||
const status = await page.evaluate(async ([projectId, v, einstellung]) => (await fetch('/api/termine/einstellungen', {
|
||
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId, version: v, einstellung }),
|
||
})).status, ['p1', v0, gespeichert()])
|
||
eq(status, 409, 'Server: Speichern bei abgeschalteter Funktion')
|
||
eq(version(), v0, 'nichts gespeichert')
|
||
})
|
||
|
||
// ------------------------------------------------------------ App-Ansicht 390 px: die drei neuen Reiter
|
||
await check('echt_390_leistungen_team_einstellungen', async (page) => {
|
||
await oeffne(page, '#funktionen')
|
||
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
|
||
await warteAnsicht(page)
|
||
// jeder Knopf, jedes Auswahlfeld und jede Zeile zum Ankreuzen ist mindestens 44 px hoch (Touch)
|
||
const kleineZiele = () => page.evaluate(() => [...document.querySelectorAll('#tm-panel button, #tm-panel select, #tm-panel textarea, #tm-panel label.tm-haken, #tm-panel input:not([type=checkbox]):not([type=radio])')]
|
||
.filter((e) => e.offsetParent !== null)
|
||
.map((e) => [e.tagName.toLowerCase(), (e.textContent.trim() || e.getAttribute('aria-label') || e.className).slice(0, 40), Math.round(e.getBoundingClientRect().height)])
|
||
.filter(([, , hoehe]) => hoehe < 44))
|
||
const pruefe = async (was) => {
|
||
eq(await passtInBreite(page), true, `${was}: kein Streifen rechts`)
|
||
eq(await kleineZiele(), [], `${was}: Touch-Ziele mindestens 44 px`)
|
||
}
|
||
// Leistungen
|
||
await page.locator('[data-reiter="leistungen"]').click()
|
||
await page.locator('#tm-panel li.tm-eintrag').first().waitFor()
|
||
await pruefe('Leistungen')
|
||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Reiter markiert')
|
||
await shot(page, '390-8-leistungen')
|
||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||
await formular(page, 'Leistung bearbeiten: Herrenschnitt').waitFor()
|
||
await pruefe('Leistung bearbeiten')
|
||
await zeileMitId(page, S_HERREN).scrollIntoViewIfNeeded()
|
||
await shot(page, '390-9-leistung-bearbeiten')
|
||
// Team & Plaetze
|
||
await page.locator('[data-reiter="team"]').click()
|
||
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
|
||
await pruefe('Team')
|
||
await shot(page, '390-10-team')
|
||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||
await zeileMitId(page, R_ANNA).locator('.tm-feed').waitFor()
|
||
await pruefe('Person bearbeiten mit Kalender-Link')
|
||
await knopf(zeileMitId(page, R_ANNA), 'Neuen Link erzeugen').click()
|
||
await page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }).waitFor()
|
||
await pruefe('Rückfrage zum Kalender-Link')
|
||
await zeileMitId(page, R_ANNA).scrollIntoViewIfNeeded()
|
||
await shot(page, '390-11-team-bearbeiten')
|
||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||
await formular(page, 'Sonderöffnung eintragen').waitFor()
|
||
await knopf(page, '+ Abwesenheit eintragen').click()
|
||
await formular(page, 'Abwesenheit eintragen').waitFor()
|
||
await pruefe('Formulare für Ausnahmen')
|
||
// Einstellungen
|
||
await page.locator('[data-reiter="einstellungen"]').click()
|
||
await page.getByRole('form', { name: 'Einstellungen' }).waitFor()
|
||
await pruefe('Einstellungen')
|
||
await knopf(page, '+ Zusatzfeld').click()
|
||
await pruefe('Einstellungen mit Zusatzfeld')
|
||
await shot(page, '390-12-einstellungen')
|
||
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'App-Ansicht Terminplaner')
|
||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||
|
||
// ------------------------------------------------------------ Meldung kommt nach dem Wechsel der Website (Fixture, Ruling R50)
|
||
await check('fx_meldung_nach_projektwechsel', async (page) => {
|
||
const fx = page.fx
|
||
await oeffne(page, '#termine')
|
||
await warteAnsicht(page)
|
||
const zeile = reihe(page, 'Lea Braun')
|
||
await knopf(zeile, 'Annehmen').click()
|
||
await knopf(zeile.locator('.tm-frage-box'), 'Annehmen').click()
|
||
// die Antwort braucht 1,2 s; in der Zwischenzeit zur anderen Website wechseln
|
||
await page.locator('#termine-project').selectOption('p2')
|
||
await page.locator('.tm-reiterleiste').waitFor()
|
||
await page.waitForFunction(() => document.querySelector('#termine-project')?.value === 'p2')
|
||
await page.waitForTimeout(2000)
|
||
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 1, 'die Aktion wurde abgeschickt')
|
||
eq(await meldung(page).count(), 0, 'keine Meldung der alten Website unter der neuen')
|
||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl p2')
|
||
}, {
|
||
fixture: (() => {
|
||
const fx = neueFixture()
|
||
fx.projekte = [
|
||
{ projectId: 'p1', projectName: 'Erste Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||
{ projectId: 'p2', projectName: 'Zweite Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||
]
|
||
fx.buchungen = alleStatus()
|
||
fx.aktionVerzoegerung = 1200
|
||
return fx
|
||
})(),
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Lauf
|
||
|
||
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)
|