Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, Snapshot und Buchungssperre. Dazu drei Vorarbeiten in der Oberfläche. Budgetplaner - Migration 004: budgets, budget_positionen, monatsabschluesse - Zeilen sind der Kategoriebaum, Spalten die Monate; die ganze Tabelle wird in einem Formular gespeichert, damit mehrere Änderungen zusammen ankommen - Zwei Ansichten: Verlauf (Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach Folgemonate) und alle Monate - Ausgaben ohne Vorzeichen eingeben, es kommt aus der Kategorieart. Saldo ist Ist minus Budget: positiv heißt besser als geplant, negativ ist die Überschreitung und wird rot ausgewiesen - Übergeordnete Kategorien summieren ihre Nachfahren und sind nicht beplanbar - Ein Monat ohne Position ist "nicht geplant" und damit etwas anderes als "mit 0,00 geplant" - Wert auf Folgemonate übertragen, Budget als Kopie ins neue Jahr Monatsabschluss - Prüflauf über nicht kategorisierte Buchungen, nicht abgeglichene Umsätze und Splitsummen-Differenzen; er hält niemanden auf, sondern zeigt den Stand - Snapshot der Kontosalden und Kategoriesummen als JSON, bleibt auch nach einer Wiedereröffnung stehen - Abgeschlossene Monate sperren Buchungen in beide Richtungen: weder hinein noch heraus verschieben. Abschaltbar über monatsabschluss_sperrt_buchungen Vorarbeiten - .knopf.primaer trug einen eigenen margin-top und stand dadurch in jeder Knopfleiste tiefer als seine Nachbarn - sichtbar an "Abbrechen" neben "Konto anlegen". Der Abstand liegt jetzt allein bei der Knopfleiste - Kontengruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt statt inline; dort hängt auch die Zuordnung der Konten (ankreuzen statt jedes Konto einzeln zu öffnen) - Der QIF-Export enthält keine Kontengruppen: kein Feld im !Account-Eintrag, nichts in der QSD-Datei. Import-Oberfläche und Doku sagen das jetzt - Die Baum-Einrückung lief über ein style-Attribut und wurde von der Content-Security-Policy verworfen; sie steht jetzt als data-ebene im Markup. Aus demselben Grund blendete app.js Erfolgsmeldungen nie aus Tests: 11_BudgetTest, 12_MonatsabschlussTest (76 Prüfungen), E2E planung.spec.js. Einzelheiten in docs/planung.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
8.6 KiB
JavaScript
224 lines
8.6 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 Σ')
|
||
})
|
||
|
||
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.fill('.formular #name', name)
|
||
await page.fill('.formular #beschreibung', 'aus dem E2E-Test')
|
||
await page.click('.formular button[type="submit"]')
|
||
|
||
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')
|
||
await page.setInputFiles('#qif', {
|
||
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')
|
||
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)
|
||
})
|
||
})
|