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("".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) } }