Elf Listenansichten (Klassen, Buchungsvorlagen, Regelmäßige Buchungen, Erinnerungen, Verträge, Adressen, Sparziele, Kredite, Abgleichregeln, Budgets, Berichtsgruppen) zeigten das Formular unter der Liste. Jetzt öffnet der Knopf „+ Neu“ es als Dialog. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
// @ts-check
|
||
const { test, expect } = require('@playwright/test')
|
||
|
||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||
|
||
// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz
|
||
// und dürfen sich nicht an Resten eines früheren Laufs stoßen.
|
||
const MARKE = Date.now().toString().slice(-6)
|
||
|
||
async function anmelden(page) {
|
||
await page.goto('/login')
|
||
|
||
if (page.url().includes('/einrichtung')) {
|
||
throw new Error(
|
||
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
|
||
'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(/\/$/)
|
||
}
|
||
|
||
test.describe('Stammdaten', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await anmelden(page)
|
||
})
|
||
|
||
test('Kontenübersicht ist erreichbar', async ({ page }) => {
|
||
await page.click('.seitenleiste a[href="/konten"]')
|
||
|
||
await expect(page.locator('h1')).toContainText('Kontenübersicht')
|
||
await expect(page.locator('a[href="/konten/neu"]').first()).toBeVisible()
|
||
})
|
||
|
||
test('Konto anlegen und in der Übersicht wiederfinden', async ({ page }) => {
|
||
const name = `E2E Girokonto ${MARKE}`
|
||
|
||
await page.goto('/konten/neu')
|
||
await page.fill('#name', name)
|
||
await page.selectOption('#kontoart', 'bank')
|
||
await page.fill('#anfangssaldo', '1.234,56')
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
await page.goto('/konten')
|
||
const zeile = page.locator('tr', { hasText: name })
|
||
await expect(zeile).toContainText('Bank')
|
||
// Deutsches Betragsformat, geschütztes Leerzeichen vor dem Euro-Zeichen.
|
||
await expect(zeile).toContainText('1.234,56')
|
||
await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände')
|
||
})
|
||
|
||
// Ein nicht angehaktes Ankreuzfeld schickt der Browser gar nicht mit. Bleibt
|
||
// es serverseitig unbeachtet, meldet die Anwendung „Konto gespeichert“ und
|
||
// das Kennzeichen steht unverändert da.
|
||
test('Σ-Kennzeichen lässt sich abschalten und wieder setzen', async ({ page }) => {
|
||
const name = `E2E Auslagen ${MARKE}`
|
||
|
||
await page.goto('/konten/neu')
|
||
await page.fill('#name', name)
|
||
await page.selectOption('#kontoart', 'sonstiges')
|
||
await page.fill('#anfangssaldo', '500,00')
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
const kennzeichen = page.locator('input[name="in_summe"]')
|
||
await expect(kennzeichen).toBeChecked()
|
||
|
||
await kennzeichen.uncheck()
|
||
await page.locator('.formular button[type="submit"]').first().click()
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
// In der Übersicht bleibt das Konto sichtbar, aber als „ohne Σ“ markiert.
|
||
const zeile = page.locator('tr', { hasText: name })
|
||
await expect(zeile).toContainText('ohne Σ')
|
||
|
||
// Und im Formular ist das Häkchen tatsächlich weg – nicht nur die Meldung da.
|
||
await zeile.locator('a').first().click()
|
||
await page.click('a.tab[href$="/bearbeiten"]')
|
||
await expect(page.locator('input[name="in_summe"]')).not.toBeChecked()
|
||
|
||
await page.locator('input[name="in_summe"]').check()
|
||
await page.locator('.formular button[type="submit"]').first().click()
|
||
await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ')
|
||
})
|
||
|
||
// Das Kontoformular steht zweispaltig, damit alle Felder ohne vertikales
|
||
// Scrollen sichtbar sind. Geprüft wird an den Kästen: Kontoart steht rechts
|
||
// neben dem Namen, und das Formular endet oberhalb der Fensterhöhe.
|
||
test('Kontoformular steht zweispaltig und ohne Scrollen', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 900 })
|
||
await page.goto('/konten/neu')
|
||
|
||
const name = await page.locator('#name').boundingBox()
|
||
const kontoart = await page.locator('#kontoart').boundingBox()
|
||
|
||
// Gleiche Zeile, zweite Spalte.
|
||
expect(Math.abs(name.y - kontoart.y)).toBeLessThan(5)
|
||
expect(kontoart.x).toBeGreaterThan(name.x + name.width)
|
||
|
||
const knopf = await page.locator('.formular button[type="submit"]').first().boundingBox()
|
||
expect(knopf.y + knopf.height).toBeLessThan(900)
|
||
})
|
||
|
||
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
|
||
const oben = `E2E Wohnen ${MARKE}`
|
||
const unten = `Strom ${MARKE}`
|
||
|
||
await page.goto('/verwaltung/kategorien/neu')
|
||
await page.fill('#name', oben)
|
||
await page.selectOption('#art', 'ausgabe')
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
// Nach dem Anlegen steht man im Bearbeitungsformular der Kategorie.
|
||
await expect(page.locator('.feldwert code')).toContainText(oben)
|
||
|
||
await page.goto('/verwaltung/kategorien/neu')
|
||
await page.fill('#name', unten)
|
||
await page.selectOption('#eltern_id', { label: oben })
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await expect(page.locator('.feldwert code')).toContainText(`${oben}:${unten}`)
|
||
|
||
await page.goto('/verwaltung/kategorien')
|
||
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie')
|
||
// Die Unterkategorie ist um eine Ebene eingerückt. Die Ebene steht als
|
||
// data-Attribut da: Ein style-Attribut würde die Content-Security-Policy
|
||
// verwerfen, die Einrückung fiele stillschweigend aus.
|
||
await expect(page.locator(`tr:has-text("${unten}") .baumname`))
|
||
.toHaveAttribute('data-ebene', '1')
|
||
})
|
||
|
||
test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => {
|
||
const einnahme = `E2E Gehalt ${MARKE}`
|
||
|
||
await page.goto('/verwaltung/kategorien/neu')
|
||
await page.fill('#name', einnahme)
|
||
await page.selectOption('#art', 'einnahme')
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
await page.goto('/verwaltung/kategorien?register=einnahmen')
|
||
await expect(page.locator('.tab.aktiv')).toContainText('Einnahmen')
|
||
await expect(page.locator('tbody')).toContainText(einnahme)
|
||
|
||
await page.goto('/verwaltung/kategorien?register=ausgaben')
|
||
await expect(page.locator('tbody')).not.toContainText(einnahme)
|
||
})
|
||
|
||
test('Umbuchungskonten zeigen die Konten in eckigen Klammern', async ({ page }) => {
|
||
await page.goto('/verwaltung/kategorien?register=umbuchungskonten')
|
||
|
||
await expect(page.locator('.tab.aktiv')).toContainText('Umbuchungskonten')
|
||
await expect(page.locator('tbody a').first()).toContainText(/^\[.+\]$/)
|
||
})
|
||
|
||
test('Klasse anlegen und wieder löschen', async ({ page }) => {
|
||
const name = `E2E Projekt ${MARKE}`
|
||
|
||
await page.goto('/verwaltung/klassen')
|
||
await page.click('#neu-knopf')
|
||
await page.fill('.formular #name', name)
|
||
await page.fill('.formular #beschreibung', 'aus dem E2E-Test')
|
||
await page.click('.formular button:has-text("Klasse anlegen")')
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||
|
||
// Die Liste ist bearbeitbar: Name und Beschreibung stehen in
|
||
// Eingabefeldern, nicht als Text – deshalb über den Attributwert suchen.
|
||
const zeile = page.locator('tr').filter({
|
||
has: page.locator(`input[name="name"][value="${name}"]`),
|
||
})
|
||
await expect(zeile).toHaveCount(1)
|
||
await expect(zeile.locator('input[name="beschreibung"]')).toHaveValue('aus dem E2E-Test')
|
||
|
||
// Den Dialog sofort schließen: solange er offen ist, kehrt click() nicht
|
||
// zurück. Die Bestätigung kommt aus /static/app.js (data-bestaetigen).
|
||
page.on('dialog', async (dialog) => await dialog.accept())
|
||
await zeile.getByRole('button', { name: 'Löschen' }).click()
|
||
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||
})
|
||
|
||
test('QIF-Trockenlauf zeigt an, was ankäme, ohne zu speichern', async ({ page }) => {
|
||
const dateiname = `e2e-probe-${MARKE}.qif`
|
||
const klasse = `E2E Klasse ${MARKE}`
|
||
const inhalt = [
|
||
'!Option:MDY',
|
||
'!Type:Class',
|
||
`N${klasse}`,
|
||
'^',
|
||
'!Type:Cat',
|
||
`NE2E Kategorie ${MARKE}`,
|
||
'E',
|
||
'^',
|
||
'!Option:AutoSwitch',
|
||
'!Account',
|
||
`NE2E Konto ${MARKE}`,
|
||
'TBank',
|
||
'^',
|
||
'!Clear:AutoSwitch',
|
||
'',
|
||
].join('\r\n')
|
||
|
||
await page.goto('/verwaltung/import?format=qif')
|
||
await page.setInputFiles('#datei', {
|
||
name: dateiname,
|
||
mimeType: 'application/octet-stream',
|
||
buffer: Buffer.from(inhalt, 'latin1'),
|
||
})
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
|
||
|
||
const zeile = page.locator('tr', { hasText: dateiname })
|
||
await zeile.getByRole('button', { name: 'Trockenlauf' }).click()
|
||
|
||
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||
const uebersicht = page.locator('.kachel table.liste').first()
|
||
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1')
|
||
// Genau die Zeile „Konten“ – „Kontengruppen“ steht direkt darunter und
|
||
// enthält denselben Wortanfang, deshalb über die erste Zelle filtern.
|
||
const kontenzeile = uebersicht.locator('tr').filter({
|
||
has: page.getByRole('cell', { name: 'Konten', exact: true }),
|
||
})
|
||
await expect(kontenzeile).toContainText('1')
|
||
|
||
// Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
|
||
await page.goto('/verwaltung/klassen')
|
||
await expect(page.locator('body')).not.toContainText(klasse)
|
||
|
||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||
await page.goto('/verwaltung/import?format=qif')
|
||
page.on('dialog', async (dialog) => await dialog.accept())
|
||
await page.locator('tr', { hasText: dateiname })
|
||
.getByRole('button', { name: 'Löschen' }).click()
|
||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||
})
|
||
|
||
test('Kontoauswahl steht erst nach dem Trockenlauf und grenzt den Import ein', async ({ page }) => {
|
||
const dateiname = `e2e-konten-${MARKE}.qif`
|
||
const giro = `E2E Giro ${MARKE}`
|
||
const tagesgeld = `E2E Tagesgeld ${MARKE}`
|
||
const inhalt = [
|
||
'!Option:MDY',
|
||
'!Option:AutoSwitch',
|
||
'!Account',
|
||
`N${giro}`,
|
||
'TBank',
|
||
'^',
|
||
`N${tagesgeld}`,
|
||
'TOth T',
|
||
'^',
|
||
'!Clear:AutoSwitch',
|
||
'!Account',
|
||
`N${giro}`,
|
||
'TBank',
|
||
'^',
|
||
'!Type:Bank',
|
||
'D4.1.26',
|
||
'T-250.00',
|
||
'PÜbertrag',
|
||
`L[${tagesgeld}]`,
|
||
'^',
|
||
'D5.2.26',
|
||
'T-49.90',
|
||
'PSupermarkt',
|
||
'^',
|
||
'',
|
||
].join('\r\n')
|
||
|
||
await page.goto('/verwaltung/import?format=qif')
|
||
await page.setInputFiles('#datei', {
|
||
name: dateiname,
|
||
mimeType: 'application/octet-stream',
|
||
buffer: Buffer.from(inhalt, 'latin1'),
|
||
})
|
||
await page.click('.formular button[type="submit"]')
|
||
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
|
||
|
||
// Vor dem ersten Lesen der Datei gibt es nichts zu wählen.
|
||
await expect(page.locator('input[name="konten[]"]')).toHaveCount(0)
|
||
|
||
await page.locator('tr', { hasText: dateiname })
|
||
.getByRole('button', { name: 'Trockenlauf' }).click()
|
||
|
||
// Danach stehen genau die Konten der Datei zur Wahl – beide angehakt.
|
||
const kaestchen = page.locator('input[name="konten[]"]')
|
||
await expect(kaestchen).toHaveCount(2)
|
||
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).toBeChecked()
|
||
await expect(page.locator(`input[name="konten[]"][value="${tagesgeld}"]`)).toBeChecked()
|
||
|
||
// Nur das Tagesgeld übernehmen: Die Umbuchung zeigt hierher und bleibt,
|
||
// die reine Girobuchung fällt heraus.
|
||
await page.locator(`input[name="konten[]"][value="${giro}"]`).uncheck()
|
||
await page.getByRole('button', { name: 'Trockenlauf mit dieser Auswahl' }).click()
|
||
|
||
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||
await expect(page.locator('tr', { hasText: 'gehört zu keinem der gewählten Konten' }))
|
||
.toContainText('1')
|
||
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
|
||
|
||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||
await page.goto('/verwaltung/import?format=qif')
|
||
page.on('dialog', async (dialog) => await dialog.accept())
|
||
await page.locator('tr', { hasText: dateiname })
|
||
.getByRole('button', { name: 'Löschen' }).click()
|
||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||
})
|
||
})
|