Finanzmanager/tests/e2e/specs/buchungen.spec.js
fds2610-bot 860e71d1e7 Fix #95: Splitzeilen bleiben bei falscher Summe erhalten
Eine falsche Splitsumme zeigte zwar die Fehlermeldung, leitete aber auf eine
frische Buchungsmaske weiter und riss dabei alle gerade erfassten Splitzeilen
(und übrigen Feldwerte) heraus, obwohl in der Datenbank nichts geändert wurde.
anlegen()/speichern() zeichnen die Maske bei einem Validierungsfehler jetzt
direkt mit den zuletzt abgeschickten Werten neu, statt wegzuleiten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:55:18 +02:00

680 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE)
await page.click('form[action="/verwaltung/klassen/neu"] button[type="submit"]')
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 <select> darin bleibt hinter der
// Kombobox verborgen.
const liste = zeile.locator('#zuordnungsauswahl')
await expect(liste).toBeVisible()
await komboboxWaehlen(liste.locator('select'), KATEGORIE)
// Die Meldung des Anlegens steht noch da: Das Schnellspeichern lädt
// die Seite nicht neu, sondern hängt seine eigene darunter.
await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }))
.toContainText(KATEGORIE)
})
// Fehler #40: Das Speichern lud bisher die ganze Seite neu und riss den
// Bildschirm nach oben – bei einer Buchung weiter unten in der Liste ein
// langer Weg zurück. Die Markierung übersteht nur, wenn nichts neu lädt.
test('Kategorie ändern lädt die Seite nicht neu und lässt die Bildlaufposition stehen', async ({ page }) => {
// Ein niedriges Fenster erzwingt Bildlauf, unabhängig davon, wie viele
// Buchungen die vorigen Tests schon angelegt haben.
await page.setViewportSize({ width: 1280, height: 400 })
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '5,55')
await page.fill('#empfaenger', `Kein Neuladen ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
await page.goto(`/konten/${giroId}`)
await page.evaluate(() => { window.scrollTo(0, 300) })
await page.evaluate(() => { window.__e2eKeinNeuladen = true })
const zeile = page.locator('tr', { hasText: `Kein Neuladen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), KATEGORIE)
await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung')
await expect(zeile).toContainText(KATEGORIE)
// Ein echtes Neuladen hätte sowohl die Markierung gelöscht als auch die
// Bildlaufposition auf null zurückgesetzt.
expect(await page.evaluate(() => window.__e2eKeinNeuladen)).toBe(true)
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
})
test('Klasse im Kontoblatt zuordnen und wieder löschen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-06')
await page.fill('#betrag', '8,80')
await page.fill('#empfaenger', `Ohne Klasse ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Dieselbe Bauart wie bei der Zuordnung: die Liste wandert in die Zelle.
const zeile = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })
await zeile.locator('a.klassenfeld').click()
const liste = zeile.locator('#klassenauswahl select')
await expect(liste).toBeVisible()
await liste.selectOption({ label: KLASSE })
await expect(page.locator('.meldung-erfolg').last()).toContainText('Klasse gespeichert')
await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }))
.toContainText(KLASSE)
// Der erste Eintrag der Liste löscht die vorhandene Klasse wieder.
const danach = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })
await danach.locator('a.klassenfeld').click()
await danach.locator('#klassenauswahl select').selectOption('')
await expect(page.locator('.meldung-erfolg').last()).toContainText('Klasse gelöscht')
await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }))
.not.toContainText(KLASSE)
})
test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-04')
await page.fill('#betrag', '75')
await page.fill('#empfaenger', `Nachträglich ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), `[${SPAR}]`)
await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${SPAR}]`)
// Und die Gegenseite steht sofort auf dem anderen Konto.
await page.goto(`/konten/${sparId}`)
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${GIRO}]`)
})
test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '90')
await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), 'Splitbuchung …')
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('-90,00')
const dialogSpeichern = dialog.locator('button[type="submit"].primaer')
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
// Solange der Restbetrag offen ist, zeigt die Summenzeile ihn an und
// „Splitbuchung speichern“ bleibt gesperrt.
await expect(page.locator('#splitdialogsumme')).toHaveText('-40,00')
await expect(dialogSpeichern).toBeDisabled()
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
await expect(page.locator('#splitdialogsumme')).toHaveText('0,00')
await expect(dialogSpeichern).toBeEnabled()
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
await dialogSpeichern.click()
await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung')
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
.toContainText('Splitbuchung')
// Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto.
await page.goto(`/konten/${sparId}`)
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00')
})
test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-06')
await page.fill('#betrag', '70')
await page.fill('#empfaenger', `Wieder einfach ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-30')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][betrag]"]', '-40')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Der Klick führt nicht in die Auswahlliste, sondern in den Dialog –
// mit den vorhandenen Zeilen darin.
const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(page.locator('#zuordnungsauswahl')).toBeHidden()
await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00')
await dialog.locator('#splitloeschen').click()
await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung gelöscht')
const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await expect(danach).not.toContainText('Splitbuchung')
await expect(danach).not.toContainText(KATEGORIE)
// Und die Zuordnung geht jetzt wieder über die Auswahlliste.
await danach.locator('a.zuordnungsfeld').click()
await expect(danach.locator('#zuordnungsauswahl')).toBeVisible()
})
test('Splitbuchung mit zwei Zeilen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-02')
await page.fill('#betrag', '260,49')
await page.fill('#empfaenger', `Versicherung ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][notiz]"]', 'Kasko')
await page.fill('input[name="split[0][betrag]"]', '-162,51')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht')
await page.fill('input[name="split[1][betrag]"]', '-97,98')
// Die Maske rechnet den Restbetrag mit; er muss aufgehen.
await expect(page.locator('#splitsumme')).not.toContainText('Rest')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` })
await expect(zeile).toContainText('Splitbuchung')
await expect(zeile).toContainText('-260,49')
// In der Spalte steht nur „Splitbuchung“ – wohin das Geld geht, zeigt
// der Tooltip: je Zeile Kategorie und Betrag.
const titel = await zeile.locator('a.zuordnungsfeld').getAttribute('title')
expect(titel).toContain(`${KATEGORIE}: -162,51`)
expect(titel).toContain(`${KATEGORIE}: -97,98`)
})
test('Falsche Splitsumme behält die eingegebenen Splitzeilen (Fix #95)', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-06-01')
await page.fill('#betrag', '100')
await page.fill('#empfaenger', `Falschsumme ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][notiz]"]', 'Erster Teil')
await page.fill('input[name="split[0][betrag]"]', '-30')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][notiz]"]', 'Zweiter Teil')
await page.fill('input[name="split[1][betrag]"]', '-50')
const zuordnungWert = await page.locator('select[name="split[0][zuordnung]"]').inputValue()
// -30 und -50 ergeben nicht die -100 der Buchung: buchungsmaske.js sperrt
// den Knopf deshalb schon clientseitig. Das Formular geht hier trotzdem
// direkt ab, um die serverseitige Prüfung zu erreichen – wie es auch ohne
// JavaScript oder bei einer manipulierten Anfrage passieren würde.
await page.locator('.buchungsmaske').evaluate((formular) => formular.submit())
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen ergeben')
// Der Kern von Fix #95: Nichts muss neu erfasst werden, alle Eingaben
// stehen nach der Fehlermeldung unverändert da.
await expect(page.locator('#datum')).toHaveValue('2026-06-01')
await expect(page.locator('#betrag')).toHaveValue('100')
await expect(page.locator('#empfaenger')).toHaveValue(`Falschsumme ${MARKE}`)
await expect(page.locator('select[name="split[0][zuordnung]"]')).toHaveValue(zuordnungWert)
await expect(page.locator('input[name="split[0][notiz]"]')).toHaveValue('Erster Teil')
await expect(page.locator('input[name="split[0][betrag]"]')).toHaveValue('-30')
await expect(page.locator('select[name="split[1][zuordnung]"]')).toHaveValue(zuordnungWert)
await expect(page.locator('input[name="split[1][notiz]"]')).toHaveValue('Zweiter Teil')
await expect(page.locator('input[name="split[1][betrag]"]')).toHaveValue('-50')
// Nach einer Korrektur lässt sich die Buchung wie gewohnt speichern.
await page.fill('input[name="split[1][betrag]"]', '-70')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Falschsumme ${MARKE}` })
await expect(zeile).toContainText('Splitbuchung')
await expect(zeile).toContainText('-100,00')
})
test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
const kategorie = page.locator('#zuordnung')
const klasse = page.locator('#klasse_id')
const splitZuordnung = page.locator('select[name="split[0][zuordnung]"]')
const splitBetrag = page.locator('input[name="split[1][betrag]"]')
const leeren = page.locator('#splitsleeren')
// Leere Maske: alles offen, nichts zu löschen.
await expect(kategorie).toBeEnabled()
await expect(splitBetrag).toBeEnabled()
await expect(leeren).toBeHidden()
// Eine Eingabe in den Splitzeilen sperrt Kategorie und Klasse sofort.
await page.fill('input[name="split[0][notiz]"]', 'Teil')
await expect(kategorie).toBeDisabled()
await expect(klasse).toBeDisabled()
await expect(leeren).toBeVisible()
// Der Knopf leert alle Zeilen und gibt oben wieder frei.
await splitZuordnung.selectOption({ label: KATEGORIE })
await splitBetrag.fill('-10')
await leeren.click()
await expect(page.locator('input[name="split[0][notiz]"]')).toHaveValue('')
await expect(splitZuordnung).toHaveValue('')
await expect(splitBetrag).toHaveValue('')
await expect(kategorie).toBeEnabled()
await expect(klasse).toBeEnabled()
await expect(leeren).toBeHidden()
// Umgekehrt: Eine Kategorie oben sperrt die Splitzeilen …
await kategorie.selectOption({ label: KATEGORIE })
await expect(splitZuordnung).toBeDisabled()
await expect(splitBetrag).toBeDisabled()
await kategorie.selectOption('')
await expect(splitBetrag).toBeEnabled()
// … und eine Klasse ebenso.
await klasse.selectOption({ label: KLASSE })
await expect(splitZuordnung).toBeDisabled()
await klasse.selectOption('')
await expect(splitZuordnung).toBeEnabled()
})
test('Bestehende Splitbuchung öffnet mit gesperrter Kategorie', async ({ page }) => {
const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` })
await page.goto(`/konten/${giroId}`)
const bearbeiten = await zeile.locator('a[href^="/buchungen/"]').first().getAttribute('href')
await page.goto(bearbeiten)
await expect(page.locator('#zuordnung')).toBeDisabled()
await expect(page.locator('#klasse_id')).toBeDisabled()
await expect(page.locator('#splitsleeren')).toBeVisible()
})
test('Klassen der Splitzeilen stehen in der Klassenspalte', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-03')
await page.fill('#betrag', '45')
await page.fill('#empfaenger', `Klassensplit ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.selectOption('select[name="split[0][klasse_id]"]', { label: KLASSE })
await page.fill('input[name="split[0][betrag]"]', '-25')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][betrag]"]', '-20')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Verschiedene Klassen: die Marke, im Tooltip die Klasse je Zeile.
const zeile = page.locator('tr', { hasText: `Klassensplit ${MARKE}` })
const klassenfeld = zeile.locator('a.klassenfeld')
await expect(klassenfeld.locator('.marke')).toHaveText('Splitbuchung')
const titel = await klassenfeld.getAttribute('title')
expect(titel).toContain(`${KATEGORIE}: ${KLASSE}`)
expect(titel).toContain(`${KATEGORIE}: (ohne Klasse)`)
// Der Klick führt in den Splitdialog, wo die Klassen der Zeilen stehen.
await klassenfeld.click()
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(page.locator('#klassenauswahl')).toBeHidden()
await dialog.locator('select[name="split[1][klasse_id]"]').selectOption({ label: KLASSE })
await dialog.locator('button[type="submit"].primaer').click()
await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung gespeichert')
// Gleiche Klassen: die Klasse selbst, ohne Marke.
const danach = page.locator('tr', { hasText: `Klassensplit ${MARKE}` }).locator('a.klassenfeld')
await expect(danach).toHaveText(KLASSE)
await expect(danach.locator('.marke')).toHaveCount(0)
})
test('Buchungszeilen sind gleich hoch, abgeschnittene Texte stehen im Tooltip', async ({ page }) => {
const LANG = `Verwendungszweck ${MARKE} mit sehr vielen Wörtern, `.repeat(6).trim()
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-04')
await page.fill('#betrag', '11,11')
await page.fill('#empfaenger', `Langtext ${MARKE}`)
await page.fill('#buchungstext', LANG)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Einheitlich zwei Textzeilen hoch: Eine Zeile, die mit ihrem Text
// wächst, ließe das Kontoblatt beim Blättern hüpfen.
const hoehen = await page.locator('table.kontoblatt tbody tr').evaluateAll(
(zeilen) => zeilen.map((zeile) => Math.round(zeile.getBoundingClientRect().height))
)
expect(hoehen.length).toBeGreaterThan(1)
// Ein Pixel Toleranz: Die letzte Zeile hat keine Trennlinie.
expect(Math.max(...hoehen) - Math.min(...hoehen)).toBeLessThanOrEqual(1)
const zeile = page.locator('tr', { hasText: `Langtext ${MARKE}` })
// Der abgeschnittene Buchungstext steht vollständig im Tooltip …
await expect(zeile.locator('td.buchungstext .zellentext'))
.toHaveAttribute('title', LANG)
// … der kurze Empfänger daneben bekommt keinen: Dort fehlt nichts.
await expect(zeile.locator('td').nth(3).locator('.zellentext'))
.not.toHaveAttribute('title')
})
test('Splitzeilen, die nicht aufgehen, sperren das Speichern', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
const speichern = page.locator('.buchungsmaske button[type="submit"]')
await page.fill('#datum', '2026-05-03')
await page.fill('#betrag', '100')
await page.fill('#empfaenger', `Krumm ${MARKE}`)
// Ohne jede Splitzeile bleibt der Button an – die Buchung ist noch keine
// Splitbuchung, auch wenn die Summenzeile schon den vollen Betrag zeigt.
await expect(page.locator('#splitsumme')).toHaveText('-100,00')
await expect(speichern).toBeEnabled()
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-60')
// Die Summenzeile zeigt den noch offenen Restbetrag, nicht die Summe der
// Zeilen – und „Speichern“ ist gesperrt, solange er nicht Null ist.
await expect(page.locator('#splitsumme')).toHaveText('-40,00')
await expect(speichern).toBeDisabled()
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][betrag]"]', '-40')
await expect(page.locator('#splitsumme')).toHaveText('0,00')
await expect(speichern).toBeEnabled()
await speichern.click()
await expect(page.locator('.meldung-erfolg')).toBeVisible()
})
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
await page.goto(`/konten/${giroId}`)
await page.fill('input[name="suche"]', 'Supermarkt')
await page.click('.filterleiste button[type="submit"]')
// Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des
// Splitdialogs. Gemeint ist hier das Kontoblatt.
await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1)
await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt')
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
// Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl.
await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–')
})
test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => {
// Heutiges Datum: Fett stehen nur Konten mit offenen Buchungen aus dem
// laufenden Jahr – ein festes Datum wäre nächstes Silvester falsch.
const heute = new Date().toISOString().slice(0, 10)
await page.goto(`/konten/${sparId}/buchung`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', heute)
await page.fill('#betrag', '17,50')
await page.fill('#empfaenger', 'Noch zuzuordnen')
// Kategorie bleibt „— keine —“: genau der Zustand, den ein automatisch
// gebuchter Bankumsatz ohne passende Regel hinterlässt.
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
await page.goto('/konten')
await expect(page.locator('tr.unkategorisiert', { hasText: SPAR })).toHaveCount(1)
await expect(page.locator('tr.unkategorisiert a', { hasText: SPAR }))
.toHaveAttribute('title', /ohne Kategorie/)
})
test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
await page.goto(`/konten/${giroId}`)
await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click()
await expect(page).toHaveURL(/\/buchungen\/\d+/)
// Der Dialog MUSS kommen: die Bestätigung hängt an data-bestaetigen und
// wird von /static/app.js gesetzt, weil Inline-Handler an der CSP
// scheitern. Der Handler muss vor dem Klick stehen und den Dialog
// sofort schließen – ein offener Dialog blockiert die Klickaktion.
const gefragt = []
page.on('dialog', async (dialog) => {
gefragt.push(dialog.message())
await dialog.accept()
})
await page.locator('form[action$="/loeschen"] button').click()
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt')
})
test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => {
for (const empfaenger of ['Sammellöschung A', 'Sammellöschung B']) {
await page.goto(`/konten/${sparId}/buchung`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-02')
await page.fill('#betrag', '9,90')
await page.fill('#empfaenger', empfaenger)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
}
await page.goto(`/konten/${sparId}`)
// Ohne Auswahl bleibt der Knopf gesperrt.
const knopf = page.locator('#buchungsauswahl button')
await expect(knopf).toBeDisabled()
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.
const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count()
await page.check('.alle-waehlen')
await expect(knopf).toContainText(`(${anzahl})`)
// Wie beim einzelnen Löschen hängt die Rückfrage an data-bestaetigen –
// der Handler muss vor dem Klick stehen.
const gefragt = []
page.on('dialog', async (dialog) => {
gefragt.push(dialog.message())
await dialog.accept()
})
await knopf.click()
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page.getByText('Sammellöschung')).toHaveCount(0)
})
})