Terminplaner: Portal-Ansicht Einstellungen – Nachbesserungen

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

No files matched your search

+120 -6
View File
@@ -232,6 +232,12 @@ async function warteBis(bedingung, text) {
}
throw new Error(`Zeit abgelaufen: ${text}`)
}
/** Zaehlt die PATCH-Aufrufe der Seite auf die Einstellungen (Liste der Adressen). */
const zaehlePatches = (seite) => {
const liste = []
seite.on('request', (r) => { if (r.method() === 'PATCH' && r.url().includes('/api/termine/einstellungen')) liste.push(r.url()) })
return liste
}
const zeileMitId = (page, id) => page.locator(`#tm-panel li[data-id="${id}"]`)
const eintrag = (page, text) => page.locator('#tm-panel li.tm-eintrag', { hasText: text })
const formular = (page, name) => page.getByRole('form', { name })
@@ -1107,8 +1113,9 @@ async function pruefungen() {
// ============================================================ Aufgabe 21b: Leistungen, Team & Plätze, Einstellungen (echte Router)
// ------------------------------------------------------------ Leistungen: anlegen, bearbeiten, Reihenfolge
await check('echt_leistung_anlegen_bearbeiten_reihenfolge', async (page) => {
const patches = zaehlePatches(page)
await oeffneReiter(page, 'leistungen')
assert((await textVon(page.locator('#tm-panel .tm-box-info'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
assert((await textVon(page.locator('#tm-panel .tm-box-info[role="note"]'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts(), ['Herrenschnitt', 'Damenschnitt', 'Kinderschnitt', 'Färben', 'Kennenlern-Beratung', 'Brautfrisur'], 'Reihenfolge wie gespeichert')
const meta = await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())
assert(['30 Min.', '5 Min. Pause danach', '28,00 €', 'Sofort buchbar', 'Anna, Ben'].every((t) => meta.includes(t)), `Kurzfassung: ${meta}`)
@@ -1162,14 +1169,36 @@ async function pruefungen() {
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
// Reihenfolge (Ruling R51): die Pfeile aendern nur eine gemerkte Reihenfolge, gespeichert wird sie in EINEM Speichern
const leiste = page.locator('#tm-panel .tm-reihenfolge')
eq(await leiste.isHidden(), true, 'ohne Aenderung keine Leiste')
const patchesVorher = patches.length
const vR = version()
await knopf(page, 'Nach oben: Damenschnitt').click()
await 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')
await leiste.waitFor()
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').first().innerText(), 'Damenschnitt', 'Anzeige geändert')
const stand = await textVon(leiste.locator('[role="status"]'))
assert(stand.includes('Stelle 1 von 7') && stand.includes('noch nicht gespeichert'), `Stand der Reihenfolge: ${stand}`)
eq([gespeichert().leistungen[0].name, version(), patches.length], ['Herrenschnitt', vR, patchesVorher], 'kein Speichern durch den Pfeil')
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), true, 'ganz oben: nicht weiter hoch')
eq(await knopf(page, 'Nach unten: Bartpflege').isDisabled(), true, 'ganz unten: nicht weiter runter')
// der Fokus bleibt in der verschobenen Zeile (kein Verlust nach dem Neuzeichnen)
// der Fokus bleibt in der verschobenen Zeile
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
await shot(page, 'leistungen-reihenfolge')
// verwerfen: alles wie gespeichert
await knopf(leiste, 'Verwerfen').click()
await leiste.waitFor({ state: 'hidden' })
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').first().innerText(), 'Herrenschnitt', 'verworfen')
eq([version(), patches.length], [vR, patchesVorher], 'verwerfen speichert nichts')
// zwei Aenderungen der Reihenfolge, ein Speichern
await knopf(page, 'Nach oben: Damenschnitt').click()
await knopf(page, 'Nach oben: Kinderschnitt').click()
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts().then((l) => l.slice(0, 3)), ['Damenschnitt', 'Kinderschnitt', 'Herrenschnitt'], 'Anzeige')
await knopf(leiste, 'Reihenfolge speichern').click()
await leiste.waitFor({ state: 'hidden' })
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 3), [['Damenschnitt', 1], ['Kinderschnitt', 2], ['Herrenschnitt', 3]], 'Reihenfolge gespeichert')
eq([version(), patches.length], [vR + 1, patchesVorher + 1], 'ein Speichern für beide Änderungen')
assert((await textVon(meldung(page))).includes('Reihenfolge gespeichert.'), 'Meldung')
// Deaktivieren (Leistung bleibt gespeichert), Gaeste sehen sie nicht mehr
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
@@ -1225,6 +1254,83 @@ async function pruefungen() {
assert((await textVon(meldung(page))).includes('Leistung „Kinderschnitt“ entfernt.'), 'Meldung')
})
// ------------------------------------------------------------ Reihenfolge-Entwurf und Bearbeiten: Eintraege per ID, nicht per Stelle (R51)
await check('echt_reihenfolge_entwurf_und_bearbeiten', async (page) => {
const patches = zaehlePatches(page)
await oeffneReiter(page, 'leistungen')
// ein offener Editor mit ungespeicherter Eingabe ueberlebt eine Aenderung der Reihenfolge
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
await kind.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
await knopf(page, 'Nach oben: Damenschnitt').click()
eq(await kind.getByLabel('Dauer (Minuten)', { exact: true }).inputValue(), '25', 'offener Editor bleibt mit seiner Eingabe')
await knopf(kind, 'Abbrechen').click()
// Anzeige: Damenschnitt, Herrenschnitt, ... ; gespeichert ist Herrenschnitt zuerst (Stelle 0), Damenschnitt an Stelle 1
const herren = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
await herren.getByLabel('Name', { exact: true }).fill('')
const vorher = version()
await knopf(herren, 'Speichern').click()
// Fehler des Servers heissen nach der gespeicherten Stelle (leistungen.0) und stehen im Formular der richtigen Leistung
await fehlerAm(herren, 'leistungen.0.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
eq([patches.length, version()], [1, vorher], 'abgelehnt, nichts gespeichert')
await herren.getByLabel('Name', { exact: true }).fill('Herrenschnitt neu')
await herren.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
await knopf(herren, 'Speichern').click()
await warteBis(() => gespeichert().leistungen[0].name === 'Herrenschnitt neu', 'Herrenschnitt gespeichert')
const e = gespeichert()
eq([e.leistungen[0].name, e.leistungen[0].dauer, e.leistungen[1].name, e.leistungen[1].dauer], ['Herrenschnitt neu', 35, 'Damenschnitt', 45], 'die richtige Leistung wurde geändert, die Reihenfolge ist unverändert')
// die gemerkte Reihenfolge ueberlebt das Speichern einer Leistung (Anzeige und Leiste)
await page.locator('#tm-panel .tm-reihenfolge').waitFor()
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts().then((l) => l.slice(0, 2)), ['Damenschnitt', 'Herrenschnitt neu'], 'Anzeige bleibt')
await knopf(page, 'Reihenfolge speichern').click()
await page.locator('#tm-panel .tm-reihenfolge').waitFor({ state: 'hidden' })
eq(gespeichert().leistungen.map((l) => l.name).slice(0, 2), ['Damenschnitt', 'Herrenschnitt neu'], 'Reihenfolge nach IDs auf den aktuellen Stand angewendet')
eq(gespeichert().leistungen.find((l) => l.id === S_HERREN).dauer, 35, 'die Änderung ging nicht verloren')
})
// ------------------------------------------------------------ Nur ein Speichern gleichzeitig
await check('echt_speichern_nur_ein_vorgang', async (page) => {
const patches = zaehlePatches(page)
// jede Antwort auf ein Speichern braucht 0,9 s: so lange ist der Reiter beschaeftigt
await page.route('**/api/termine/einstellungen', async (route) => {
if (route.request().method() === 'PATCH') await new Promise((fertig) => { setTimeout(fertig, 900) })
await route.fallback()
})
await oeffneReiter(page, 'leistungen')
const tafel = page.locator('#tm-panel')
const freieKnoepfe = () => page.locator('#tm-panel .tm-tab button:not(:disabled)').count()
const v0 = version()
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
await knopf(form, 'Speichern').click()
await tafel.and(page.locator('[aria-busy="true"]')).waitFor()
eq(await freieKnoepfe(), 0, 'beschäftigt: alle Knöpfe des Reiters gesperrt')
await shot(page, 'leistungen-beschaeftigt')
// ein zweites Absenden (z. B. Enter im Formular) wird abgewiesen, nicht mit alter Version gesendet
await form.evaluate((f) => f.requestSubmit())
await form.evaluate((f) => f.requestSubmit())
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
eq(patches.length, 1, 'genau ein PATCH')
eq(version(), v0 + 1, 'genau ein Speichern')
eq(gespeichert().leistungen[0].dauer, 35, 'Wirkung da')
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
assert((await freieKnoepfe()) > 5, 'danach sind die Knöpfe wieder frei')
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), false, 'frei wie vorher (Pfeil nicht am Rand)')
eq(await knopf(page, 'Nach oben: Herrenschnitt').isDisabled(), true, 'am Rand bleibt gesperrt')
// Doppelklick auf Speichern: ein Speichern, kein Konflikt
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
const form2 = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
await knopf(form2, 'Speichern').click({ clickCount: 2, delay: 20 })
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
await warteBis(() => gespeichert().leistungen[0].dauer === 40, 'zweites Speichern')
eq([patches.length, version()], [2, v0 + 2], 'der Doppelklick sendet einmal')
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
assert((await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())).includes('40 Min.'), 'Wirkung sichtbar')
})
// ------------------------------------------------------------ Team & Plätze: Wochenplan ändern, neuer Platz mit Zuordnung
await check('echt_team_wochenplan_und_neuer_platz', async (page) => {
await oeffneReiter(page, 'team')
@@ -1374,6 +1480,7 @@ async function pruefungen() {
eq([gespeichert().bundesland, gespeichert().feiertageGeschlossen], ['BY', true], 'Feiertage gespeichert')
// Sonderöffnungen
assert((await textVon(page.locator('#tm-panel .tm-abschnitt', { hasText: 'Sonderöffnungen' }).locator('.tm-abschnitt-text'))).endsWith('und geht dem Wochenplan und den Feiertagen vor.'), 'Satz: „geht … vor“')
await knopf(page, '+ Sonderöffnung eintragen').click()
const sonder = formular(page, 'Sonderöffnung eintragen')
await sonder.getByLabel('Tag', { exact: true }).fill('2026-10-18')
@@ -1579,7 +1686,9 @@ async function pruefungen() {
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')
// Ruling R52: Wortlaut der rechtlichen Hinweise
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind und du eine Leistung mit sofortiger Buchung anbietest.', 'Hinweis: Widerruf schlägt Stornoregel')
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
@@ -1768,6 +1877,11 @@ async function pruefungen() {
await pruefe('Leistungen')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Reiter markiert')
await shot(page, '390-8-leistungen')
await knopf(page, 'Nach oben: Damenschnitt').click()
await page.locator('#tm-panel .tm-reihenfolge').waitFor()
await pruefe('Leistungen mit Reihenfolge-Leiste')
await shot(page, '390-8b-leistungen-reihenfolge')
await knopf(page.locator('#tm-panel .tm-reihenfolge'), 'Verwerfen').click()
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
await formular(page, 'Leistung bearbeiten: Herrenschnitt').waitFor()
await pruefe('Leistung bearbeiten')