feat: Zweite Stufe der iOS-App - Uebersicht, Budget, Buchungen, Vertraege

Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter:

- Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe,
  Kontostaende und die letzten Buchungen. Feste Auswahl statt des
  verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das
  Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der
  Offline-Stand nicht aus vier verschiedenen Momenten stammt.
- Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next
  aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt
  als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende
  Wechselkurse stehen als Hinweis darunter, statt die Summen still zu
  verfaelschen.
- Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht
  im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck.
- Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit.

Zwei Dinge, die das Backend bewusst dem Client ueberlaesst:

Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit
to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert.
Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen.
Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste
stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu
diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem
Vorzeichen: Manche Banken liefern beide Beine positiv.

Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand,
einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend
sich selbst verbietet.

Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die
Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort.

Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu,
ein lokaler Server haette eine ATS-Ausnahme gebraucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 20:24:06 +02:00
co-authored by Claude Opus 5
parent 58bd504333
commit bf437b3b69
16 changed files with 2252 additions and 2 deletions
+276
View File
@@ -302,3 +302,279 @@ struct ModellTests {
#expect(fremd.istCertain == false)
}
}
// MARK: - Überträge zusammenfassen
@Suite("Übertragszeilen")
struct UebertragszeilenTests {
/// Eine Buchung, wie der Server sie schickt – nur die Felder, auf die es
/// hier ankommt.
private func buchung(_ id: Int, konto: Int, cents: Int,
art: Buchungsart = .expense) throws -> Buchung {
let roh = """
{"id": \(id), "type": "\(art.rawValue)", "date": "2026-10-15", "time": null,
"account_id": \(konto), "to_account_id": null, "account_currency": "EUR",
"amount_cents": \(cents), "to_amount_cents": null, "fx_rate": null,
"amount_eur_cents": \(cents), "amount_base_cents": \(cents),
"category_id": null, "budget_treatment": null, "budget_category_id": null,
"subscription_id": null, "budget_period": "2026-10",
"budget_period_manuell": false, "import_batch_id": null,
"herkunft": "bank", "payee": "Test", "description": null, "notes": null,
"tag_ids": []}
"""
return try JSONDecoder().decode(Buchung.self, from: Data(roh.utf8))
}
private func verknuepfung(_ anderes: Int, raus: Bool, gegenkonto: Int?) -> Uebertragsverknuepfung {
Uebertragsverknuepfung(linkId: 1, otherId: anderes,
richtung: raus ? "raus" : "rein",
otherAccountId: gegenkonto, automatisch: true)
}
@Test("Zwei verknüpfte Buchungen ergeben eine Zeile, nicht zwei")
func paarWirdZusammengefasst() throws {
let raus = try buchung(1, konto: 10, cents: -5000)
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
let zeilen = Buchungszeile.zeilen(aus: [raus, rein], paare: [
"1": verknuepfung(2, raus: true, gegenkonto: 20),
"2": verknuepfung(1, raus: false, gegenkonto: 10),
])
#expect(zeilen.count == 1)
#expect(zeilen[0].istUebertrag)
#expect(zeilen[0].vonKonto == 10)
#expect(zeilen[0].nachKonto == 20)
}
@Test("Fehlt das zweite Bein, verschwindet die Zeile nicht")
func einzelnesBeinBleibtStehen() throws {
// Genau der Fall eines Kontofilters: Die Gegenbuchung liegt auf einem
// Konto, das gerade nicht gezeigt wird.
let raus = try buchung(1, konto: 10, cents: -5000)
let zeilen = Buchungszeile.zeilen(aus: [raus], paare: [
"1": verknuepfung(2, raus: true, gegenkonto: 20),
])
#expect(zeilen.count == 1)
#expect(zeilen[0].istUebertrag)
// Das Gegenkonto steht in der Verknüpfung, auch ohne die Buchung.
#expect(zeilen[0].nachKonto == 20)
}
@Test("Die Richtung kommt aus der Verknüpfung, nicht aus dem Vorzeichen")
func richtungZaehlt() throws {
// Manche Banken liefern beide Beine positiv. Wer am Vorzeichen
// ablesen wollte, wohin das Geld geht, läge hier falsch herum.
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
let zeilen = Buchungszeile.zeilen(aus: [rein], paare: [
"2": verknuepfung(1, raus: false, gegenkonto: 10),
])
#expect(zeilen[0].vonKonto == 10)
#expect(zeilen[0].nachKonto == 20)
}
@Test("Ohne Verknüpfung bleibt jede Buchung ihre eigene Zeile")
func ohneVerknuepfung() throws {
let eins = try buchung(1, konto: 10, cents: -1000)
let zwei = try buchung(2, konto: 10, cents: -2000)
let zeilen = Buchungszeile.zeilen(aus: [eins, zwei], paare: [:])
#expect(zeilen.count == 2)
#expect(zeilen.allSatisfy { !$0.istUebertrag })
}
}
// MARK: - Was der Client selbst rechnet
@Suite("Budget-Anteile")
struct BudgetAnteilTests {
private func kategorie(geplant: Int, ist: Int) throws -> Budgetkategorie {
let roh = """
{"category_id": 1, "name": "Test", "icon": "home", "color": "#3f51b5",
"planned_cents": \(geplant), "actual_cents": \(ist),
"delta_cents": \(geplant - ist), "over": \(ist > geplant),
"modus": "fest", "zuschlag_cents": 0, "aus_vertraegen_cents": 0,
"vertraege_anzahl": 0, "good": false}
"""
return try JSONDecoder().decode(Budgetkategorie.self, from: Data(roh.utf8))
}
@Test("Ohne Plan gibt es keinen Anteil, keinen vollen Balken")
func ohnePlanKeinBalken() throws {
#expect(try kategorie(geplant: 0, ist: 0).anteilVerbraucht == 0)
// Ausgegeben ohne Plan: Ein voller Balken sähe aus wie "genau
// aufgebraucht" und wäre die falsche Auskunft.
#expect(try kategorie(geplant: 0, ist: 5000).anteilVerbraucht == 0)
}
@Test("Überzogen wird bei eins gekappt")
func gekappt() throws {
#expect(try kategorie(geplant: 10000, ist: 25000).anteilVerbraucht == 1)
#expect(try kategorie(geplant: 10000, ist: 5000).anteilVerbraucht == 0.5)
}
}
// MARK: - Nachschlagewerk
@Suite("Nachschlag")
struct NachschlagTests {
private func gruppe() throws -> Kategoriegruppe {
let roh = """
{"id": 1, "name": "Fixkosten", "kind": "expense", "icon": "landmark",
"color": "#3f51b5", "position": 0, "is_builtin": true,
"categories": [{"id": 7, "group_id": 1, "name": "Wohnen", "type": "expense",
"icon": "home", "color": "#3f51b5", "position": 0, "is_builtin": true,
"is_archived": false, "excluded_from_stats": false,
"spending_is_good": false, "keyword_count": 0}]}
"""
return try JSONDecoder().decode(Kategoriegruppe.self, from: Data(roh.utf8))
}
@Test("Eine ID findet ihren Namen")
func kategorieNachId() throws {
let nachschlag = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
#expect(nachschlag.kategorie(7)?.name == "Wohnen")
#expect(nachschlag.kategorie(999) == nil)
#expect(nachschlag.kategorie(nil) == nil)
}
@Test("Ein unbekanntes Konto ergibt Text, nicht nichts")
func unbekanntesKonto() throws {
// Eine leere Zeile in der Liste wäre schlimmer als das Wort
// "Unbekannt" – sie sähe aus wie ein Fehler der App.
#expect(!Nachschlag.leer.kontoname(42).isEmpty)
}
@Test("Der Zwischenspeicher gibt dasselbe Nachschlagewerk zurück")
func ueberlebtDenSpeicher() throws {
let original = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
let roh = try JSONEncoder().encode(original)
let zurueck = try JSONDecoder().decode(Nachschlag.self, from: roh)
// Die Nachschlagetabellen werden beim Lesen neu gebaut, nicht
// mitgeschrieben – genau das prüft dieser Test.
#expect(zurueck.kategorie(7)?.name == "Wohnen")
}
}
// MARK: - Die Modelle gegen echte Serverantworten
/// Nur da, um über `Bundle(for:)` an die Prüfdatei zu kommen. Swift Testing
/// kennt keine Testklasse, an der sich das Bündel ablesen liesse.
private final class Markierung {}
/// Entschlüsselt jedes Modell aus einer echten Antwort des Backends.
///
/// Das ist die Prüfung, die beim blinden Schreiben wirklich etwas fängt: Ein
/// vertippter Feldname übersetzt anstandslos und fällt erst auf, wenn eine
/// Liste beim Nutzer leer bleibt. `Tests/Fixtures/antworten.json` kommt aus
/// `backend/scripts/erzeuge_ios_fixture.py`; nach jeder Schema-Änderung neu
/// erzeugen.
@Suite("Serverantworten")
struct ServerantwortenTests {
private static let antworten: [String: Data] = {
guard let ort = Bundle(for: Markierung.self)
.url(forResource: "antworten", withExtension: "json"),
let roh = try? Data(contentsOf: ort),
let oben = try? JSONSerialization.jsonObject(with: roh) as? [String: Any]
else { return [:] }
return oben.compactMapValues {
try? JSONSerialization.data(withJSONObject: $0, options: .fragmentsAllowed)
}
}()
private func lies<A: Decodable>(_ typ: A.Type, _ name: String) throws -> A {
let daten = try #require(Self.antworten[name],
"Prüfdatei kennt '\(name)' nicht – neu erzeugen?")
return try JSONDecoder().decode(A.self, from: daten)
}
@Test("Die Prüfdatei liegt im Testbündel")
func dateiIstDa() {
// Ohne diesen Test wären alle folgenden still übersprungen, wenn die
// Datei nicht ins Ziel kopiert wird.
#expect(!Self.antworten.isEmpty)
}
@Test("Konten, Kategorien und Schlagwörter")
func stammdaten() throws {
let konten = try lies([Konto].self, "konten")
#expect(konten.count == 2)
#expect(konten.contains { $0.isLocked })
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
#expect(gruppen.contains { !$0.categories.isEmpty })
#expect(try !lies([Schlagwort].self, "schlagwoerter").isEmpty)
}
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
func buchungen() throws {
let alle = try lies([Buchung].self, "buchungen")
#expect(alle.count == 5)
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
// nur in einem Fall belegt sind.
#expect(alle.contains { !$0.tagIds.isEmpty })
#expect(alle.contains { $0.type == .transfer && $0.toAccountId != nil })
#expect(alle.contains { $0.subscriptionId != nil })
#expect(alle.contains { $0.notes?.isEmpty == false })
}
@Test("Die Paarliste fasst zwei getrennte Buchungen zusammen")
func verknuepfungen() throws {
let paare = try lies([String: Uebertragsverknuepfung].self, "uebertragsverknuepfungen")
#expect(paare.count == 2)
let alle = try lies([Buchung].self, "buchungen")
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
// Fünf Buchungen, zwei davon ein Paar: vier Zeilen.
#expect(zeilen.count == 4)
#expect(zeilen.filter(\.istUebertrag).count == 2)
}
@Test("Budget-Monat, Vorlage und Verträge")
func budget() throws {
let monat = try lies(Budgetmonat.self, "budget")
#expect(!monat.groups.isEmpty)
#expect(monat.groups.contains { !$0.categories.isEmpty })
#expect(monat.totals.plannedExpense > 0)
// prev/next tragen die Blätterung – ohne sie steht die Ansicht still.
#expect(!monat.prev.isEmpty && !monat.next.isEmpty)
let vorlage = try lies(Budgetvorlage.self, "budgetvorlage")
// Betriebsart und Zuschlag kamen mit Backend-Ergaenzung C dazu.
#expect(vorlage.lines.contains { $0.modus == "fest" })
let abos = try lies(Aboliste.self, "abos")
#expect(abos.summary.anzahlAktiv >= 1)
#expect(abos.subscriptions.contains { $0.intervalMonths == 12 })
}
@Test("Alle vier Dashboard-Karten")
func dashboard() throws {
let staende = try lies(Kontostaende.self, "kontostaende")
#expect(staende.accounts.count == 2)
#expect(staende.gebunden != 0)
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
#expect(try lies(EinnahmenAusgaben.self, "einnahmenAusgaben").income > 0)
let letzte = try lies(LetzteBuchungen.self, "letzteBuchungen")
#expect(!letzte.transactions.isEmpty)
// Ein Übertrag hat keine Richtung – sign ist dort null.
#expect(letzte.transactions.contains { $0.vorzeichen == .keines })
}
@Test("Benutzer, Einstellungen und Serverkennung")
func zugang() throws {
#expect(try lies(Serverkennung.self, "version").istCertain)
#expect(try !lies(Benutzer.self, "ichSelbst").username.isEmpty)
let roh = try lies([Einstellung].self, "einstellungen")
#expect(!Einstellungen(roh).basiswaehrung.isEmpty)
}
}