// @ts-check const { test, expect } = require('@playwright/test') const { komboboxWaehlen } = require('./hilfen') const BENUTZER = process.env.E2E_USER || 'e2e' const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' const MARKE = Date.now().toString().slice(-6) const GIRO = `E2E Giro ${MARKE}` const SPAR = `E2E Spar ${MARKE}` const KATEGORIE = `E2E Einkauf ${MARKE}` const KLASSE = `E2E Privat ${MARKE}` async function anmelden(page) { await page.goto('/login') if (page.url().includes('/einrichtung')) { throw new Error( 'Instanz ist noch nicht eingerichtet: ' + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' ) } await page.fill('#benutzername', BENUTZER) await page.fill('#passwort', PASSWORT) await page.click('button[type="submit"]') await expect(page).toHaveURL(/\/$/) } /** Legt ein Konto an und liefert dessen Kennung aus der URL. */ async function kontoAnlegen(page, name, anfangssaldo) { await page.goto('/konten/neu') await page.fill('#name', name) await page.fill('#anfangssaldo', anfangssaldo) await page.click('.formular button[type="submit"]') await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) const treffer = page.url().match(/\/konten\/(\d+)\//) return treffer ? treffer[1] : null } test.describe('Buchungen', () => { let giroId = null let sparId = null test.beforeAll(async ({ browser }) => { const page = await browser.newPage() await anmelden(page) giroId = await kontoAnlegen(page, GIRO, '1.000,00') sparId = await kontoAnlegen(page, SPAR, '5.000,00') await page.goto('/verwaltung/kategorien/neu') await page.fill('#name', KATEGORIE) await page.selectOption('#art', 'ausgabe') await page.click('.formular button[type="submit"]') await page.goto('/verwaltung/klassen') await page.click('#neu-knopf') await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE) await page.click('form[action="/verwaltung/klassen/neu"] button.primaer') await page.close() }) test.beforeEach(async ({ page }) => { await anmelden(page) }) test('Kontoblatt zeigt den Anfangssaldo', async ({ page }) => { await page.goto(`/konten/${giroId}`) await expect(page.locator('h1')).toContainText(GIRO) await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00') }) // Die Maske nennt oben das Konto, in dem die Buchung liegt, und stellt ihre // Felder zweispaltig – beides, damit man beim Erfassen weder rätselt noch // vertikal scrollt. test('Buchungsmaske nennt das Konto und passt auf einen Bildschirm', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }) await page.goto(`/konten/${giroId}/buchung`) // Das Konto steht in der Maske selbst, nicht nur in der Kopfzeile. await expect(page.locator('.buchungsmaske .feldwert')).toContainText(GIRO) // Gleiche Zeile, zweite Spalte. const art = await page.locator('#art').boundingBox() const datum = await page.locator('#datum').boundingBox() expect(Math.abs(art.y - datum.y)).toBeLessThan(5) expect(datum.x).toBeGreaterThan(art.x + art.width) // Und die Beschriftung steht links neben der Eingabe, nicht darüber. const beschriftung = await page.locator('label[for="betrag"]').boundingBox() const betrag = await page.locator('#betrag').boundingBox() expect(beschriftung.x + beschriftung.width).toBeLessThanOrEqual(betrag.x) expect(Math.abs( (beschriftung.y + beschriftung.height / 2) - (betrag.y + betrag.height / 2) )).toBeLessThan(5) // Die Erklärungen stehen im Tooltip der Beschriftung, nicht als eigene // Zeile darunter – sonst passte die Maske nicht auf einen Bildschirm. await expect(page.locator('label[for="zuordnung"]')).toHaveAttribute('title', /Umbuchung/) await expect(page.locator('label[for="betrag"]')).toHaveAttribute('title', /positiv/) // Und genau das ist der Zweck: Die ganze Maske samt Splitzeilen und // Knopfleiste endet oberhalb der Fensterhöhe. const maske = await page.locator('.buchungsmaske').boundingBox() expect(maske.y + maske.height).toBeLessThan(900) }) test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.selectOption('#art', 'ausgabe') await page.fill('#datum', '2026-03-18') await page.fill('#betrag', '49,90') await page.fill('#empfaenger', 'Supermarkt') await page.selectOption('#zuordnung', { label: KATEGORIE }) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: 'Supermarkt' }) await expect(zeile).toContainText('-49,90') await expect(zeile).toContainText('950,10') await expect(page.locator('.saldoanzeige').first()).toContainText('950,10') }) test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) // Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen // die eigenen Konten in eckigen Klammern. await page.selectOption('#art', 'ausgabe') await page.fill('#datum', '2026-04-01') await page.fill('#betrag', '500') await page.fill('#empfaenger', `Übertrag ${MARKE}`) await page.selectOption('#zuordnung', { label: `[${SPAR}]` }) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() // Ausgangskonto: Abgang, mit dem Gegenkonto in eckigen Klammern. const hin = page.locator('tr', { hasText: `Übertrag ${MARKE}` }) await expect(hin).toContainText('-500,00') await expect(hin).toContainText(`[${SPAR}]`) // Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem // Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung // ohne erkennbares Gegenüber. await page.goto(`/konten/${sparId}`) const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` }) await expect(her).toContainText('500,00') await expect(her).toContainText('Umbuchung') await expect(her).toContainText(`[${GIRO}]`) await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00') // Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die // auf dem anderen Konto liegt und nur dort geändert wird. await her.locator('a.zuordnungsfeld.gegenseite').click() await expect(page).toHaveURL(/\/buchungen\/\d+/) await expect(page.locator('h1')).toContainText('Buchung bearbeiten') await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/) }) test('Kategorie im Kontoblatt zuordnen', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-04-03') await page.fill('#betrag', '12,30') await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`) await page.click('.buchungsmaske button[type="submit"]') await expect(page.locator('.meldung-erfolg')).toBeVisible() const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }) await zeile.locator('a.zuordnungsfeld').click() // Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle. // Sichtbar wird das Formular; das