Ein neuer Reiter "Eingang" mit allem, worueber jemand entscheiden muss. Vier Abschnitte, jeweils mit Anzahl im Kopf: - Zu buchen: offene Eingangseintraege aus Kurzbefehl, Postfach oder Bank. Buchen, als Uebertrag buchen, verwerfen - oder alle auf einmal, bei denen das Konto feststeht. Unklare tragen einen Hinweis ueber den Knoepfen, nicht darunter: Man soll sie ansehen, nicht wegbuchen. - Koennten Uebertraege sein: zwei getrennte Buchungen, die zusammengehoeren koennten. Verbinden oder ablehnen. Hat eine der beiden eine Kategorie, steht dabei, dass sie durchs Verbinden aus dem Budget faellt. - Ohne Kategorie: gebuchtes Geld, das in keiner Budget-Zeile mitzaehlt. Ein Tipp, eine Kategorie - kein Formular mit zwoelf Feldern davor. - Sieht doppelt aus: gleiches Konto, gleicher Tag, gleicher Betrag. Mit der Kennung der Bank je Zeile, denn verschiedene Kennungen heissen, dass die Bank sie als verschiedene Zahlungen gemeldet hat. Alles in einem Abruf. Vier getrennte Zwischenspeicher koennten offline aus vier verschiedenen Momenten stammen und sich widersprechen. Hier sitzt der einzige 409-Fall der App: Beim Buchen als Uebertrag kann das Backend Bedenken haben. Das ist eine Frage, kein Fehler - die App zeigt den Hinweis und wiederholt den Aufruf auf Wunsch mit trotzdem=true. Ihn zu verschlucken waere falsch, ihn als blossen Fehler zu zeigen auch. Zwei Dinge, die die Tests festhalten: Ohne Konto laesst sich nichts buchen (das Backend lehnt mit 400 ab, der Knopf bleibt also grau - und die Zeile sagt auch warum). Und ein Uebertrag braucht keine Kategorie, sonst fragte die Liste ewig nach einer, die es nie geben wird. Die Pruefdatei deckt jetzt auch Eingang, Dubletten und Uebertragsvorschlaege ab. Die aelteren Pruefungen zaehlten exakt und wurden dadurch rot - sie pruefen jetzt Eigenschaften statt Mengen, damit sie das naechste Wachstum ueberleben. 54 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
747 lines
30 KiB
Swift
747 lines
30 KiB
Swift
import Testing
|
||
import Foundation
|
||
@testable import Certain
|
||
|
||
// Reine Logik, kein Netz. Das ist alles, was sich ohne laufenden Server
|
||
// prüfen lässt – und zugleich das, was beim blinden Schreiben am ehesten
|
||
// still danebengeht.
|
||
//
|
||
// Hinweis: `@testable import Certain` muss auf den Namen deines App-Ziels
|
||
// zeigen. Heisst es anders, hier anpassen.
|
||
|
||
// MARK: - Geldbeträge
|
||
|
||
@Suite("Geldbetrag")
|
||
struct GeldbetragTests {
|
||
|
||
@Test("Komma und Punkt meinen dasselbe")
|
||
func trennerSindAustauschbar() {
|
||
#expect(Geldbetrag.ausEingabe("12,50", waehrung: "CHF")?.cents == 1250)
|
||
#expect(Geldbetrag.ausEingabe("12.50", waehrung: "CHF")?.cents == 1250)
|
||
#expect(Geldbetrag.ausEingabe("12", waehrung: "CHF")?.cents == 1200)
|
||
#expect(Geldbetrag.ausEingabe("0,05", waehrung: "CHF")?.cents == 5)
|
||
}
|
||
|
||
@Test("Bei beiden Zeichen ist das hintere der Dezimaltrenner")
|
||
func tausenderUndDezimal() {
|
||
#expect(Geldbetrag.ausEingabe("1.234,50", waehrung: "EUR")?.cents == 123450)
|
||
#expect(Geldbetrag.ausEingabe("1,234.50", waehrung: "EUR")?.cents == 123450)
|
||
}
|
||
|
||
@Test("Drei Stellen hinter dem Trenner sind Tausender, keine Rappen")
|
||
func dreiStellenSindKeinBruch() {
|
||
// "1.234" meint tausendzweihundertvierunddreissig, nicht 1,23.
|
||
#expect(Geldbetrag.ausEingabe("1.234", waehrung: "EUR")?.cents == 123400)
|
||
}
|
||
|
||
@Test("Vorzeichen bleibt erhalten")
|
||
func negativ() {
|
||
#expect(Geldbetrag.ausEingabe("-5", waehrung: "EUR")?.cents == -500)
|
||
#expect(Geldbetrag.ausEingabe("-12,34", waehrung: "EUR")?.cents == -1234)
|
||
}
|
||
|
||
@Test("Unsinn ergibt keinen Betrag")
|
||
func unsinn() {
|
||
#expect(Geldbetrag.ausEingabe("", waehrung: "EUR") == nil)
|
||
#expect(Geldbetrag.ausEingabe(" ", waehrung: "EUR") == nil)
|
||
// Der wichtige Fall: Ohne diese Prüfung käme hier 0,00 heraus und die
|
||
// App buchte stillschweigend einen Nullbetrag.
|
||
#expect(Geldbetrag.ausEingabe("abc", waehrung: "EUR") == nil)
|
||
#expect(Geldbetrag.ausEingabe(",", waehrung: "EUR") == nil)
|
||
}
|
||
|
||
@Test("Hin und zurück durchs Eingabefeld")
|
||
func rundlauf() {
|
||
let betrag = Geldbetrag(cents: 123450, waehrung: "CHF")
|
||
#expect(betrag.alsEingabe == "1234,50")
|
||
#expect(Geldbetrag.ausEingabe(betrag.alsEingabe, waehrung: "CHF")?.cents == 123450)
|
||
|
||
let krumm = Geldbetrag(cents: 5, waehrung: "CHF")
|
||
#expect(krumm.alsEingabe == "0,05")
|
||
#expect(Geldbetrag.ausEingabe(krumm.alsEingabe, waehrung: "CHF")?.cents == 5)
|
||
}
|
||
|
||
@Test("Die Formatierung nennt Betrag und Währung")
|
||
func formatierung() {
|
||
let text = Geldbetrag(cents: 1250, waehrung: "CHF").formatiert()
|
||
// Kein Vergleich auf die genaue Zeichenkette: Trennzeichen und Stellung
|
||
// des Währungszeichens hängen an der Sprache des Geräts.
|
||
#expect(text.contains("12"))
|
||
#expect(text.contains("50"))
|
||
}
|
||
|
||
@Test("Plus nur, wenn ausdrücklich gewünscht")
|
||
func vorzeichenstil() {
|
||
let positiv = Geldbetrag(cents: 500, waehrung: "EUR")
|
||
#expect(positiv.formatiert(vorzeichen: .immer).hasPrefix("+"))
|
||
#expect(positiv.formatiert().hasPrefix("+") == false)
|
||
}
|
||
|
||
@Test("Summieren geht nur in derselben Währung")
|
||
func summieren() {
|
||
let a = Geldbetrag(cents: 100, waehrung: "EUR")
|
||
let b = Geldbetrag(cents: 250, waehrung: "EUR")
|
||
#expect((a + b).cents == 350)
|
||
}
|
||
}
|
||
|
||
// MARK: - Zeitangaben
|
||
|
||
@Suite("Zeitangaben")
|
||
struct ZeitangabenTests {
|
||
|
||
@Test("Ein Tagesdatum überlebt Hin- und Rückweg")
|
||
func tagesdatum() throws {
|
||
let tag = try #require(Tagesdatum("2026-07-14"))
|
||
#expect(tag.text == "2026-07-14")
|
||
#expect(Tagesdatum(tag.datum).text == "2026-07-14")
|
||
}
|
||
|
||
@Test("Tage lassen sich vergleichen")
|
||
func reihenfolge() throws {
|
||
let frueher = try #require(Tagesdatum("2026-01-31"))
|
||
let spaeter = try #require(Tagesdatum("2026-02-01"))
|
||
#expect(frueher < spaeter)
|
||
}
|
||
|
||
@Test("Kaputte Tagesangaben ergeben nichts")
|
||
func kaputterTag() {
|
||
#expect(Tagesdatum("14.07.2026") == nil)
|
||
#expect(Tagesdatum("") == nil)
|
||
#expect(Tagesdatum("2026-13-45") == nil)
|
||
}
|
||
|
||
@Test("Zeitstempel mit und ohne Sekundenbruchteile")
|
||
func zeitpunkte() {
|
||
// Das Backend schickt beide Formen, je nach Feld.
|
||
#expect(Zeitpunkt("2026-07-14T10:11:12Z") != nil)
|
||
#expect(Zeitpunkt("2026-07-14T10:11:12.345Z") != nil)
|
||
#expect(Zeitpunkt("2026-07-14T10:11:12+02:00") != nil)
|
||
// Python schreibt ohne Zone, wenn keine gesetzt ist.
|
||
#expect(Zeitpunkt("2026-07-14T10:11:12") != nil)
|
||
// Mikrosekunden, wie SQLite sie ablegt.
|
||
#expect(Zeitpunkt("2026-07-14T10:11:12.345678") != nil)
|
||
#expect(Zeitpunkt("übermorgen") == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - Server-Adresse
|
||
|
||
@Suite("Server-Adresse")
|
||
struct AdressTests {
|
||
|
||
@Test("Ohne Schema wird https ergänzt")
|
||
func schemaErgaenzen() throws {
|
||
let adresse = try #require(AppZustand.deuteAdresse("budget.beispiel.com"))
|
||
#expect(adresse.absoluteString == "https://budget.beispiel.com")
|
||
}
|
||
|
||
@Test("Ein angehängter Pfad fliegt raus")
|
||
func pfadWeg() throws {
|
||
// Der Nutzer kopiert gern die Adresse aus der Browserzeile, samt Pfad.
|
||
// Die API hängt aber immer unter /api an der Wurzel.
|
||
let adresse = try #require(AppZustand.deuteAdresse("https://budget.beispiel.com/buchungen?x=1"))
|
||
#expect(adresse.absoluteString == "https://budget.beispiel.com")
|
||
}
|
||
|
||
@Test("http bleibt http")
|
||
func schemaBleibt() throws {
|
||
let adresse = try #require(AppZustand.deuteAdresse("http://192.168.1.5:8000"))
|
||
#expect(adresse.absoluteString == "http://192.168.1.5:8000")
|
||
}
|
||
|
||
@Test("Unsinn ergibt keine Adresse")
|
||
func unsinn() {
|
||
#expect(AppZustand.deuteAdresse("") == nil)
|
||
#expect(AppZustand.deuteAdresse(" ") == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - Symbole
|
||
|
||
@Suite("Symbole")
|
||
struct SymbolTests {
|
||
|
||
@Test("Bekannte Namen werden übersetzt")
|
||
func bekannt() {
|
||
#expect(Symbole.sf("wallet") == "wallet.bifold")
|
||
#expect(Symbole.sf("shopping-cart") == "cart")
|
||
}
|
||
|
||
@Test("Unbekanntes wird zum Platzhalter, nie zu nichts")
|
||
func unbekannt() {
|
||
// Eine leere Stelle in der Liste sähe aus wie ein Fehler.
|
||
#expect(Symbole.sf("gibtesnicht") == Symbole.platzhalter)
|
||
#expect(Symbole.sf(nil) == Symbole.platzhalter)
|
||
}
|
||
}
|
||
|
||
// MARK: - Fehlerantworten
|
||
|
||
@Suite("Fehlerantworten")
|
||
struct FehlerTests {
|
||
|
||
@Test("detail als Zeichenkette")
|
||
func alsText() {
|
||
let daten = Data(#"{"detail":"Nicht gefunden"}"#.utf8)
|
||
#expect(Fehlerantwort.ausDaten(daten) == "Nicht gefunden")
|
||
}
|
||
|
||
@Test("detail als Liste, wie bei 422")
|
||
func alsListe() {
|
||
// Genau hier gehen Meldungen verloren, wenn der Decoder nur eine
|
||
// Zeichenkette kennt.
|
||
let daten = Data(#"{"detail":[{"loc":["body","amount_cents"],"msg":"muss groesser als 0 sein","type":"value_error"}]}"#.utf8)
|
||
#expect(Fehlerantwort.ausDaten(daten) == "muss groesser als 0 sein")
|
||
}
|
||
|
||
@Test("Was gar kein Fehlerkörper ist, ergibt leeren Text statt eines Absturzes")
|
||
func unbrauchbar() {
|
||
#expect(Fehlerantwort.ausDaten(Data("<html>".utf8)) == "")
|
||
#expect(Fehlerantwort.ausDaten(Data()) == "")
|
||
}
|
||
}
|
||
|
||
// MARK: - Modelle gegen echte Antworten
|
||
|
||
@Suite("Modelle")
|
||
struct ModellTests {
|
||
|
||
@Test("Ein Konto, wie der Server es schickt")
|
||
func konto() throws {
|
||
// Abgenommen von einer echten Antwort aus GET /api/accounts.
|
||
let roh = """
|
||
{"id":3,"name":"Revolut CHF","type":"girokonto","currency":"CHF",
|
||
"icon":"wallet","color":"#3f51b5","opening_balance_cents":0,
|
||
"opening_date":"2026-01-01","credit_limit_cents":null,
|
||
"is_locked":false,"is_archived":false,"position":2,
|
||
"savings_category_id":null,
|
||
"identifiers":[{"id":7,"kind":"iban","value":"CH93..."}],
|
||
"balance_cents":-1043,"balance_base_cents":-1043,
|
||
"bank_balance_cents":null,"bank_balance_at":null,
|
||
"bank_balance_vergleichbar":false}
|
||
"""
|
||
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
|
||
#expect(konto.name == "Revolut CHF")
|
||
#expect(konto.type == .girokonto)
|
||
#expect(konto.saldo.cents == -1043)
|
||
#expect(konto.identifiers.first?.kind == .iban)
|
||
#expect(konto.weichtVonDerBankAb == false)
|
||
}
|
||
|
||
@Test("Ein Banksaldo, der abweicht, wird gemeldet")
|
||
func saldoWeichtAb() throws {
|
||
let roh = """
|
||
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
|
||
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
|
||
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
|
||
"position":0,"savings_category_id":null,"identifiers":[],
|
||
"balance_cents":1000,"balance_base_cents":1000,
|
||
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
|
||
"bank_balance_vergleichbar":true}
|
||
"""
|
||
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
|
||
#expect(konto.weichtVonDerBankAb)
|
||
}
|
||
|
||
@Test("Ein nicht vergleichbarer Banksaldo loest keinen Hinweis aus")
|
||
func saldoNichtVergleichbar() throws {
|
||
// Ein verfuegbarer Saldo enthaelt Vorgemerktes - dass er abweicht,
|
||
// ist normal und keine Meldung wert.
|
||
let roh = """
|
||
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
|
||
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
|
||
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
|
||
"position":0,"savings_category_id":null,"identifiers":[],
|
||
"balance_cents":1000,"balance_base_cents":1000,
|
||
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
|
||
"bank_balance_vergleichbar":false}
|
||
"""
|
||
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
|
||
#expect(konto.weichtVonDerBankAb == false)
|
||
}
|
||
|
||
@Test("Einstellungen mit Lücken fallen auf sinnvolle Werte zurück")
|
||
func einstellungen() {
|
||
let leer = Einstellungen([])
|
||
#expect(leer.basiswaehrung == "EUR")
|
||
#expect(leer.budgetStarttag == 1)
|
||
|
||
let gesetzt = Einstellungen([
|
||
Einstellung(key: "base_currency", value: "CHF"),
|
||
Einstellung(key: "budget_start_day", value: "25"),
|
||
Einstellung(key: "active_currencies", value: "EUR, CHF ,USD"),
|
||
])
|
||
#expect(gesetzt.basiswaehrung == "CHF")
|
||
#expect(gesetzt.budgetStarttag == 25)
|
||
#expect(gesetzt.aktiveWaehrungen == ["EUR", "CHF", "USD"])
|
||
}
|
||
|
||
@Test("Ein unsinniger Starttag wird eingefangen")
|
||
func starttagGrenzen() {
|
||
// Das Backend begrenzt auf 1..31; die App darf sich nicht darauf
|
||
// verlassen, dass in der Einstellung nichts anderes steht.
|
||
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "99")])
|
||
.budgetStarttag == 31)
|
||
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "0")])
|
||
.budgetStarttag == 1)
|
||
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "weiss nicht")])
|
||
.budgetStarttag == 1)
|
||
}
|
||
|
||
@Test("Die Serverkennung erkennt einen fremden Server")
|
||
func serverkennung() throws {
|
||
let echt = try JSONDecoder().decode(
|
||
Serverkennung.self,
|
||
from: Data(#"{"name":"Certain Finance","version":"0.1.0","git_sha":"abc"}"#.utf8))
|
||
#expect(echt.istCertain)
|
||
|
||
let fremd = try JSONDecoder().decode(
|
||
Serverkennung.self,
|
||
from: Data(#"{"name":"Nextcloud","version":"29","git_sha":null}"#.utf8))
|
||
#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")
|
||
// Nicht auf eine genaue Zahl pruefen: Die Pruefdatei waechst mit
|
||
// jeder Stufe, und ein Test, der dann rot wird, sagt nichts aus.
|
||
#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)
|
||
// Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je
|
||
// Richtung - also halb so viele Paare wie Eintraege. Genau so viele
|
||
// Zeilen fallen beim Zusammenfassen weg.
|
||
#expect(zeilen.count == alle.count - paare.count / 2)
|
||
let uebertraege = zeilen.filter(\.istUebertrag)
|
||
#expect(!uebertraege.isEmpty)
|
||
}
|
||
|
||
@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)
|
||
}
|
||
}
|
||
|
||
// MARK: - Was zum Server hinausgeht
|
||
|
||
@Suite("Eingaben")
|
||
struct EingabenTests {
|
||
|
||
/// Die Felder, die wirklich im JSON stehen.
|
||
private func felder<A: Encodable>(_ wert: A) throws -> [String: Any] {
|
||
let roh = try JSONEncoder().encode(wert)
|
||
return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any])
|
||
}
|
||
|
||
@Test("Eine Teiländerung schickt nur, was gesetzt ist")
|
||
func teilaenderungBleibtTeilweise() throws {
|
||
// Der Fall, der stillschweigend Daten frisst: Ginge ein nicht
|
||
// angefasstes Feld als null hinaus, loeschte jedes Speichern den
|
||
// Empfänger, die Notiz und die Kategorie gleich mit.
|
||
let nurNotiz = try felder(BuchungAendern(notes: "Kassenbon fehlt"))
|
||
|
||
#expect(nurNotiz.keys.sorted() == ["notes"])
|
||
#expect(nurNotiz["notes"] as? String == "Kassenbon fehlt")
|
||
}
|
||
|
||
@Test("Eine leere Tag-Liste geht mit, denn sie bedeutet etwas")
|
||
func leereTagListeGehtMit() throws {
|
||
// Mitgeschickt heisst ERSETZEN. Eine leere Liste nimmt alle Tags ab;
|
||
// sie wegzulassen liesse sie stehen. Die zwei Faelle duerfen nicht
|
||
// zusammenfallen.
|
||
let ohneTags = try felder(BuchungAendern(tagIds: []))
|
||
#expect(ohneTags["tag_ids"] as? [Int] == [])
|
||
|
||
let unberuehrt = try felder(BuchungAendern(payee: "Laden"))
|
||
#expect(unberuehrt["tag_ids"] == nil)
|
||
}
|
||
|
||
@Test("Die Feldnamen sind die des Backends")
|
||
func feldnamenPassen() throws {
|
||
let anlegen = try felder(BuchungAnlegen(
|
||
type: .expense, date: #require(Tagesdatum("2026-10-15")), accountId: 3,
|
||
amountCents: 4250, categoryId: 7, payee: "Supermarkt"))
|
||
|
||
#expect(anlegen["account_id"] as? Int == 3)
|
||
#expect(anlegen["amount_cents"] as? Int == 4250)
|
||
#expect(anlegen["category_id"] as? Int == 7)
|
||
#expect(anlegen["date"] as? String == "2026-10-15")
|
||
#expect(anlegen["type"] as? String == "expense")
|
||
}
|
||
|
||
@Test("Konto und Vertrag ändern sich ebenso nur in dem, was gesetzt ist")
|
||
func weitereTeilaenderungen() throws {
|
||
#expect(try felder(KontoAendern(isArchived: true)).keys.sorted() == ["is_archived"])
|
||
#expect(try felder(AboAendern(isActive: false)).keys.sorted() == ["is_active"])
|
||
|
||
let kreditrahmen = try felder(KontoAendern(creditLimitCents: 200000))
|
||
#expect(kreditrahmen["credit_limit_cents"] as? Int == 200000)
|
||
}
|
||
|
||
@Test("Die Rhythmen sind genau die, die das Backend annimmt")
|
||
func rhythmenPassenZumBackend() {
|
||
// INTERVALL_MONATE in backend/app/schemas.py. Weicht die App ab,
|
||
// antwortet der Server mit 422 auf etwas, das die Auswahl anbot.
|
||
#expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12])
|
||
}
|
||
}
|
||
|
||
// MARK: - Arbeitsliste
|
||
|
||
@Suite("Arbeitsliste")
|
||
struct ArbeitslisteTests {
|
||
|
||
private func eintrag(stand: String, konto: Int?, kategorie: Int?,
|
||
gegenkonto: Int? = nil) throws -> Eingangseintrag {
|
||
let roh = """
|
||
{"id": 1, "source": "shortcut", "status": "\(stand)",
|
||
"account_id": \(konto.map(String.init) ?? "null"),
|
||
"date": "2026-10-15", "amount_cents": -1290, "currency": "EUR",
|
||
"payee": "Baeckerei", "note": null,
|
||
"category_id": \(kategorie.map(String.init) ?? "null"),
|
||
"transaction_id": null,
|
||
"transfer_account_id": \(gegenkonto.map(String.init) ?? "null"),
|
||
"fx_rate": null, "counter_amount_cents": null, "counter_currency": null,
|
||
"created_at": "2026-10-15T14:18:15.780705"}
|
||
"""
|
||
return try JSONDecoder().decode(Eingangseintrag.self, from: Data(roh.utf8))
|
||
}
|
||
|
||
@Test("Ohne Konto laesst sich nichts buchen")
|
||
func ohneKontoKeinBuchen() throws {
|
||
// Das Backend lehnt es mit 400 ab. Den Knopf trotzdem anzubieten
|
||
// hiesse, dem Nutzer eine Fehlermeldung als Antwort zu geben.
|
||
#expect(try eintrag(stand: "pending", konto: nil, kategorie: nil)
|
||
.laesstSichBuchen == false)
|
||
#expect(try eintrag(stand: "pending", konto: 3, kategorie: nil)
|
||
.laesstSichBuchen)
|
||
}
|
||
|
||
@Test("Was schon gebucht ist, steht nicht mehr zum Buchen an")
|
||
func gebuchtesIstErledigt() throws {
|
||
#expect(try eintrag(stand: "booked", konto: 3, kategorie: 7)
|
||
.laesstSichBuchen == false)
|
||
// Unklares schon: Es wartet auf einen Blick, nicht auf ein Konto.
|
||
#expect(try eintrag(stand: "unclear", konto: 3, kategorie: nil)
|
||
.laesstSichBuchen)
|
||
}
|
||
|
||
@Test("Ein Uebertrag braucht keine Kategorie")
|
||
func uebertragBrauchtKeineKategorie() throws {
|
||
// Ohne transfer_account_id liesse sich ein Uebertrag nicht von einer
|
||
// Ausgabe unterscheiden - und die App fragte ewig nach einer
|
||
// Kategorie, die es nie geben wird.
|
||
#expect(try eintrag(stand: "booked", konto: 3, kategorie: nil)
|
||
.brauchtKategorie)
|
||
#expect(try eintrag(stand: "booked", konto: 3, kategorie: nil, gegenkonto: 4)
|
||
.brauchtKategorie == false)
|
||
}
|
||
|
||
@Test("Ein Vorschlag ist dasselbe Paar, egal herum")
|
||
func paarIstUngeordnet() throws {
|
||
let roh = """
|
||
{"verknuepft": 0, "vorschlaege": [
|
||
{"a": {"id": 9, "date": "2026-10-15", "type": "expense",
|
||
"amount_cents": 7500, "currency": "EUR", "account_id": 1,
|
||
"account_name": "Giro", "payee": "Dauerauftrag",
|
||
"description": null, "category_id": null},
|
||
"b": {"id": 10, "date": "2026-10-15", "type": "income",
|
||
"amount_cents": 7500, "currency": "EUR", "account_id": 3,
|
||
"account_name": "Zweitkonto", "payee": "Dauerauftrag",
|
||
"description": null, "category_id": 4}}]}
|
||
"""
|
||
let stand = try JSONDecoder().decode(Uebertragsvorschlaege.self,
|
||
from: Data(roh.utf8))
|
||
let paar = try #require(stand.vorschlaege.first)
|
||
|
||
// Die ID muss unabhaengig von der Reihenfolge sein, sonst zeigt die
|
||
// Liste dasselbe Paar als zwei Eintraege.
|
||
#expect(paar.id == "9-10")
|
||
// Eine zugeordnete Buchung faellt durchs Verbinden aus dem Budget.
|
||
#expect(paar.hatKategorie)
|
||
}
|
||
}
|
||
|
||
extension ServerantwortenTests {
|
||
|
||
@Test("Eingang, Dubletten und Uebertragsvorschlaege")
|
||
func arbeitsliste() throws {
|
||
let eingang = try lies([Eingangseintrag].self, "eingang")
|
||
#expect(!eingang.isEmpty)
|
||
#expect(eingang.allSatisfy { $0.currency.isEmpty == false })
|
||
|
||
let dubletten = try lies(Dublettengruppen.self, "dubletten")
|
||
let gruppe = try #require(dubletten.gruppen.first)
|
||
#expect(gruppe.zeilen.count >= 2)
|
||
|
||
let vorschlaege = try lies(Uebertragsvorschlaege.self, "uebertragsvorschlaege")
|
||
#expect(vorschlaege.verknuepft == 0, "beim Fragen darf nichts verknuepft werden")
|
||
#expect(vorschlaege.vorschlaege.count == 1)
|
||
// Das Paar stand frueher zweimal drin, einmal je Richtung.
|
||
let ids = Set(vorschlaege.vorschlaege.map(\.id))
|
||
#expect(ids.count == vorschlaege.vorschlaege.count)
|
||
}
|
||
}
|