Files
certain/ios/Tests/GrundlagenTests.swift
T
GuidoandClaude Opus 5 bf437b3b69 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>
2026-10-21 20:24:06 +02:00

581 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
#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)
}
}