Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.
Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.
Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.
QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.
Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.
Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
6.5 KiB
JavaScript
181 lines
6.5 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')
|
||
})
|
||
|
||
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.
|
||
await expect(page.locator(`tr:has-text("${unten}") .baumname`))
|
||
.toHaveAttribute('style', /--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')
|
||
|
||
page.once('dialog', (dialog) => 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')
|
||
await expect(uebersicht.locator('tr', { hasText: 'Konten' })).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.once('dialog', (dialog) => dialog.accept())
|
||
await page.locator('tr', { hasText: dateiname })
|
||
.getByRole('button', { name: 'Löschen' }).click()
|
||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||
})
|
||
})
|