Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
650919b022
commit
0b75b31009
15 files changed
+2479
-79
No files matched your search
+775
-17
@@ -7,13 +7,13 @@
|
||||
// - "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten; der Server liefert nur dashboard.html und die Dateien aus public/.
|
||||
// - "echt_*": die echten Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite; nur die uebrigen Portal-Schnittstellen
|
||||
// (/api/auth/me usw.) mockt page.route. Portal-Sitzung per Cookie (srv.portalCookie()).
|
||||
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln).
|
||||
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
|
||||
// Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
startTermineUiServer, salonEingabe,
|
||||
R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_ANFRAGE,
|
||||
R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_KINDER, S_ANFRAGE,
|
||||
} from './termineTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
@@ -151,6 +151,7 @@ async function fixtureAntwort(fx, route, req, url, json) {
|
||||
if (aktion && m === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
fx.aufrufe.push(['aktion', aktion[1], body])
|
||||
if (fx.aktionVerzoegerung) await new Promise((fertig) => { setTimeout(fertig, fx.aktionVerzoegerung) })
|
||||
const b = fx.buchungen.find((x) => x.id === aktion[1])
|
||||
if (b.gastName === 'Konflikt Test') return json({ error: 'Dieser Termin ist nicht mehr offen.', code: 'nicht_moeglich', fehler: { _: 'Dieser Termin ist nicht mehr offen.' } }, 409)
|
||||
if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
|
||||
@@ -176,14 +177,15 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
|
||||
await ctx.clock.setFixedTime(jetzt)
|
||||
if (!fixture) await ctx.addCookies([await srv.portalCookie()])
|
||||
const page = await ctx.newPage()
|
||||
page.fx = fixture
|
||||
page.setDefaultTimeout(8000)
|
||||
ctx.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
page.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
|
||||
page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
|
||||
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
|
||||
await page.route('**/api/**', async (route) => {
|
||||
// jede Seite des Kontexts (auch ein zweiter Tab, ctx.newPage()) wird beobachtet und beantwortet die Schnittstellen gleich
|
||||
ctx.on('page', (seite) => {
|
||||
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
|
||||
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
|
||||
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
|
||||
})
|
||||
await ctx.route('**/api/**', async (route) => {
|
||||
const req = route.request()
|
||||
const url = new URL(req.url())
|
||||
const p = url.pathname
|
||||
@@ -191,6 +193,8 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
|
||||
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return fixture ? fixtureAntwort(fixture, route, req, url, json) : route.continue()
|
||||
return portalHilfe(p, json)
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
page.fx = fixture
|
||||
try {
|
||||
await fn(page, ctx)
|
||||
assert(fehler.length === 0, fehler.join(' | '))
|
||||
@@ -199,6 +203,7 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
if (process.env.STACK) console.log(e.stack)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
@@ -208,6 +213,29 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
|
||||
const oeffne = async (page, hash) => {
|
||||
await page.goto(`${srv.url}/dashboard.html${hash}`)
|
||||
}
|
||||
/** Ansicht öffnen und zum Reiter wechseln ('leistungen' | 'team' | 'einstellungen' | 'termine'). */
|
||||
const oeffneReiter = async (page, key) => {
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.locator(`[data-reiter="${key}"]`).click()
|
||||
await page.locator(`#tm-tab-${key}[aria-selected="true"]`).waitFor()
|
||||
await page.locator('#tm-panel .tm-tab').waitFor()
|
||||
}
|
||||
/** Gespeicherte Einstellung und Version im Nachbau von Appwrite. */
|
||||
const gespeichert = () => srv.einstellung()
|
||||
const version = () => srv.aw.docs('termineConfig').get(srv.FEATURE).version
|
||||
/** Wartet (bis 5 s), bis eine Bedingung im Nachbau des Servers stimmt (z. B. ein Speichern angekommen ist). */
|
||||
async function warteBis(bedingung, text) {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
if (bedingung()) return
|
||||
await new Promise((fertig) => { setTimeout(fertig, 50) })
|
||||
}
|
||||
throw new Error(`Zeit abgelaufen: ${text}`)
|
||||
}
|
||||
const zeileMitId = (page, id) => page.locator(`#tm-panel li[data-id="${id}"]`)
|
||||
const eintrag = (page, text) => page.locator('#tm-panel li.tm-eintrag', { hasText: text })
|
||||
const formular = (page, name) => page.getByRole('form', { name })
|
||||
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
|
||||
const warteAnsicht = async (page) => {
|
||||
await page.locator('#view-termine.active').waitFor()
|
||||
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
|
||||
@@ -496,7 +524,10 @@ async function pruefungen() {
|
||||
await knopf(banner, 'Anfragen ansehen').click()
|
||||
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen')
|
||||
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-04', status: 'angefragt' }, 'Abfrage: ab heute bis Ende des Buchungszeitraums (60 Tage), nur Anfragen')
|
||||
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (sechs Aufrufe zu hoechstens 62 Tagen), nur Anfragen
|
||||
const anfragenAufrufe = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
|
||||
eq(anfragenAufrufe.length, 6, 'sechs Abfragefenster')
|
||||
eq([anfragenAufrufe[0].von, anfragenAufrufe.at(-1).bis], ['2026-10-05', '2027-10-05'], 'ab heute bis heute + 365 Tage')
|
||||
eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage')
|
||||
const zeile = reihe(page, 'Lea Braun')
|
||||
// Details zeigen Kontakt und Nachricht als Text
|
||||
@@ -621,18 +652,18 @@ async function pruefungen() {
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
const banner = page.locator('.tm-box-info')
|
||||
await banner.getByText('3 Anfragen warten').waitFor()
|
||||
await banner.getByText('4 Anfragen warten').waitFor()
|
||||
await knopf(banner, 'Anfragen ansehen').click()
|
||||
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 3)
|
||||
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei
|
||||
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2'], 'alle drei Anfragen in Zeitfolge')
|
||||
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 4)
|
||||
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei, auch die jenseits des Buchungszeitraums von 180 Tagen (330 Tage)
|
||||
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2', 'bweit'], 'alle vier Anfragen in Zeitfolge')
|
||||
assert((await textVon(reihe(page, 'Fern Eins'))).includes('Angefragt'), 'ferne Anfrage mit Status')
|
||||
// Abfragen: lueckenlose Fenster ab heute bis heute + Buchungszeitraum (180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
|
||||
// Abfragen (Ruling R50): lueckenlose Fenster ab heute bis heute + 365 Tage, unabhaengig vom Buchungszeitraum (hier 180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
|
||||
const tageZwischen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000)
|
||||
const fenster = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
|
||||
eq(fenster.length, 3, 'drei Aufrufe')
|
||||
eq(fenster.length, 6, 'sechs Aufrufe')
|
||||
eq(fenster[0].von, '2026-10-05', 'beginnt heute')
|
||||
eq(fenster.at(-1).bis, '2027-04-03', 'endet nach 180 Tagen')
|
||||
eq(fenster.at(-1).bis, '2027-10-05', 'endet nach 365 Tagen')
|
||||
fenster.forEach((f, i) => {
|
||||
assert(tageZwischen(f.von, f.bis) <= 62, `Fenster ${i} zu lang: ${f.von}..${f.bis}`)
|
||||
if (i > 0) eq(tageZwischen(fenster[i - 1].bis, f.von), 1, `Fenster ${i} schließt lückenlos an`)
|
||||
@@ -646,6 +677,7 @@ async function pruefungen() {
|
||||
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun' }),
|
||||
buchung('bfern1', '2027-01-20', '09:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Eins' }),
|
||||
buchung('bfern2', '2027-03-10', '14:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Zwei' }),
|
||||
buchung('bweit', '2027-09-01', '11:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Weit Weg' }),
|
||||
]
|
||||
return fx
|
||||
})(),
|
||||
@@ -1071,6 +1103,732 @@ async function pruefungen() {
|
||||
await shot(page, name === 'echt_zeitzone_new_york' ? 'echt-new-york' : 'echt-berlin')
|
||||
}, { context: { timezoneId: zone }, jetzt: new Date('2026-10-05T23:30:00Z') })
|
||||
}
|
||||
|
||||
// ============================================================ Aufgabe 21b: Leistungen, Team & Plätze, Einstellungen (echte Router)
|
||||
// ------------------------------------------------------------ Leistungen: anlegen, bearbeiten, Reihenfolge
|
||||
await check('echt_leistung_anlegen_bearbeiten_reihenfolge', async (page) => {
|
||||
await oeffneReiter(page, 'leistungen')
|
||||
assert((await textVon(page.locator('#tm-panel .tm-box-info'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
|
||||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts(), ['Herrenschnitt', 'Damenschnitt', 'Kinderschnitt', 'Färben', 'Kennenlern-Beratung', 'Brautfrisur'], 'Reihenfolge wie gespeichert')
|
||||
const meta = await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())
|
||||
assert(['30 Min.', '5 Min. Pause danach', '28,00 €', 'Sofort buchbar', 'Anna, Ben'].every((t) => meta.includes(t)), `Kurzfassung: ${meta}`)
|
||||
assert((await textVon(eintrag(page, 'Brautfrisur'))).includes('Nur als Anfrage') && (await textVon(eintrag(page, 'Kennenlern-Beratung'))).includes('ohne Preis'), 'Anfrage und ohne Preis')
|
||||
await shot(page, 'leistungen')
|
||||
|
||||
// Leistung anlegen: erst Fehler (Dauer leer: im Feld erkannt; ohne Name und Person: vom Server), dann ein gueltiger Eintrag
|
||||
const v0 = version()
|
||||
await knopf(page, '+ Leistung anlegen').click()
|
||||
const neu = formular(page, 'Leistung anlegen')
|
||||
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('')
|
||||
await knopf(neu, 'Leistung anlegen').click()
|
||||
eq(await textVon(fehlerAm(neu, 'leistungen.6.dauer')), 'Bitte gib die Dauer in ganzen Minuten an.', 'Dauer leer')
|
||||
eq(version(), v0, 'nichts gespeichert')
|
||||
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('20')
|
||||
await knopf(neu, 'Leistung anlegen').click()
|
||||
await fehlerAm(neu, 'leistungen.6.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
|
||||
assert((await textVon(fehlerAm(neu, 'leistungen.6.ressourcen'))).includes('mindestens eine aktive Person'), 'Zuordnung fehlt (Server)')
|
||||
eq(await neu.getByLabel('Name', { exact: true }).getAttribute('aria-invalid'), 'true', 'Feld markiert')
|
||||
eq(version(), v0, 'auch der Server hat nichts gespeichert')
|
||||
await neu.getByLabel('Name', { exact: true }).fill('Bartpflege')
|
||||
await neu.getByLabel('Beschreibung (optional)').fill('Trimmen und Konturen.')
|
||||
await neu.getByLabel('Pause danach (Minuten)').fill('5')
|
||||
await neu.getByLabel('Preis', { exact: true }).selectOption('fest')
|
||||
await neu.getByLabel('Betrag in Euro').fill('19,90')
|
||||
await neu.getByLabel('Anna · Person').check()
|
||||
await shot(page, 'leistung-anlegen')
|
||||
await knopf(neu, 'Leistung anlegen').click()
|
||||
await eintrag(page, 'Bartpflege').waitFor()
|
||||
assert((await textVon(meldung(page))).includes('Leistung angelegt.'), 'Meldung')
|
||||
eq(version(), v0 + 1, 'ein Speichern')
|
||||
const angelegt = gespeichert().leistungen.at(-1)
|
||||
assert(/^s_[a-z0-9]{8}$/.test(angelegt.id), `ID im Muster s_ + 8: ${angelegt.id}`)
|
||||
eq({ ...angelegt, id: 'x' }, { id: 'x', name: 'Bartpflege', beschreibung: 'Trimmen und Konturen.', dauer: 20, pufferNach: 5, preisArt: 'fest', preisCent: 1990, buchungsart: 'sofort', ressourcen: [R_ANNA], aktiv: true, reihenfolge: 7 }, 'gespeicherte Leistung')
|
||||
const meta2 = await textVon(eintrag(page, 'Bartpflege').locator('.tm-meta').first())
|
||||
assert(meta2.includes('19,90 €') && meta2.includes('Anna') && !meta2.includes('Ben'), `Zeile: ${meta2}`)
|
||||
// Gaeste sehen sie sofort
|
||||
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
|
||||
const gast = conf.leistungen.find((l) => l.name === 'Bartpflege')
|
||||
assert(gast && gast.preis === '19,90 €' && gast.id === angelegt.id, 'oeffentliche Config nennt die neue Leistung')
|
||||
|
||||
// bearbeiten, zweimal hintereinander ohne Neuladen (die Version wird mitgefuehrt)
|
||||
for (const dauer of ['35', '40']) {
|
||||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||||
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||||
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill(dauer)
|
||||
await knopf(form, 'Speichern').click()
|
||||
await page.waitForFunction((d) => document.querySelector('#tm-panel li[data-id="s_herren01"] .tm-meta')?.textContent.includes(`${d} Min.`), dauer)
|
||||
eq(gespeichert().leistungen[0].dauer, Number(dauer), `Dauer ${dauer} gespeichert`)
|
||||
}
|
||||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt mit sich selbst')
|
||||
assert((await textVon(meldung(page))).includes('Leistung gespeichert.'), 'Meldung Speichern')
|
||||
|
||||
// Reihenfolge: Damenschnitt nach oben
|
||||
await knopf(page, 'Nach oben: Damenschnitt').click()
|
||||
await page.waitForFunction(() => document.querySelector('#tm-panel li.tm-eintrag .tm-name')?.textContent === 'Damenschnitt')
|
||||
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 2), [['Damenschnitt', 1], ['Herrenschnitt', 2]], 'Reihenfolge gespeichert')
|
||||
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), true, 'ganz oben: nicht weiter hoch')
|
||||
eq(await knopf(page, 'Nach unten: Bartpflege').isDisabled(), true, 'ganz unten: nicht weiter runter')
|
||||
// der Fokus bleibt in der verschobenen Zeile (kein Verlust nach dem Neuzeichnen)
|
||||
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
|
||||
// Deaktivieren (Leistung bleibt gespeichert), Gaeste sehen sie nicht mehr
|
||||
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
|
||||
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
|
||||
await kind.getByLabel(/^Aktiv/).uncheck()
|
||||
await knopf(kind, 'Speichern').click()
|
||||
await eintrag(page, 'Kinderschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
|
||||
assert(!(await (await fetch(`${API}/${KEY}/config`)).json()).leistungen.some((l) => l.name === 'Kinderschnitt'), 'inaktive Leistung nicht oeffentlich')
|
||||
await shot(page, 'leistungen-nach-aenderung')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Leistung mit kuenftigem Termin entfernen ⇒ Anzahl am Eintrag
|
||||
await check('echt_leistung_entfernen_mit_termin', async (page) => {
|
||||
await buche({ leistung: S_HERREN, tag: '2026-10-12', zeit: '10:00', email: 'a@example.com' })
|
||||
await buche({ leistung: S_HERREN, tag: '2026-10-13', zeit: '11:00', email: 'b@example.com' })
|
||||
await buche({ leistung: S_DAMEN, tag: '2026-10-14', zeit: '09:00', email: 'c@example.com', name: 'Dora Damen' })
|
||||
await oeffneReiter(page, 'leistungen')
|
||||
const v0 = version()
|
||||
// zwei kuenftige Termine ⇒ Mehrzahl, die Leistung bleibt
|
||||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||||
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||||
await knopf(form, 'Entfernen').click()
|
||||
const frage = page.getByRole('form', { name: 'Leistung „Herrenschnitt“ entfernen?' })
|
||||
await frage.waitFor()
|
||||
assert((await textVon(frage)).includes('Das kann nicht rückgängig gemacht werden.'), 'Rueckfrage in der Seite')
|
||||
await knopf(frage, 'Ja, entfernen').click()
|
||||
await frage.locator('.tm-fehler:not(:empty)').waitFor()
|
||||
eq(await textVon(frage.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.', 'Anzahl am Eintrag')
|
||||
eq(gespeichert().leistungen.some((l) => l.id === S_HERREN), true, 'Leistung bleibt')
|
||||
eq(version(), v0, 'nichts gespeichert')
|
||||
await shot(page, 'leistung-entfernen-mit-termin')
|
||||
// Abbrechen schliesst die Rueckfrage
|
||||
await knopf(frage, 'Abbrechen').click()
|
||||
eq(await frage.count(), 0, 'Rueckfrage zu')
|
||||
// ein Termin ⇒ Einzahl
|
||||
await knopf(eintrag(page, 'Damenschnitt'), 'Bearbeiten: Damenschnitt').click()
|
||||
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Entfernen').click()
|
||||
const frageDamen = page.getByRole('form', { name: 'Leistung „Damenschnitt“ entfernen?' })
|
||||
await knopf(frageDamen, 'Ja, entfernen').click()
|
||||
await frageDamen.locator('.tm-fehler:not(:empty)').waitFor()
|
||||
eq(await textVon(frageDamen.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.', 'Einzahl')
|
||||
// Deaktivieren geht, die Termine bleiben
|
||||
await knopf(frageDamen, 'Abbrechen').click()
|
||||
await formular(page, 'Leistung bearbeiten: Damenschnitt').getByLabel(/^Aktiv/).uncheck()
|
||||
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Speichern').click()
|
||||
await eintrag(page, 'Damenschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
|
||||
eq(srv.buchungen().filter((b) => b.leistungId === S_DAMEN && b.status === 'bestaetigt').length, 1, 'Termin der inaktiven Leistung bleibt')
|
||||
// ohne Termin ⇒ weg
|
||||
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
|
||||
await knopf(formular(page, 'Leistung bearbeiten: Kinderschnitt'), 'Entfernen').click()
|
||||
await knopf(page.getByRole('form', { name: 'Leistung „Kinderschnitt“ entfernen?' }), 'Ja, entfernen').click()
|
||||
await eintrag(page, 'Kinderschnitt').waitFor({ state: 'detached' })
|
||||
eq(gespeichert().leistungen.some((l) => l.id === S_KINDER), false, 'Kinderschnitt entfernt')
|
||||
assert((await textVon(meldung(page))).includes('Leistung „Kinderschnitt“ entfernt.'), 'Meldung')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Team & Plätze: Wochenplan ändern, neuer Platz mit Zuordnung
|
||||
await check('echt_team_wochenplan_und_neuer_platz', async (page) => {
|
||||
await oeffneReiter(page, 'team')
|
||||
const kopfzeile = await page.locator('#tm-panel li.tm-eintrag .tm-meta').allInnerTexts()
|
||||
assert(kopfzeile[0].includes('Person') && kopfzeile[0].includes('Zeiten an 6 Tagen') && kopfzeile[0].includes('6 Leistungen'), `Kurzfassung: ${kopfzeile[0]}`)
|
||||
await shot(page, 'team')
|
||||
// Wochenplan von Anna: Montag ändern
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||||
const form = formular(page, 'Bearbeiten: Anna')
|
||||
const montag = form.getByLabel('Montag', { exact: true })
|
||||
eq(await montag.inputValue(), '09:00–12:30, 13:30–18:00', 'Wochenplan als Text')
|
||||
eq(await form.getByLabel('Sonntag', { exact: true }).inputValue(), '', 'Sonntag geschlossen')
|
||||
await shot(page, 'team-bearbeiten')
|
||||
// ungueltige Eingaben ⇒ Fehler am Tag, nichts wird gesendet
|
||||
const v0 = version()
|
||||
await montag.fill('10-25')
|
||||
await form.getByLabel('Dienstag', { exact: true }).fill('09:00–12:00, 11:00–13:00')
|
||||
await knopf(form, 'Speichern').click()
|
||||
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.1'))).includes('09:00–12:30, 13:30–18:00'), 'Format-Hinweis am Montag')
|
||||
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.2'))).includes('ohne Überlappung'), 'Überlappung am Dienstag')
|
||||
eq(await montag.getAttribute('aria-invalid'), 'true', 'Montag markiert')
|
||||
eq(version(), v0, 'nichts gespeichert')
|
||||
// gueltig: Montag 10:00–14:00 (Schreibweise wird vereinheitlicht), Dienstag und Sonntag
|
||||
await montag.fill('10:00 - 14:00')
|
||||
await form.getByLabel('Dienstag', { exact: true }).fill('9:00–12:00 und 14:00 bis 24:00')
|
||||
await form.getByLabel('Sonntag', { exact: true }).fill('10:00–11:00')
|
||||
await knopf(form, 'Speichern').click()
|
||||
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_anna0001"] .tm-meta')?.textContent.includes('Zeiten an 7 Tagen'))
|
||||
eq(version(), v0 + 1, 'ein Speichern')
|
||||
const plan = gespeichert().ressourcen[0].plan
|
||||
eq([plan['1'], plan['2'], plan['7'], plan['3']], [[['10:00', '14:00']], [['09:00', '12:00'], ['14:00', '24:00']], [['10:00', '11:00']], [['09:00', '12:30'], ['13:30', '18:00']]], 'Plan gespeichert')
|
||||
// Gaeste sehen die neuen Zeiten: Montag 2026-10-12 hat bei Anna nichts vor 10:00
|
||||
const tage = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-12`)).json()
|
||||
eq([tage.zeiten?.[0], tage.zeiten?.at(-1)], ['10:00', '13:15'], 'erste und letzte Startzeit am Montag (15-Minuten-Raster, 30 Min. + 5 Pause bis 14:00)')
|
||||
|
||||
// neuer Platz mit Zeiten und Zuordnung in EINEM Speichern
|
||||
const v1 = version()
|
||||
await knopf(page, '+ Person oder Platz hinzufügen').click()
|
||||
const neu = formular(page, 'Person oder Platz hinzufügen')
|
||||
await neu.getByLabel('Name', { exact: true }).fill('Kabine 1')
|
||||
await neu.getByLabel('Art', { exact: true }).selectOption('platz')
|
||||
await neu.getByLabel('Montag', { exact: true }).fill('09:00–12:00')
|
||||
await neu.getByLabel('Herrenschnitt').check()
|
||||
await neu.getByLabel('Färben').check()
|
||||
await knopf(neu, 'Hinzufügen').click()
|
||||
await eintrag(page, 'Kabine 1').waitFor()
|
||||
eq(version(), v1 + 1, 'ein Speichern für Platz und Zuordnung')
|
||||
const e = gespeichert()
|
||||
const platz = e.ressourcen.at(-1)
|
||||
assert(/^r_[a-z0-9]{8}$/.test(platz.id) && platz.typ === 'platz' && platz.aktiv === true, `Platz: ${JSON.stringify(platz)}`)
|
||||
assert(/^[A-Za-z0-9_-]{43}$/.test(platz.feedToken), 'Kalender-Token vergibt der Server')
|
||||
eq(e.leistungen.filter((l) => l.ressourcen.includes(platz.id)).map((l) => l.name), ['Herrenschnitt', 'Färben'], 'Zuordnung bei den Leistungen')
|
||||
eq(await textVon(eintrag(page, 'Kabine 1').locator('.tm-meta').first()), 'Platz · Zeiten an 1 Tag · 2 Leistungen', 'Kurzfassung')
|
||||
// der neue Platz zeigt seinen Kalender-Link, sobald die Daten neu geladen sind (kein Neuladen der Seite noetig)
|
||||
await knopf(eintrag(page, 'Kabine 1'), 'Bearbeiten: Kabine 1').click()
|
||||
await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').waitFor()
|
||||
assert((await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').inputValue()).endsWith(`/${platz.feedToken}.ics`), 'Link mit dem Token des Servers')
|
||||
|
||||
// Person entfernen: Zeiten, Zuordnung und Abwesenheiten gehen mit
|
||||
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
|
||||
await knopf(formular(page, 'Bearbeiten: Ben'), 'Entfernen').click()
|
||||
await knopf(page.getByRole('form', { name: '„Ben“ entfernen?' }), 'Ja, entfernen').click()
|
||||
await zeileMitId(page, R_BEN).waitFor({ state: 'detached' })
|
||||
const nach = gespeichert()
|
||||
eq(nach.ressourcen.map((r) => r.name), ['Anna', 'Kabine 1'], 'Ben entfernt')
|
||||
eq(nach.leistungen.every((l) => !l.ressourcen.includes(R_BEN)), true, 'aus allen Zuordnungen entfernt')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Team: Fehler des Servers stehen an der Leistung (nicht in der Person)
|
||||
await check('echt_team_fehler_zuordnung', async (page) => {
|
||||
await oeffneReiter(page, 'team')
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||||
const form = formular(page, 'Bearbeiten: Anna')
|
||||
// Anna und Ben bieten alles an; zuerst bei Ben alles abwaehlen ⇒ dann haengt jede Leistung nur an Anna
|
||||
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
|
||||
const ben = formular(page, 'Bearbeiten: Ben')
|
||||
for (const kasten of await ben.locator('fieldset input[type=checkbox]').all()) await kasten.uncheck()
|
||||
await knopf(ben, 'Speichern').click()
|
||||
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_ben00002"] .tm-meta')?.textContent.includes('keine Leistung'))
|
||||
// jetzt Anna auf „Inaktiv“: alle aktiven Leistungen haben dann niemanden
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||||
const anna = formular(page, 'Bearbeiten: Anna')
|
||||
await anna.getByLabel(/^Aktiv/).first().uncheck()
|
||||
const v0 = version()
|
||||
await knopf(anna, 'Speichern').click()
|
||||
const slot = fehlerAm(anna, 'leistungen.0.ressourcen')
|
||||
await slot.filter({ hasText: 'mindestens eine aktive Person' }).waitFor()
|
||||
eq(await anna.locator('[data-fehler^="leistungen."]:not(:empty)').count(), 6, 'ein Fehler an jeder der sechs Leistungen')
|
||||
eq(await anna.locator('input[aria-invalid="true"]').count(), 6, 'sechs Haken markiert')
|
||||
eq(version(), v0, 'nichts gespeichert')
|
||||
await shot(page, 'team-fehler-zuordnung')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Team: Abwesenheiten, Feiertage, Sonderöffnungen
|
||||
await check('echt_team_ausnahmen', async (page) => {
|
||||
await oeffneReiter(page, 'team')
|
||||
// Abwesenheit
|
||||
const v0 = version()
|
||||
await knopf(page, '+ Abwesenheit eintragen').click()
|
||||
const neu = formular(page, 'Abwesenheit eintragen')
|
||||
await neu.getByLabel('Von (erster Tag)').fill('2026-11-06')
|
||||
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-02')
|
||||
await knopf(neu, 'Abwesenheit eintragen').click()
|
||||
await fehlerAm(neu, 'abwesenheiten.0.bis').filter({ hasText: 'Das Ende darf nicht vor dem Beginn liegen.' }).waitFor() // Fehler des Servers am Feld
|
||||
eq(version(), v0, 'nichts gespeichert')
|
||||
await neu.getByLabel('Von (erster Tag)').fill('2026-11-02')
|
||||
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-06')
|
||||
await neu.getByLabel('Titel (optional)').fill('Betriebsurlaub')
|
||||
await knopf(neu, 'Abwesenheit eintragen').click()
|
||||
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Betriebsurlaub' }).waitFor()
|
||||
const a = gespeichert().abwesenheiten[0]
|
||||
assert(/^a_[a-z0-9]{8}$/.test(a.id), `ID a_ + 8: ${a.id}`)
|
||||
eq({ ...a, id: 'x' }, { id: 'x', ressourceId: null, von: '2026-11-02', bis: '2026-11-06', titel: 'Betriebsurlaub' }, 'Abwesenheit gespeichert')
|
||||
eq(await textVon(page.locator('#tm-panel [aria-label="Abwesenheiten"] li .tm-name')), '02.11.2026 – 06.11.2026', 'Zeitraum')
|
||||
// ... und fuer Anna allein, vergangene stehen eingeklappt
|
||||
await knopf(page, '+ Abwesenheit eintragen').click()
|
||||
const anna = formular(page, 'Abwesenheit eintragen')
|
||||
await anna.getByLabel('Wer ist weg?').selectOption(R_ANNA)
|
||||
await anna.getByLabel('Von (erster Tag)').fill('2026-09-01')
|
||||
await anna.getByLabel('Bis (letzter Tag)').fill('2026-09-05')
|
||||
await knopf(anna, 'Abwesenheit eintragen').click()
|
||||
await page.locator('#tm-panel summary', { hasText: 'Vergangene Abwesenheiten (1)' }).waitFor()
|
||||
eq(gespeichert().abwesenheiten.length, 2, 'zwei Abwesenheiten')
|
||||
// Gaeste: am 28.12. ist niemand buchbar
|
||||
const tag = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=egal&tag=2026-11-04`)).json()
|
||||
eq(tag.zeiten, [], 'in der Abwesenheit keine Zeiten')
|
||||
// bearbeiten und entfernen
|
||||
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Betriebsurlaub/).click()
|
||||
const bearbeiten = formular(page, 'Abwesenheit bearbeiten')
|
||||
await bearbeiten.getByLabel('Titel (optional)').fill('Winterpause')
|
||||
await knopf(bearbeiten, 'Speichern').click()
|
||||
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor()
|
||||
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Winterpause/).click()
|
||||
await knopf(formular(page, 'Abwesenheit bearbeiten'), 'Entfernen').click()
|
||||
await knopf(page.getByRole('form', { name: 'Abwesenheit entfernen?' }), 'Ja, entfernen').click()
|
||||
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor({ state: 'detached' })
|
||||
eq(gespeichert().abwesenheiten.map((x) => x.titel), [''], 'nur die vergangene bleibt')
|
||||
|
||||
// Feiertage
|
||||
const feiertage = page.getByRole('form', { name: 'Feiertage' })
|
||||
eq(await feiertage.getByLabel('Bundesland').inputValue(), 'RP', 'Bundesland')
|
||||
eq(await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).isChecked(), false, 'Haken wie gespeichert (Testdaten: aus)')
|
||||
await feiertage.getByLabel('Bundesland').selectOption('BY')
|
||||
await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).check()
|
||||
await knopf(feiertage, 'Speichern').click()
|
||||
await page.waitForFunction(() => document.body.textContent.includes('Feiertage gespeichert.'))
|
||||
eq([gespeichert().bundesland, gespeichert().feiertageGeschlossen], ['BY', true], 'Feiertage gespeichert')
|
||||
|
||||
// Sonderöffnungen
|
||||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||||
const sonder = formular(page, 'Sonderöffnung eintragen')
|
||||
await sonder.getByLabel('Tag', { exact: true }).fill('2026-10-18')
|
||||
await sonder.getByLabel(/^Geöffnet von/).fill('09:00 bis 13')
|
||||
await knopf(sonder, 'Sonderöffnung eintragen').click()
|
||||
assert((await textVon(fehlerAm(sonder, 'zeiten'))).includes('09:00–12:30, 13:30–18:00'), 'Zeiten ungültig (am Feld)')
|
||||
await sonder.getByLabel(/^Geöffnet von/).fill('09:00–13:00')
|
||||
await knopf(sonder, 'Sonderöffnung eintragen').click()
|
||||
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor()
|
||||
eq(gespeichert().sonderoeffnungen['2026-10-18'], [['09:00', '13:00']], 'Sonderöffnung gespeichert')
|
||||
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-18"] .tm-name')), 'Sonntag, 18. Oktober 2026', 'Tag')
|
||||
// ein Tag ohne Zeiten = geschlossen; doppelt ⇒ Fehler
|
||||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||||
const zu = formular(page, 'Sonderöffnung eintragen')
|
||||
await zu.getByLabel('Tag', { exact: true }).fill('2026-10-19')
|
||||
await knopf(zu, 'Sonderöffnung eintragen').click()
|
||||
await page.locator('#tm-panel li[data-tag="2026-10-19"]').waitFor()
|
||||
eq(gespeichert().sonderoeffnungen['2026-10-19'], [], 'geschlossen = leere Liste')
|
||||
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-19"] .tm-meta')), 'Geschlossen', 'Anzeige')
|
||||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||||
const doppelt = formular(page, 'Sonderöffnung eintragen')
|
||||
await doppelt.getByLabel('Tag', { exact: true }).fill('2026-10-18')
|
||||
await knopf(doppelt, 'Sonderöffnung eintragen').click()
|
||||
assert((await textVon(fehlerAm(doppelt, 'datum'))).includes('schon eine Sonderöffnung'), 'doppelter Tag')
|
||||
await knopf(doppelt, 'Abbrechen').click()
|
||||
// Gaeste: der Sonntag nur bis 13 Uhr, 25.12. zu (Feiertag-Haken + Sonderöffnung)
|
||||
const hs = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-18`)).json()
|
||||
eq([hs.zeiten[0], hs.zeiten.at(-1)], ['09:00', '12:25'].map((x, i) => (i === 1 ? hs.zeiten.at(-1) : x)), 'der Sonntag ab 09:00')
|
||||
assert(hs.zeiten.at(-1) < '13:00', `der Sonntag endet vor 13 Uhr: ${hs.zeiten.at(-1)}`)
|
||||
// Sonderöffnung bearbeiten und entfernen
|
||||
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
|
||||
const sb = formular(page, 'Sonderöffnung 18.10.2026')
|
||||
await sb.getByLabel(/^Geöffnet von/).fill('10:00–12:00')
|
||||
await knopf(sb, 'Speichern').click()
|
||||
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-tag="2026-10-18"] .tm-meta')?.textContent === '10:00–12:00')
|
||||
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
|
||||
await knopf(formular(page, 'Sonderöffnung 18.10.2026'), 'Entfernen').click()
|
||||
await knopf(page.getByRole('form', { name: 'Sonderöffnung am 18.10.2026 entfernen?' }), 'Ja, entfernen').click()
|
||||
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor({ state: 'detached' })
|
||||
eq(Object.keys(gespeichert().sonderoeffnungen), ['2026-10-19'], 'nur der geschlossene Tag bleibt')
|
||||
await shot(page, 'team-ausnahmen')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Kalender-Abo-Link: kopieren, neu erzeugen
|
||||
await check('echt_feed_link_kopieren_und_neu', async (page, ctx) => {
|
||||
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
|
||||
await oeffneReiter(page, 'team')
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||||
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
|
||||
const link = feed.locator('.tm-link-feld')
|
||||
const token0 = gespeichert().ressourcen[0].feedToken
|
||||
const url0 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token0}.ics`
|
||||
eq(await link.inputValue(), url0, 'Link des Servers')
|
||||
eq(await link.getAttribute('readonly'), '', 'nur lesbar')
|
||||
const hinweis = await textVon(feed)
|
||||
assert(hinweis.includes('alle paar Stunden') && hinweis.includes('Der Link ist geheim'), `Hinweise: ${hinweis}`)
|
||||
await shot(page, 'team-kalender-link')
|
||||
const r0 = await fetch(url0)
|
||||
assert(r0.status === 200 && (r0.headers.get('content-type') || '').startsWith('text/calendar'), `Link liefert den Kalender (${r0.status})`)
|
||||
// kopieren
|
||||
await knopf(feed, 'Link kopieren').click()
|
||||
await feed.getByText('Der Link ist kopiert.').waitFor()
|
||||
eq(await page.evaluate(() => navigator.clipboard.readText()), url0, 'Zwischenablage')
|
||||
// neu erzeugen: erst Rueckfrage in der Seite, Abbrechen aendert nichts
|
||||
const v0 = version()
|
||||
await knopf(feed, 'Neuen Link erzeugen').click()
|
||||
const frage = page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' })
|
||||
assert((await textVon(frage)).includes('Der bisherige Link funktioniert danach nicht mehr.'), 'Rückfrage nennt die Folge')
|
||||
await knopf(frage, 'Abbrechen').click()
|
||||
eq([gespeichert().ressourcen[0].feedToken, version()], [token0, v0], 'Abbrechen ändert nichts')
|
||||
await knopf(feed, 'Neuen Link erzeugen').click()
|
||||
await shot(page, 'team-kalender-link-frage')
|
||||
await knopf(frage, 'Neuen Link erzeugen').click()
|
||||
await page.waitForFunction((alt) => document.querySelector('#tm-panel .tm-link-feld')?.value !== alt, url0)
|
||||
const token1 = gespeichert().ressourcen[0].feedToken
|
||||
assert(token1 !== token0 && /^[A-Za-z0-9_-]{43}$/.test(token1), 'neues Token vom Server')
|
||||
const url1 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token1}.ics`
|
||||
eq(await link.inputValue(), url1, 'neuer Link steht im Feld')
|
||||
eq(version(), v0 + 1, 'das Erzeugen zählt die Version hoch')
|
||||
eq((await fetch(url0)).status, 404, 'der alte Link gilt nicht mehr')
|
||||
eq((await fetch(url1)).status, 200, 'der neue Link gilt')
|
||||
assert((await textVon(feed)).includes('Der neue Link ist da. Der alte funktioniert nicht mehr.'), 'Hinweis am Link')
|
||||
eq(gespeichert().ressourcen[1].feedToken.length, 43, 'Ben behält seinen Link')
|
||||
// ein Speichern direkt danach kollidiert nicht mit dem Erzeugen (Version aus der Antwort)
|
||||
const form = formular(page, 'Bearbeiten: Anna')
|
||||
await form.getByLabel('Name', { exact: true }).fill('Anna M.')
|
||||
await knopf(form, 'Speichern').click()
|
||||
await eintrag(page, 'Anna M.').waitFor()
|
||||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
|
||||
eq([gespeichert().ressourcen[0].name, gespeichert().ressourcen[0].feedToken], ['Anna M.', token1], 'Name gespeichert, Link bleibt der neue')
|
||||
})
|
||||
|
||||
await check('echt_feed_link_ohne_zwischenablage', async (page, ctx) => {
|
||||
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
|
||||
await oeffneReiter(page, 'team')
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||||
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
|
||||
await knopf(feed, 'Link kopieren').click()
|
||||
const status = feed.locator('[role="status"]')
|
||||
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
|
||||
assert((await textVon(status)).includes('markiert'), 'Hinweis: Link ist markiert')
|
||||
const auswahl = await page.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
return { link: el?.classList.contains('tm-link-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
|
||||
})
|
||||
eq(auswahl.link, true, 'Fokus im Link-Feld')
|
||||
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Link ist markiert')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Konflikt mit zweitem Tab
|
||||
await check('echt_konflikt_zweiter_tab', async (page, ctx) => {
|
||||
await oeffneReiter(page, 'einstellungen')
|
||||
const zweiter = await ctx.newPage()
|
||||
await oeffneReiter(zweiter, 'einstellungen')
|
||||
// der zweite Tab speichert zuerst
|
||||
await zweiter.getByLabel('Telefon (Pflicht)').fill('06301 9999')
|
||||
await knopf(zweiter, 'Einstellungen speichern').click()
|
||||
await zweiter.getByText('Einstellungen gespeichert.').waitFor()
|
||||
eq(gespeichert().betrieb.telefon, '06301 9999', 'zweiter Tab gespeichert')
|
||||
// der erste hat noch die alte Version
|
||||
const v1 = version()
|
||||
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
|
||||
await knopf(page, 'Einstellungen speichern').click()
|
||||
const box = page.locator('#tm-panel .tm-box-warn')
|
||||
await box.waitFor()
|
||||
assert((await textVon(box)).includes('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), `Konflikt-Text: ${await textVon(box)}`)
|
||||
eq(await box.getAttribute('role'), 'alert', 'als Hinweis ausgezeichnet')
|
||||
eq([gespeichert().betrieb.name, version()], ['Salon Müller', v1], 'nichts überschrieben')
|
||||
await shot(page, 'konflikt')
|
||||
// neu laden holt den Stand des anderen Tabs, danach geht das Speichern
|
||||
await knopf(box, 'Neu laden').click()
|
||||
await box.waitFor({ state: 'hidden' })
|
||||
eq(await page.getByLabel('Telefon (Pflicht)').inputValue(), '06301 9999', 'Stand des anderen Tabs')
|
||||
eq(await page.getByLabel('Name des Betriebs', { exact: true }).inputValue(), 'Salon Müller', 'eigene ungespeicherte Änderung ist weg')
|
||||
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
|
||||
await knopf(page, 'Einstellungen speichern').click()
|
||||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||||
eq([gespeichert().betrieb.name, gespeichert().betrieb.telefon], ['Salon Müller & Söhne', '06301 9999'], 'beide Änderungen da')
|
||||
|
||||
// dasselbe im Reiter Leistungen: der zweite Tab benennt eine Leistung um, der erste hat die alte Version
|
||||
await page.locator('[data-reiter="leistungen"]').click()
|
||||
await page.locator('#tm-panel li[data-id="s_herren01"]').waitFor()
|
||||
await zweiter.reload() // der zweite Tab ist seit dem Speichern des ersten veraltet: frisch laden
|
||||
await oeffneReiter(zweiter, 'leistungen')
|
||||
await knopf(zweiter.locator('#tm-panel li[data-id="s_herren01"]'), 'Bearbeiten: Herrenschnitt').click()
|
||||
const f2 = zweiter.getByRole('form', { name: 'Leistung bearbeiten: Herrenschnitt' })
|
||||
await f2.getByLabel('Name', { exact: true }).fill('Herrenschnitt klassisch')
|
||||
await knopf(f2, 'Speichern').click()
|
||||
await zweiter.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
|
||||
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
|
||||
const f1 = formular(page, 'Leistung bearbeiten: Kinderschnitt')
|
||||
await f1.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
|
||||
await knopf(f1, 'Speichern').click()
|
||||
await page.locator('#tm-panel .tm-box-warn:not([hidden])').waitFor()
|
||||
eq(gespeichert().leistungen.find((l) => l.id === S_KINDER).dauer, 30, 'Kinderschnitt unverändert')
|
||||
await knopf(page.locator('#tm-panel .tm-box-warn'), 'Neu laden').click()
|
||||
await page.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
|
||||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'Hinweis weg')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Feldfehler am Feld (javascript:-URL, Zahl, Name)
|
||||
await check('echt_einstellungen_feldfehler', async (page) => {
|
||||
await oeffneReiter(page, 'einstellungen')
|
||||
const form = page.getByRole('form', { name: 'Einstellungen' })
|
||||
const url = form.getByLabel('Link zum Impressum', { exact: true })
|
||||
const v0 = version()
|
||||
await url.fill('javascript:alert(1)')
|
||||
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('5')
|
||||
await form.getByLabel('Name des Betriebs', { exact: true }).fill('')
|
||||
await knopf(form, 'Einstellungen speichern').click()
|
||||
const fehlerUrl = fehlerAm(form, 'betrieb.impressumUrl')
|
||||
await fehlerUrl.filter({ hasText: 'Bitte gib eine vollständige Adresse mit https:// an' }).waitFor()
|
||||
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.', 'Zahl außerhalb: Text des Servers am Feld')
|
||||
eq(await textVon(fehlerAm(form, 'betrieb.name')), 'Bitte gib den Namen deines Betriebs an.', 'Name fehlt (Server)')
|
||||
eq(await url.getAttribute('aria-invalid'), 'true', 'URL-Feld markiert')
|
||||
eq(await page.evaluate(() => document.activeElement?.dataset.feld), 'betrieb.name', 'Fokus auf dem ersten fehlerhaften Feld (Reihenfolge der Seite)')
|
||||
eq([version(), gespeichert().betrieb.impressumUrl], [v0, 'https://salon-mueller.example/impressum'], 'nichts gespeichert')
|
||||
await shot(page, 'einstellungen-feldfehler')
|
||||
// die Adresse darf nie als Link oder Skript landen: gespeichert ist sie auch nicht
|
||||
eq(await page.locator('#tm-panel a[href^="javascript:"]').count(), 0, 'kein javascript:-Link in der Seite')
|
||||
// eine leere Zahl wird schon im Feld erkannt
|
||||
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('')
|
||||
await knopf(form, 'Einstellungen speichern').click()
|
||||
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Bitte gib eine ganze Zahl an.', 'leere Zahl')
|
||||
// alles korrigieren: die Fehler verschwinden mit dem Speichern
|
||||
await url.fill('https://salon-mueller.example/impressum-neu')
|
||||
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('120')
|
||||
await form.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller')
|
||||
await knopf(form, 'Einstellungen speichern').click()
|
||||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||||
eq(await page.locator('#tm-panel .tm-fehler:not(:empty)').count(), 0, 'keine Fehlertexte mehr')
|
||||
eq([gespeichert().betrieb.impressumUrl, gespeichert().aufbewahrungTage], ['https://salon-mueller.example/impressum-neu', 120], 'gespeichert')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Einstellungen: alle Abschnitte
|
||||
await check('echt_einstellungen_speichern', async (page) => {
|
||||
await oeffneReiter(page, 'einstellungen')
|
||||
const form = page.getByRole('form', { name: 'Einstellungen' })
|
||||
const feld = (label) => form.getByLabel(label, { exact: true })
|
||||
// vorbefuellt aus der gespeicherten Einstellung
|
||||
eq([await feld('Name des Betriebs').inputValue(), await feld('E-Mail des Betriebs').inputValue(), await feld('Telefon (Pflicht)').inputValue()], ['Salon Müller', 'info@salon-mueller.example', '06301 1234'], 'Betrieb vorbefüllt')
|
||||
eq(await feld('Akzentfarbe').inputValue(), '#0f766e', 'Farbe')
|
||||
eq(await feld('Selbst absagen bis (Stunden vorher)').inputValue(), '24', 'Regeln')
|
||||
// Hinweise der Spezifikation
|
||||
const text = await textVon(page.locator('#tm-panel'))
|
||||
assert(text.includes('Der Widerruf schlägt die Stornofrist.'), 'Hinweis: Widerruf schlägt Stornofrist')
|
||||
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
|
||||
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
|
||||
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
|
||||
const online = form.getByRole('radio', { name: /Online bezahlen/ })
|
||||
eq(await online.isDisabled(), true, 'Online bezahlen ausgegraut')
|
||||
assert((await textVon(page.locator('.tm-optionen'))).includes('kommt bald'), '„kommt bald“')
|
||||
eq(await form.getByRole('radio', { name: /Vor Ort bezahlen/ }).isChecked(), true, 'vor Ort gewählt')
|
||||
eq(await page.getByRole('link', { name: /CSV/ }).getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
|
||||
const csv = await page.evaluate(async () => { const r = await fetch('/api/termine/export.csv?projectId=p1'); return { status: r.status, typ: r.headers.get('content-type') } })
|
||||
assert(csv.status === 200 && csv.typ.startsWith('text/csv'), `Export liefert CSV: ${JSON.stringify(csv)}`)
|
||||
await shot(page, 'einstellungen')
|
||||
|
||||
// aendern
|
||||
const stornoVorher = gespeichert().storno.version
|
||||
await feld('Telefon (Pflicht)').fill('06301 9999')
|
||||
await feld('Wie sprichst du deine Gäste an?').selectOption('du')
|
||||
await feld('Akzentfarbe').fill('#112233')
|
||||
await feld('Startzeiten alle').selectOption('30')
|
||||
await feld('Mindestvorlauf (Minuten)').fill('60')
|
||||
await feld('Buchungszeitraum (Tage)').fill('90')
|
||||
await feld('Selbst absagen bis (Stunden vorher)').fill('12')
|
||||
await feld('Offene Termine je E-Mail-Adresse').fill('5')
|
||||
await form.getByLabel(/Gäste dürfen die Person oder den Platz selbst wählen/).uncheck()
|
||||
await feld('Telefonnummer des Gastes').selectOption('pflicht')
|
||||
await feld('Nachricht des Gastes').selectOption('optional')
|
||||
await knopf(form, '+ Zusatzfeld').click()
|
||||
await knopf(form, '+ Zusatzfeld').click()
|
||||
const zeilen = form.locator('.tm-zusatz-zeile')
|
||||
await zeilen.nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe')
|
||||
await zeilen.nth(1).getByLabel('Beschriftung', { exact: true }).fill('Haarlänge')
|
||||
eq(await zeilen.nth(0).getByLabel('Auswahlmöglichkeiten').isVisible(), false, 'Auswahl nur bei „Auswahl aus Liste“')
|
||||
await zeilen.nth(1).getByLabel('Art', { exact: true }).selectOption('auswahl')
|
||||
await zeilen.nth(1).getByLabel('Auswahlmöglichkeiten').fill('kurz\nmittel\nlang')
|
||||
await zeilen.nth(1).getByLabel('Pflichtfeld').check()
|
||||
await feld('Die Erinnerung kommt').selectOption('12')
|
||||
await knopf(form, 'Muster in das Textfeld übernehmen').click()
|
||||
assert((await feld('Dein Text').inputValue()).includes('bis 12 Stunden vorher kostenlos absagen'), 'Muster mit der Frist aus dem Feld darüber')
|
||||
await feld('Deine Gäste sind').selectOption('gemischt')
|
||||
await form.getByLabel('Kreditkarte', { exact: true }).uncheck()
|
||||
await form.getByLabel('PayPal', { exact: true }).check()
|
||||
await feld('Aufbewahrung (Tage)').fill('120')
|
||||
const v0 = version()
|
||||
await knopf(form, 'Einstellungen speichern').click()
|
||||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||||
eq(version(), v0 + 1, 'ein Speichern')
|
||||
const e = gespeichert()
|
||||
eq(e.betrieb.telefon, '06301 9999', 'Telefon')
|
||||
eq([e.anrede, e.farbe], ['du', '#112233'], 'Anrede, Farbe')
|
||||
eq(e.regeln, { raster: 30, vorlaufMin: 60, horizontTage: 90, absageBisStd: 12, maxOffeneJeEmail: 5, personWaehlbar: false }, 'Regeln')
|
||||
eq(e.gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [
|
||||
{ key: 'wunschfarbe', label: 'Wunschfarbe', typ: 'text', optionen: [], pflicht: false },
|
||||
{ key: 'haarlaenge', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
|
||||
] }, 'Gastfelder')
|
||||
eq(e.erinnerung, { anbieten: true, stundenVorher: 12 }, 'Erinnerung')
|
||||
assert(e.storno.text.includes('bis 12 Stunden vorher') && e.storno.version !== stornoVorher, `Storno mit neuer Fassung: ${e.storno.version}`)
|
||||
eq(e.recht, { gaesteSind: 'gemischt', freizeit: false }, 'Rechtliches')
|
||||
eq(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'paypal'] }, 'Zahlung')
|
||||
eq(e.aufbewahrungTage, 120, 'Aufbewahrung')
|
||||
// Gaeste sehen es
|
||||
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
|
||||
eq([conf.anrede, conf.zahlung.text, conf.gastFelder.zusatz.length, conf.gastFelder.telefon], ['du', 'Bezahlung vor Ort: bar, EC-Karte, PayPal', 2, 'pflicht'], 'öffentliche Config')
|
||||
// die Seite zeigt den gespeicherten Stand; ein Zusatzfeld bleibt bei einer Umbenennung unter demselben Schluessel
|
||||
eq(await form.locator('.tm-zusatz-zeile').count(), 2, 'zwei Zusatzfelder')
|
||||
await form.locator('.tm-zusatz-zeile').nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe der Haare')
|
||||
await knopf(form, 'Einstellungen speichern').click()
|
||||
await page.waitForFunction(() => document.querySelector('[data-zusatz="0"] input')?.value === 'Wunschfarbe der Haare')
|
||||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||||
eq(gespeichert().gastFelder.zusatz.map((z) => [z.key, z.label]), [['wunschfarbe', 'Wunschfarbe der Haare'], ['haarlaenge', 'Haarlänge']], 'Schlüssel bleibt')
|
||||
// Zusatzfeld entfernen
|
||||
await knopf(form, 'Zusatzfeld entfernen: Wunschfarbe der Haare').click()
|
||||
eq(gespeichert().gastFelder.zusatz.length, 2, 'bis zum Speichern bleibt alles')
|
||||
await knopf(form, 'Einstellungen speichern').click()
|
||||
await warteBis(() => gespeichert().gastFelder.zusatz.length === 1, 'Zusatzfeld entfernt')
|
||||
eq(gespeichert().gastFelder.zusatz.map((z) => z.key), ['haarlaenge'], 'eins entfernt')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Telefon und Anschrift fehlen (Entwurf aus Vorlage, Ruling R41)
|
||||
await check('echt_pflichtangaben_aus_vorlage', async (page) => {
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.getByLabel('Friseur und Salon').check()
|
||||
await knopf(page, 'Mit dieser Vorlage starten').click()
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
const leiste = page.locator('#termine-body .tm-hinweise .tm-box-warn')
|
||||
assert((await textVon(leiste)).includes('Noch nicht buchbar'), 'Hinweisleiste')
|
||||
// Speichern in Leistungen: der Server verlangt zuerst die Angaben, der Hinweis sagt wo
|
||||
await page.locator('[data-reiter="leistungen"]').click()
|
||||
const zeile = page.locator('#tm-panel li.tm-eintrag').first()
|
||||
await knopf(zeile, /^Bearbeiten:/).click()
|
||||
const form = page.locator('#tm-panel form').first()
|
||||
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
|
||||
await knopf(form, 'Speichern').click()
|
||||
const fehler = form.locator('[data-fehler="_"]')
|
||||
await fehler.filter({ hasText: 'Telefonnummer' }).waitFor()
|
||||
assert((await textVon(fehler)).includes('Das änderst du im Reiter „Einstellungen“.'), `Hinweis auf den Reiter: ${await textVon(fehler)}`)
|
||||
// Einstellungen: Hinweis in der Karte und Felder fuellen
|
||||
await page.locator('[data-reiter="einstellungen"]').click()
|
||||
const einstellungen = page.getByRole('form', { name: 'Einstellungen' })
|
||||
const warnung = page.locator('#tm-panel .tm-abschnitt .tm-box-warn')
|
||||
eq(await textVon(warnung), 'Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: deine Telefonnummer und deine Anschrift.', 'Hinweis in den Einstellungen')
|
||||
await shot(page, 'einstellungen-pflichtangaben')
|
||||
await einstellungen.getByLabel('Telefon (Pflicht)').fill('06301 555')
|
||||
await knopf(einstellungen, 'Einstellungen speichern').click()
|
||||
await fehlerAm(einstellungen, 'betrieb.adresse').filter({ hasText: 'Bitte gib die Anschrift deines Betriebs an.' }).waitFor()
|
||||
await einstellungen.getByLabel('Anschrift', { exact: true }).fill('Hauptstr. 1, 67722 Winnweiler')
|
||||
await knopf(einstellungen, 'Einstellungen speichern').click()
|
||||
await page.getByText('Einstellungen gespeichert.').waitFor()
|
||||
// der Hinweis oben verschwindet sofort, ohne die Seite neu zu laden
|
||||
eq(await page.locator('#termine-body .tm-hinweise .tm-box-warn').count(), 0, 'Hinweisleiste weg')
|
||||
eq(await page.locator('#tm-panel .tm-abschnitt .tm-box-warn').count(), 0, 'Hinweis in der Karte weg')
|
||||
eq([gespeichert().betrieb.telefon, gespeichert().betrieb.adresse], ['06301 555', 'Hauptstr. 1, 67722 Winnweiler'], 'gespeichert')
|
||||
// jetzt geht das Speichern in Leistungen
|
||||
await page.locator('[data-reiter="leistungen"]').click()
|
||||
await knopf(page.locator('#tm-panel li.tm-eintrag').first(), /^Bearbeiten:/).click()
|
||||
const form2 = page.locator('#tm-panel form').first()
|
||||
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
|
||||
await knopf(form2, 'Speichern').click()
|
||||
await page.getByText('Leistung gespeichert.').waitFor()
|
||||
eq(gespeichert().leistungen[0].dauer, 40, 'Dauer gespeichert')
|
||||
}, { reset: { ohneEinstellung: true } })
|
||||
|
||||
// ------------------------------------------------------------ Abgeschaltet: alle neuen Reiter nur lesen
|
||||
await check('echt_abgeschaltet_reiter_nur_lesen', async (page) => {
|
||||
await srv.setzeFunktion('disabled')
|
||||
const v0 = version()
|
||||
const alleGesperrt = (loc) => loc.locator('input, select, textarea').evaluateAll((els) => els.length > 0 && els.every((e) => e.disabled || e.closest('fieldset[disabled]')))
|
||||
// Leistungen
|
||||
await oeffneReiter(page, 'leistungen')
|
||||
eq(await knopf(page, '+ Leistung anlegen').count(), 0, 'Leistungen: kein Anlegen')
|
||||
eq(await page.locator('#tm-panel [data-aktion="hoch"], #tm-panel [data-aktion="runter"]').count(), 0, 'Leistungen: keine Reihenfolge')
|
||||
await knopf(eintrag(page, 'Herrenschnitt'), 'Ansehen: Herrenschnitt').click()
|
||||
const leistung = formular(page, 'Leistung: Herrenschnitt')
|
||||
eq(await alleGesperrt(leistung), true, 'Leistungen: Felder gesperrt')
|
||||
eq(await leistung.getByLabel('Name', { exact: true }).inputValue(), 'Herrenschnitt', 'Leistungen: Werte stehen da')
|
||||
eq(await leistung.getByRole('button').allInnerTexts(), ['Schließen'], 'Leistungen: nur Schließen')
|
||||
await shot(page, 'abgeschaltet-leistungen')
|
||||
await knopf(leistung, 'Schließen').click()
|
||||
// Team
|
||||
await page.locator('[data-reiter="team"]').click()
|
||||
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
|
||||
for (const knopfText of ['+ Person oder Platz hinzufügen', '+ Abwesenheit eintragen', '+ Sonderöffnung eintragen']) eq(await knopf(page, knopfText).count(), 0, `Team: ${knopfText} fehlt`)
|
||||
eq(await alleGesperrt(page.getByRole('form', { name: 'Feiertage' })), true, 'Team: Feiertage gesperrt')
|
||||
eq(await knopf(page.getByRole('form', { name: 'Feiertage' }), 'Speichern').count(), 0, 'Team: kein Speichern bei den Feiertagen')
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Ansehen: Anna').click()
|
||||
const anna = formular(page, 'Person oder Platz: Anna')
|
||||
eq(await alleGesperrt(anna), true, 'Team: Person gesperrt')
|
||||
eq(await anna.getByLabel('Montag', { exact: true }).inputValue(), '09:00–12:30, 13:30–18:00', 'Team: Wochenplan sichtbar')
|
||||
eq(await anna.getByRole('button').allInnerTexts(), ['Schließen'], 'Team: nur Schließen')
|
||||
eq(await page.locator('#tm-panel .tm-feed').count(), 0, 'Team: kein Kalender-Link (Server liefert keine)')
|
||||
await shot(page, 'abgeschaltet-team')
|
||||
// Einstellungen
|
||||
await page.locator('[data-reiter="einstellungen"]').click()
|
||||
const form = page.getByRole('form', { name: 'Einstellungen' })
|
||||
await form.waitFor()
|
||||
eq(await alleGesperrt(form), true, 'Einstellungen: alle Felder gesperrt')
|
||||
eq(await knopf(page, 'Einstellungen speichern').count(), 0, 'Einstellungen: kein Speichern')
|
||||
eq(await knopf(form, 'Muster in das Textfeld übernehmen').count() + await knopf(form, '+ Zusatzfeld').count(), 0, 'Einstellungen: keine Änderungsknöpfe')
|
||||
eq(await page.getByRole('link', { name: /CSV/ }).count(), 1, 'Einstellungen: Export bleibt')
|
||||
assert((await textVon(page.locator('#tm-panel .tm-status'))).includes('Abgeschaltet'), 'Einstellungen: Status')
|
||||
await shot(page, 'abgeschaltet-einstellungen')
|
||||
// die Schnittstelle verweigert Schreiben auch direkt
|
||||
const status = await page.evaluate(async ([projectId, v, einstellung]) => (await fetch('/api/termine/einstellungen', {
|
||||
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId, version: v, einstellung }),
|
||||
})).status, ['p1', v0, gespeichert()])
|
||||
eq(status, 409, 'Server: Speichern bei abgeschalteter Funktion')
|
||||
eq(version(), v0, 'nichts gespeichert')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ App-Ansicht 390 px: die drei neuen Reiter
|
||||
await check('echt_390_leistungen_team_einstellungen', async (page) => {
|
||||
await oeffne(page, '#funktionen')
|
||||
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
|
||||
await warteAnsicht(page)
|
||||
// jeder Knopf, jedes Auswahlfeld und jede Zeile zum Ankreuzen ist mindestens 44 px hoch (Touch)
|
||||
const kleineZiele = () => page.evaluate(() => [...document.querySelectorAll('#tm-panel button, #tm-panel select, #tm-panel textarea, #tm-panel label.tm-haken, #tm-panel input:not([type=checkbox]):not([type=radio])')]
|
||||
.filter((e) => e.offsetParent !== null)
|
||||
.map((e) => [e.tagName.toLowerCase(), (e.textContent.trim() || e.getAttribute('aria-label') || e.className).slice(0, 40), Math.round(e.getBoundingClientRect().height)])
|
||||
.filter(([, , hoehe]) => hoehe < 44))
|
||||
const pruefe = async (was) => {
|
||||
eq(await passtInBreite(page), true, `${was}: kein Streifen rechts`)
|
||||
eq(await kleineZiele(), [], `${was}: Touch-Ziele mindestens 44 px`)
|
||||
}
|
||||
// Leistungen
|
||||
await page.locator('[data-reiter="leistungen"]').click()
|
||||
await page.locator('#tm-panel li.tm-eintrag').first().waitFor()
|
||||
await pruefe('Leistungen')
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Reiter markiert')
|
||||
await shot(page, '390-8-leistungen')
|
||||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||||
await formular(page, 'Leistung bearbeiten: Herrenschnitt').waitFor()
|
||||
await pruefe('Leistung bearbeiten')
|
||||
await zeileMitId(page, S_HERREN).scrollIntoViewIfNeeded()
|
||||
await shot(page, '390-9-leistung-bearbeiten')
|
||||
// Team & Plaetze
|
||||
await page.locator('[data-reiter="team"]').click()
|
||||
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
|
||||
await pruefe('Team')
|
||||
await shot(page, '390-10-team')
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
|
||||
await zeileMitId(page, R_ANNA).locator('.tm-feed').waitFor()
|
||||
await pruefe('Person bearbeiten mit Kalender-Link')
|
||||
await knopf(zeileMitId(page, R_ANNA), 'Neuen Link erzeugen').click()
|
||||
await page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }).waitFor()
|
||||
await pruefe('Rückfrage zum Kalender-Link')
|
||||
await zeileMitId(page, R_ANNA).scrollIntoViewIfNeeded()
|
||||
await shot(page, '390-11-team-bearbeiten')
|
||||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||||
await formular(page, 'Sonderöffnung eintragen').waitFor()
|
||||
await knopf(page, '+ Abwesenheit eintragen').click()
|
||||
await formular(page, 'Abwesenheit eintragen').waitFor()
|
||||
await pruefe('Formulare für Ausnahmen')
|
||||
// Einstellungen
|
||||
await page.locator('[data-reiter="einstellungen"]').click()
|
||||
await page.getByRole('form', { name: 'Einstellungen' }).waitFor()
|
||||
await pruefe('Einstellungen')
|
||||
await knopf(page, '+ Zusatzfeld').click()
|
||||
await pruefe('Einstellungen mit Zusatzfeld')
|
||||
await shot(page, '390-12-einstellungen')
|
||||
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'App-Ansicht Terminplaner')
|
||||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
|
||||
// ------------------------------------------------------------ Meldung kommt nach dem Wechsel der Website (Fixture, Ruling R50)
|
||||
await check('fx_meldung_nach_projektwechsel', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
const zeile = reihe(page, 'Lea Braun')
|
||||
await knopf(zeile, 'Annehmen').click()
|
||||
await knopf(zeile.locator('.tm-frage-box'), 'Annehmen').click()
|
||||
// die Antwort braucht 1,2 s; in der Zwischenzeit zur anderen Website wechseln
|
||||
await page.locator('#termine-project').selectOption('p2')
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
await page.waitForFunction(() => document.querySelector('#termine-project')?.value === 'p2')
|
||||
await page.waitForTimeout(2000)
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 1, 'die Aktion wurde abgeschickt')
|
||||
eq(await meldung(page).count(), 0, 'keine Meldung der alten Website unter der neuen')
|
||||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl p2')
|
||||
}, {
|
||||
fixture: (() => {
|
||||
const fx = neueFixture()
|
||||
fx.projekte = [
|
||||
{ projectId: 'p1', projectName: 'Erste Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||||
{ projectId: 'p2', projectName: 'Zweite Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||||
]
|
||||
fx.buchungen = alleStatus()
|
||||
fx.aktionVerzoegerung = 1200
|
||||
return fx
|
||||
})(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lauf
|
||||
|
||||
Reference in new issue
Block a user