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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user