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
+278
-11
@@ -5,7 +5,18 @@ import {
|
||||
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
|
||||
fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
|
||||
} from '../public/termine-view.js'
|
||||
import {
|
||||
MAX, BUNDESLAENDER, ERINNERUNG_STUNDEN, FENSTER_FEHLER, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, RASTER_MIN, WOCHENTAGE_PLAN, ZAHLARTEN,
|
||||
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
|
||||
} from '../public/termine-view.js'
|
||||
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
|
||||
import { exportUrl, request } from '../public/termine-dom.js'
|
||||
import { beschrifteFehler } from '../public/termine-speichern.js'
|
||||
import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js'
|
||||
import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js'
|
||||
import { VORLAGEN, ausVorlage } from '../server/services/termine/vorlagen.js'
|
||||
|
||||
const JETZT_SERVER = new Date('2026-10-05T08:00:00.000Z') // feste Uhr für Aufrufe der Server-Prüfung
|
||||
|
||||
// Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird
|
||||
// nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit.
|
||||
@@ -81,11 +92,10 @@ test('bereichFuer und verschiebeTag je Ansicht', () => {
|
||||
assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' })
|
||||
assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' })
|
||||
assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' })
|
||||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-04' }, 'ab heute bis zum Ende des Buchungszeitraums (Vorgabe 60 Tage)')
|
||||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 180), { von: '2026-10-05', bis: '2027-04-03' }, 'Buchungszeitraum der Einstellung')
|
||||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 365), { von: '2026-10-05', bis: '2027-10-05' })
|
||||
assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 9999).bis, '2027-10-05', 'höchstens 365 Tage')
|
||||
for (const kaputt of [0, -5, NaN, 'x', null, undefined]) assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', kaputt).bis, '2026-12-04', `ungültiger Zeitraum ${String(kaputt)} ⇒ Vorgabe`)
|
||||
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (so lange kann eine Anfrage nicht in der Zukunft liegen), unabhängig vom Buchungszeitraum
|
||||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2027-10-05' }, 'ab heute 365 Tage')
|
||||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 30), { von: '2026-10-05', bis: '2027-10-05' }, 'ein kurzer Buchungszeitraum begrenzt die Liste nicht')
|
||||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2028-02-28'), { von: '2028-02-28', bis: '2029-02-27' }, 'Schaltjahr: 365 Kalendertage')
|
||||
assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01')
|
||||
assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30')
|
||||
assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12')
|
||||
@@ -261,33 +271,65 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen eine
|
||||
|
||||
test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => {
|
||||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort()
|
||||
assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js'])
|
||||
assert.deepEqual(dateien, [
|
||||
'termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js',
|
||||
'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-termine.js', 'termine-team-ausnahmen.js', 'termine-view.js', 'termine.js',
|
||||
])
|
||||
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||||
const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))])
|
||||
// Nur termine-gaestetexte.js schreibt für Gäste (Mustertext, Auswahl der Anrede) und darf deshalb die Sie-Form enthalten
|
||||
const GAESTETEXTE = 'termine-gaestetexte.js'
|
||||
for (const [datei, quelle] of quellen) {
|
||||
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
|
||||
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
|
||||
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
|
||||
if (datei !== GAESTETEXTE) assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
|
||||
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
|
||||
}
|
||||
assert.match(quellen.find(([f]) => f === GAESTETEXTE)[1], /\bSie\b/, 'die Ausnahme hat einen Grund')
|
||||
// Links nur auf die eigene Export-Schnittstelle (exportUrl), nie auf Kundentext
|
||||
const hrefs = quellen.flatMap(([datei, quelle]) => (quelle.match(/href: [^,}]+|setAttribute\('href'/g) || []).map((m) => `${datei}: ${m}`))
|
||||
assert.deepEqual(hrefs, ['termine.js: href: exportUrl(projectId)'])
|
||||
assert.deepEqual(hrefs, ['termine-dom.js: href: exportUrl(projectId)'])
|
||||
const dom = quellen.find(([f]) => f === 'termine-dom.js')[1]
|
||||
assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID')
|
||||
// Speichern der Einstellungen gibt es genau einmal (termine-speichern.js), nicht in jedem Reiter
|
||||
assert.deepEqual(quellen.filter(([, quelle]) => /'PATCH'/.test(quelle)).map(([datei]) => datei), ['termine-speichern.js'])
|
||||
})
|
||||
|
||||
test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => {
|
||||
const huelle = lies('public/termine.js')
|
||||
assert.match(huelle, /import \{ render as renderTermine \} from '\.\/termine-tab-termine\.js'/)
|
||||
for (const tab of ['termine', 'leistungen', 'team', 'einstellungen']) {
|
||||
assert.match(huelle, new RegExp(`import \\{ render as render${tab[0].toUpperCase()}${tab.slice(1)} \\} from '\\./termine-tab-${tab}\\.js'`))
|
||||
assert.match(lies(`public/termine-tab-${tab}.js`), /export function render\(panel, ctx\)/)
|
||||
}
|
||||
assert.match(huelle, /const REITER = \[/)
|
||||
assert.match(lies('public/termine-tab-termine.js'), /export function render\(panel, ctx\)/)
|
||||
assert.deepEqual([...huelle.matchAll(/\{ key: '(\w+)', label: '([^']+)', render: (\w+) \}/g)].map((m) => [m[1], m[2], m[3]]), [
|
||||
['termine', 'Termine', 'renderTermine'], ['leistungen', 'Leistungen', 'renderLeistungen'], ['team', 'Team & Plätze', 'renderTeam'], ['einstellungen', 'Einstellungen', 'renderEinstellungen'],
|
||||
])
|
||||
// Reiter lesen Zustand nur aus ctx, nicht aus der Huelle
|
||||
for (const datei of ['termine-tab-termine.js', 'termine-aktionen.js', 'termine-formulare.js']) {
|
||||
for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js']) {
|
||||
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
|
||||
}
|
||||
})
|
||||
|
||||
test('beschrifteFehler: Fehler ohne Platz am Feld nennen den Eintrag', () => {
|
||||
const quellen = [{
|
||||
leistungen: [{ id: 's_aaaaaaaa', name: 'Herrenschnitt' }, { id: 's_bbbbbbbb', name: 'Färben' }],
|
||||
ressourcen: [{ id: 'r_aaaaaaaa', name: 'Anna', typ: 'person' }, { id: 'r_bbbbbbbb', name: 'Kabine 1', typ: 'platz' }],
|
||||
abwesenheiten: [{ id: 'a_aaaaaaaa', titel: 'Betriebsurlaub' }],
|
||||
}]
|
||||
assert.equal(beschrifteFehler('leistungen.1.ressourcen', 'Eine aktive Leistung braucht …', quellen), 'Leistung „Färben“: Eine aktive Leistung braucht …', 'nach Stelle')
|
||||
assert.equal(beschrifteFehler('leistungen.s_aaaaaaaa', 'Für diese Leistung gibt es noch 1 künftigen Termin.', quellen), 'Leistung „Herrenschnitt“: Für diese Leistung gibt es noch 1 künftigen Termin.', 'nach ID')
|
||||
assert.equal(beschrifteFehler('ressourcen.r_bbbbbbbb', 'Für diesen Platz …', quellen), 'Platz „Kabine 1“: Für diesen Platz …')
|
||||
assert.equal(beschrifteFehler('ressourcen.0.plan.3', 'Zeitfenster überlappen sich.', quellen), 'Person „Anna“: Zeitfenster überlappen sich.')
|
||||
assert.equal(beschrifteFehler('abwesenheiten.0.bis', 'Das Ende …', quellen), 'Abwesenheit „Betriebsurlaub“: Das Ende …')
|
||||
assert.equal(beschrifteFehler('leistungen.7.name', 'Bitte gib einen Namen an.', quellen), 'Bitte gib einen Namen an.', 'unbekannter Eintrag: Text unverändert')
|
||||
assert.equal(beschrifteFehler('regeln.raster', 'Das Raster …', quellen), 'Das Raster …')
|
||||
assert.equal(beschrifteFehler('betrieb.adresse', 'Bitte gib die Anschrift an.', quellen), 'Bitte gib die Anschrift an. Das änderst du im Reiter „Einstellungen“.', 'Betriebsangaben stehen woanders')
|
||||
assert.equal(beschrifteFehler('_', 'Zu umfangreich.', quellen), 'Zu umfangreich.')
|
||||
// gesucht wird in allen Quellen (der alte Stand kennt Einträge, die der Entwurf nicht mehr hat)
|
||||
assert.equal(beschrifteFehler('leistungen.s_cccccccc', 'x', [{ leistungen: [] }, { leistungen: [{ id: 's_cccccccc', name: 'Alt' }] }]), 'Leistung „Alt“: x')
|
||||
})
|
||||
|
||||
test('request: Netzwerkfehler auf Deutsch, Fehler der Schnittstelle mit Feldern und Code', async () => {
|
||||
const vorher = globalThis.fetch
|
||||
try {
|
||||
@@ -306,3 +348,228 @@ test('exportUrl: Projekt-ID wird codiert', () => {
|
||||
assert.equal(exportUrl('p1'), '/api/termine/export.csv?projectId=p1')
|
||||
assert.equal(exportUrl('a&b=c d'), '/api/termine/export.csv?projectId=a%26b%3Dc%20d')
|
||||
})
|
||||
|
||||
// ---- Einstellungen bearbeiten (Aufgabe 21b): Wochenplan, IDs, Preise, Spiegel der Server-Grenzen
|
||||
test('Wochenplan: Text je Wochentag ⇔ Plan der Einstellung', () => {
|
||||
const plan = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
|
||||
assert.deepEqual(WOCHENTAGE_PLAN.map(([nr, name]) => `${nr}${name}`), ['1Montag', '2Dienstag', '3Mittwoch', '4Donnerstag', '5Freitag', '6Samstag', '7Sonntag'])
|
||||
assert.deepEqual(planTexte(plan), { 1: '09:00–12:30, 13:30–18:00', 2: '', 3: '', 4: '', 5: '', 6: '09:00–13:00', 7: '' })
|
||||
assert.deepEqual(planTexte(undefined), { 1: '', 2: '', 3: '', 4: '', 5: '', 6: '', 7: '' })
|
||||
assert.deepEqual(planAusTexten(planTexte(plan)), { plan }, 'Umkehrung')
|
||||
assert.deepEqual(planAusTexten({ 1: ' ', 2: '8:00-12:00 und 14:00 bis 24:00' }), { plan: { 2: [['08:00', '12:00'], ['14:00', '24:00']] } }, 'leer = geschlossen, Schreibweisen werden vereinheitlicht')
|
||||
assert.deepEqual(planAusTexten({}), { plan: {} })
|
||||
assert.deepEqual(planAusTexten(undefined), { plan: {} })
|
||||
assert.deepEqual(planAusTexten({ 3: '9-12', 5: '09:00–09:00', 1: '09:00–10:00' }), { fehler: { 3: FENSTER_FEHLER, 5: FENSTER_FEHLER } }, 'Fehler je Tag, kein Teilplan')
|
||||
assert.match(FENSTER_FEHLER, /09:00–12:30, 13:30–18:00/)
|
||||
})
|
||||
|
||||
test('Wochenplan aus dem Formular nimmt der Server an (5-Minuten-Raster, aufsteigend, bis 24:00)', () => {
|
||||
const e = ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '1', adresse: 'Str. 1' }, JETZT_SERVER)
|
||||
e.ressourcen[0].plan = planAusTexten({ 1: '07:05–12:00, 13:00–24:00', 7: '10:00–11:00' }).plan
|
||||
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
|
||||
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
|
||||
assert.deepEqual(erg.einstellung.ressourcen[0].plan, { 1: [['07:05', '12:00'], ['13:00', '24:00']], 7: [['10:00', '11:00']] })
|
||||
})
|
||||
|
||||
test('neueId: Präfix + 8 Zeichen a–z0–9, mit crypto.getRandomValues (Ruling R8)', () => {
|
||||
for (const p of ['r', 's', 'a']) for (let i = 0; i < 50; i += 1) assert.match(neueId(p), new RegExp(`^${p}_[a-z0-9]{8}$`))
|
||||
assert.equal(new Set(Array.from({ length: 2000 }, () => neueId('s'))).size, 2000, 'Zufall, keine Wiederholung')
|
||||
assert.throws(() => neueId('x'), /Präfix/)
|
||||
// Bytes ab 252 werden verworfen (252 = 7 · 36: sonst wären die ersten Zeichen häufiger); der Rest wird modulo 36 abgebildet
|
||||
assert.equal(neueId('r', () => Uint8Array.from([255, 252, 253, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])), 'r_abcdefgh')
|
||||
assert.equal(neueId('s', () => Uint8Array.from([35, 36, 71, 251, 250, 249, 248, 247, 0, 0, 0, 0, 0, 0, 0, 0])), 's_9a998765')
|
||||
// zu wenige brauchbare Bytes ⇒ es wird nachgezogen
|
||||
let aufrufe = 0
|
||||
const nurSpaeterBrauchbar = () => {
|
||||
aufrufe += 1
|
||||
return Uint8Array.from({ length: 16 }, (_, i) => (aufrufe === 1 ? 255 : i))
|
||||
}
|
||||
assert.equal(neueId('a', nurSpaeterBrauchbar), 'a_abcdefgh')
|
||||
assert.equal(aufrufe, 2)
|
||||
})
|
||||
|
||||
test('eindeutigeId: nie eine ID, die es in der Liste schon gibt', () => {
|
||||
const folgen = [Array.from({ length: 16 }, (_, i) => i), Array.from({ length: 16 }, (_, i) => i + 8)]
|
||||
let k = 0
|
||||
const bytes = () => Uint8Array.from(folgen[k++])
|
||||
assert.equal(eindeutigeId('r', ['r_abcdefgh'], bytes), 'r_ijklmnop')
|
||||
assert.equal(k, 2)
|
||||
assert.match(eindeutigeId('s', []), /^s_[a-z0-9]{8}$/)
|
||||
})
|
||||
|
||||
test('Preis: Euro-Text ⇔ Cent (deutsche Schreibweise), Anzeige wie beim Server', () => {
|
||||
const gut = { '45': 4500, '45,00': 4500, '45,5': 4550, '45.50': 4550, '1.234,50': 123450, '1.234': 123400, '1234,5': 123450, ' 19,99 € ': 1999, '0': 0, '0,05': 5, '10000': 1000000, '10000,01': 1000001, '12.5': 1250 }
|
||||
for (const [text, cent] of Object.entries(gut)) assert.equal(euroZuCent(text), cent, text)
|
||||
for (const schlecht of ['', ' ', 'abc', ',50', '12,345', '1,2,3', '-5', '1.2.3', '1e3', '99999999999', '4 5', null, undefined, 45]) assert.equal(euroZuCent(schlecht), null, String(schlecht))
|
||||
assert.equal(centZuEuro(4500), '45,00')
|
||||
assert.equal(centZuEuro(5), '0,05')
|
||||
assert.equal(centZuEuro(123450), '1234,50')
|
||||
assert.equal(centZuEuro(0), '0,00')
|
||||
assert.equal(centZuEuro(-1), '')
|
||||
assert.equal(centZuEuro(1.5), '')
|
||||
assert.equal(centZuEuro(undefined), '')
|
||||
assert.equal(euroZuCent(centZuEuro(123456)), 123456, 'Hin und zurück')
|
||||
for (const l of [{ preisArt: 'fest', preisCent: 4500 }, { preisArt: 'ab', preisCent: 4500 }, { preisArt: 'fest', preisCent: 123450 }, { preisArt: 'ab', preisCent: 5 }]) {
|
||||
assert.equal(preisAnzeige(l), preisText(l), `wie preisText des Servers: ${JSON.stringify(l)}`)
|
||||
}
|
||||
assert.equal(preisAnzeige({ preisArt: 'keiner', preisCent: 0 }), 'ohne Preis')
|
||||
assert.equal(preisAnzeige({ preisArt: 'fest', preisCent: 0 }), 'ohne Preis')
|
||||
assert.equal(preisAnzeige(undefined), 'ohne Preis')
|
||||
})
|
||||
|
||||
test('ganzzahl: nur ganze Zahlen aus Feldern, sonst null', () => {
|
||||
assert.equal(ganzzahl('45'), 45)
|
||||
assert.equal(ganzzahl(' 45 '), 45)
|
||||
assert.equal(ganzzahl('0'), 0)
|
||||
assert.equal(ganzzahl('-5'), -5)
|
||||
for (const schlecht of ['', ' ', '4.5', '4,5', 'abc', '1e3', '9999999999', null, undefined, 5]) assert.equal(ganzzahl(schlecht), null, String(schlecht))
|
||||
})
|
||||
|
||||
test('zusatzSchluessel: Schlüssel aus der Beschriftung, eindeutig, vom Server erlaubt', () => {
|
||||
assert.equal(zusatzSchluessel('Wunschfarbe', []), 'wunschfarbe')
|
||||
assert.equal(zusatzSchluessel('Größe in cm', []), 'groesse_in_cm')
|
||||
assert.equal(zusatzSchluessel(' Haar-Länge (Straße) ', []), 'haar_laenge_strasse')
|
||||
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge']), 'haarlaenge_2')
|
||||
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge', 'haarlaenge_2']), 'haarlaenge_3')
|
||||
assert.equal(zusatzSchluessel('!!!', []), 'feld')
|
||||
assert.equal(zusatzSchluessel('', ['feld']), 'feld_2')
|
||||
const lang = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [])
|
||||
assert.match(lang, /^[a-z0-9_]{1,20}$/)
|
||||
const langZwei = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [lang])
|
||||
assert.match(langZwei, /^[a-z0-9_]{1,20}$/)
|
||||
assert.notEqual(langZwei, lang)
|
||||
for (const reserviert of ['constructor', 'prototype', '__proto__']) {
|
||||
const key = zusatzSchluessel(reserviert, [])
|
||||
assert.match(key, /^[a-z0-9_]{1,20}$/)
|
||||
assert.ok(!['__proto__', 'constructor', 'prototype'].includes(key), `${reserviert} ⇒ ${key}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Auswahlmöglichkeiten: eine je Zeile', () => {
|
||||
assert.deepEqual(optionenAusText('Rot\n Blau \r\n\nGrün\n'), ['Rot', 'Blau', 'Grün'])
|
||||
assert.deepEqual(optionenAusText(''), [])
|
||||
assert.deepEqual(optionenAusText(undefined), [])
|
||||
assert.equal(optionenText(['Rot', 'Blau']), 'Rot\nBlau')
|
||||
assert.equal(optionenText(undefined), '')
|
||||
})
|
||||
|
||||
test('verschiebeEintrag: Reihenfolge ändern, ohne die Liste zu verändern', () => {
|
||||
const liste = ['a', 'b', 'c']
|
||||
assert.deepEqual(verschiebeEintrag(liste, 1, -1), ['b', 'a', 'c'])
|
||||
assert.deepEqual(verschiebeEintrag(liste, 0, 1), ['b', 'a', 'c'])
|
||||
assert.deepEqual(verschiebeEintrag(liste, 2, 1), ['a', 'b', 'c'], 'am Ende nach unten: unverändert')
|
||||
assert.deepEqual(verschiebeEintrag(liste, 0, -1), ['a', 'b', 'c'], 'am Anfang nach oben: unverändert')
|
||||
assert.deepEqual(verschiebeEintrag(liste, 7, 1), ['a', 'b', 'c'], 'unbekannte Stelle')
|
||||
assert.deepEqual(liste, ['a', 'b', 'c'])
|
||||
assert.notEqual(verschiebeEintrag(liste, 0, -1), liste, 'immer eine Kopie')
|
||||
})
|
||||
|
||||
test('aktivSeitText: Datum nach Berliner Kalender', () => {
|
||||
assert.equal(aktivSeitText('2026-10-01T09:00:00.000Z'), 'Aktiv seit 01.10.2026')
|
||||
assert.equal(aktivSeitText('2026-09-30T22:30:00.000Z'), 'Aktiv seit 01.10.2026', 'nach Mitternacht in Berlin')
|
||||
assert.equal(aktivSeitText('2026-09-30T21:30:00.000Z'), 'Aktiv seit 30.09.2026')
|
||||
for (const schlecht of ['', 'gestern', null, undefined, 0]) assert.equal(aktivSeitText(schlecht), '', String(schlecht))
|
||||
})
|
||||
|
||||
test('Hinweis zu Gesundheitsangaben: Wortlaut wie bei den Gästen', () => {
|
||||
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
|
||||
})
|
||||
|
||||
// ---- Spiegel der Auswahl und Grenzen des Servers (einstellungen.js): driftet eine Seite, wird dieser Test rot
|
||||
const basisEinstellung = () => ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '06301 1', adresse: 'Hauptstr. 1' }, JETZT_SERVER)
|
||||
/** Fehler des Servers für eine geänderte Einstellung ({} = gültig). */
|
||||
const serverFehler = (aendere) => {
|
||||
const e = basisEinstellung()
|
||||
aendere(e)
|
||||
return normalisiereEinstellung(e, { now: JETZT_SERVER }).fehler || {}
|
||||
}
|
||||
|
||||
test('Spiegel: Auswahllisten sind die des Servers', () => {
|
||||
assert.deepEqual(RASTER_MIN, [5, 10, 15, 20, 30, 60])
|
||||
for (const r of RASTER_MIN) assert.equal(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], undefined, `Raster ${r}`)
|
||||
for (const r of [0, 7, 45, 90]) assert.ok(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], `Raster ${r} gibt es nicht`)
|
||||
assert.deepEqual(ERINNERUNG_STUNDEN, [2, 12, 24, 48])
|
||||
for (const n of ERINNERUNG_STUNDEN) assert.equal(serverFehler((e) => { e.erinnerung.stundenVorher = n })['erinnerung.stundenVorher'], undefined, `${n} Stunden`)
|
||||
assert.ok(serverFehler((e) => { e.erinnerung.stundenVorher = 3 })['erinnerung.stundenVorher'])
|
||||
assert.deepEqual(GAST_FELD_WAHL.map(([key]) => key), ['aus', 'optional', 'pflicht'])
|
||||
for (const [key] of GAST_FELD_WAHL) {
|
||||
assert.deepEqual(serverFehler((e) => { e.gastFelder.telefon = key; e.gastFelder.nachricht = key }), {}, key)
|
||||
}
|
||||
assert.deepEqual(new Set(BUNDESLAENDER.map(([key]) => key)), new Set(SERVER_BUNDESLAENDER), 'alle 16 Bundesländer, keines zu viel')
|
||||
assert.equal(BUNDESLAENDER.length, 16)
|
||||
assert.deepEqual(BUNDESLAENDER.map(([, name]) => name), [...BUNDESLAENDER.map(([, name]) => name)].sort((a, b) => a.localeCompare(b, 'de')), 'nach Namen sortiert')
|
||||
for (const [key] of BUNDESLAENDER) assert.deepEqual(serverFehler((e) => { e.bundesland = key }), {}, key)
|
||||
assert.deepEqual(ANREDE_WAHL.map(([key]) => key), ['sie', 'du'])
|
||||
for (const [key] of ANREDE_WAHL) assert.deepEqual(serverFehler((e) => { e.anrede = key }), {}, key)
|
||||
})
|
||||
|
||||
test('Spiegel: Zahlarten und ihre Namen sind die des Servers', () => {
|
||||
const keys = ZAHLARTEN.map(([key]) => key)
|
||||
assert.deepEqual(keys, ['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal'])
|
||||
const e = basisEinstellung()
|
||||
e.zahlung.vorOrtArten = keys
|
||||
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
|
||||
assert.equal(erg.fehler, undefined)
|
||||
assert.deepEqual(erg.einstellung.zahlung.vorOrtArten, keys, 'Reihenfolge wie beim Server')
|
||||
assert.equal(zahlungText(erg.einstellung), `Bezahlung vor Ort: ${ZAHLARTEN.map(([, name]) => name).join(', ')}`)
|
||||
assert.ok(serverFehler((x) => { x.zahlung.vorOrtArten = ['bitcoin'] })['zahlung.vorOrtArten'])
|
||||
})
|
||||
|
||||
test('Spiegel: Obergrenzen der Listen sind die des Servers', () => {
|
||||
assert.deepEqual(LIMITS, { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 })
|
||||
const tag = (i) => new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10)
|
||||
const faelle = {
|
||||
ressourcen: [LIMITS.ressourcen, 'ressourcen', (e, n) => { e.leistungen = []; e.abwesenheiten = []; e.ressourcen = Array.from({ length: n }, (_, i) => ({ id: neueId('r'), typ: 'person', name: `P${i}`, aktiv: true, plan: {} })) }],
|
||||
leistungen: [LIMITS.leistungen, 'leistungen', (e, n) => { e.leistungen = Array.from({ length: n }, (_, i) => ({ ...e.leistungen[0], id: neueId('s'), name: `L${i}` })) }],
|
||||
abwesenheiten: [LIMITS.abwesenheiten, 'abwesenheiten', (e, n) => { e.abwesenheiten = Array.from({ length: n }, (_, i) => ({ id: neueId('a'), ressourceId: null, von: tag(i), bis: tag(i), titel: '' })) }],
|
||||
sonderoeffnungen: [LIMITS.sonderoeffnungen, 'sonderoeffnungen', (e, n) => { e.sonderoeffnungen = Object.fromEntries(Array.from({ length: n }, (_, i) => [tag(i), []])) }],
|
||||
zusatzfelder: [LIMITS.zusatzfelder, 'gastFelder.zusatz', (e, n) => { e.gastFelder.zusatz = Array.from({ length: n }, (_, i) => ({ key: `f${i}`, label: `F${i}`, typ: 'text', optionen: [], pflicht: false })) }],
|
||||
optionen: [LIMITS.optionen, 'gastFelder.zusatz.0.optionen', (e, n) => { e.gastFelder.zusatz = [{ key: 'wahl', label: 'Wahl', typ: 'auswahl', optionen: Array.from({ length: n }, (_, i) => `O${i}`), pflicht: false }] }],
|
||||
}
|
||||
for (const [name, [grenze, pfad, baue]] of Object.entries(faelle)) {
|
||||
assert.equal(serverFehler((e) => baue(e, grenze))[pfad], undefined, `${name}: ${grenze} sind erlaubt`)
|
||||
assert.ok(serverFehler((e) => baue(e, grenze + 1))[pfad], `${name}: ${grenze + 1} sind zu viele`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Spiegel: Längen der Eingabefelder (maxlength) kürzt der Server nicht mehr', () => {
|
||||
const lang = (n) => 'x'.repeat(n)
|
||||
const lies = (e, was) => was(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung)
|
||||
const faelle = [
|
||||
['betriebName', (e, t) => { e.betrieb.name = t }, (x) => x.betrieb.name],
|
||||
['telefon', (e, t) => { e.betrieb.telefon = t }, (x) => x.betrieb.telefon],
|
||||
['adresse', (e, t) => { e.betrieb.adresse = t }, (x) => x.betrieb.adresse],
|
||||
['leistungName', (e, t) => { e.leistungen[0].name = t }, (x) => x.leistungen[0].name],
|
||||
['beschreibung', (e, t) => { e.leistungen[0].beschreibung = t }, (x) => x.leistungen[0].beschreibung],
|
||||
['ressourcenName', (e, t) => { e.ressourcen[0].name = t }, (x) => x.ressourcen[0].name],
|
||||
['abwesenheitTitel', (e, t) => { e.abwesenheiten = [{ id: 'a_abcd1234', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: t }] }, (x) => x.abwesenheiten[0].titel],
|
||||
['zusatzLabel', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: t, typ: 'text', optionen: [], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].label],
|
||||
['option', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: 'K', typ: 'auswahl', optionen: [t], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].optionen[0]],
|
||||
['storno', (e, t) => { e.storno = { text: t } }, (x) => x.storno.text],
|
||||
]
|
||||
for (const [name, setze, lese] of faelle) {
|
||||
const grenze = MAX[name === 'telefon' ? 'telefon' : name]
|
||||
assert.ok(Number.isInteger(grenze), `MAX.${name}`)
|
||||
const e = basisEinstellung()
|
||||
setze(e, lang(grenze))
|
||||
assert.equal(lies(e, lese).length, grenze, `${name}: ${grenze} Zeichen bleiben ganz`)
|
||||
const zuLang = basisEinstellung()
|
||||
setze(zuLang, lang(grenze + 1))
|
||||
assert.equal(lies(zuLang, lese).length, grenze, `${name}: mehr als ${grenze} würden gekürzt`)
|
||||
}
|
||||
assert.equal(MAX.url, 300)
|
||||
const url = (n) => `https://example.org/${'a'.repeat(n - 'https://example.org/'.length)}`
|
||||
assert.equal(serverFehler((e) => { e.betrieb.impressumUrl = url(300) })['betrieb.impressumUrl'], undefined)
|
||||
assert.ok(serverFehler((e) => { e.betrieb.impressumUrl = url(301) })['betrieb.impressumUrl'])
|
||||
})
|
||||
|
||||
test('Mustertext Storno (Gästetext): wie die Vorlagen des Servers, Frist einsetzbar', () => {
|
||||
assert.equal(stornoMuster(24), VORLAGEN.salon.storno, 'derselbe Wortlaut wie in den Vorlagen')
|
||||
assert.ok(stornoMuster(48).includes('bis 48 Stunden vorher'))
|
||||
assert.ok(stornoMuster(48).includes('50 % des Preises'))
|
||||
for (const unbrauchbar of [0, -1, 1.5, NaN, 'x', null, undefined]) assert.equal(stornoMuster(unbrauchbar), stornoMuster(24), String(unbrauchbar))
|
||||
const e = basisEinstellung()
|
||||
e.storno = { text: stornoMuster(36) }
|
||||
assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht')
|
||||
assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)'])
|
||||
})
|
||||
+775
-17
@@ -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