Files
nahbar/nahbar/nahbarTests/StoreTests.swift
T
sven 7605a2d30c Fix #12: Interessen als farbige Chips mit Autocomplete
- InterestTagHelper: parse/join/add/remove/suggestions (alphabetisch sortiert)
- InterestChipRow: wiederverwendbare Display-Komponente (grün/rot)
- InterestTagEditor: Chip-Editor mit × + Tipp-Autocomplete
- AddPersonView, PersonDetailView, IchView auf neue Komponenten umgestellt
- 20 InterestTagHelper-Tests in StoreTests
- Lokalisierung: "Tag hinzufügen…" → "Add tag…"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:17:50 +02:00

490 lines
16 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 nahbar
// MARK: - SubscriptionTier Tests
@Suite("SubscriptionTier Enum")
struct SubscriptionTierTests {
@Test("Genau 2 Tiers vorhanden")
func allCasesCount() {
#expect(SubscriptionTier.allCases.count == 2)
}
@Test("Pro productID ist 'profeatures'")
func proProductID() {
#expect(SubscriptionTier.pro.productID == "profeatures")
}
@Test("Max productID ist 'maxfeatures'")
func maxProductID() {
#expect(SubscriptionTier.max.productID == "maxfeatures")
}
@Test("Pro displayName ist 'Pro'")
func proDisplayName() {
#expect(SubscriptionTier.pro.displayName == "Pro")
}
@Test("Max displayName ist 'Max'")
func maxDisplayName() {
#expect(SubscriptionTier.max.displayName == "Max")
}
@Test("productIDs sind einzigartig")
func productIDsAreUnique() {
let ids = SubscriptionTier.allCases.map { $0.productID }
#expect(Set(ids).count == SubscriptionTier.allCases.count)
}
@Test("displayNames sind einzigartig")
func displayNamesAreUnique() {
let names = SubscriptionTier.allCases.map { $0.displayName }
#expect(Set(names).count == SubscriptionTier.allCases.count)
}
@Test("productIDs sind nicht leer")
func productIDsNotEmpty() {
for tier in SubscriptionTier.allCases {
#expect(!tier.productID.isEmpty)
}
}
}
// MARK: - AI Free Query Counter Tests
// Tests laufen serialisiert, da alle auf UserDefaults.standard mit gleichem Key schreiben
@Suite("AIAnalysisService Gratis-Abfragen", .serialized)
struct AIFreeQueryTests {
init() {
AIAnalysisService.shared.freeQueriesUsed = 0
}
@Test("Limit ist genau 3")
func limitIsThree() {
#expect(AIAnalysisService.freeQueryLimit == 3)
}
@Test("Initial: Abfragen verfügbar, remaining = 3")
func initialState() {
AIAnalysisService.shared.freeQueriesUsed = 0
#expect(AIAnalysisService.shared.hasFreeQueriesLeft == true)
#expect(AIAnalysisService.shared.freeQueriesRemaining == 3)
}
@Test("consumeFreeQuery erhöht Zähler um 1")
func consumeIncrementsCounter() {
AIAnalysisService.shared.freeQueriesUsed = 0
AIAnalysisService.shared.consumeFreeQuery()
#expect(AIAnalysisService.shared.freeQueriesUsed == 1)
#expect(AIAnalysisService.shared.freeQueriesRemaining == 2)
}
@Test("Nach 3 Verbrauchungen: hasFreeQueriesLeft == false")
func afterThreeConsumed() {
AIAnalysisService.shared.freeQueriesUsed = 0
AIAnalysisService.shared.consumeFreeQuery()
AIAnalysisService.shared.consumeFreeQuery()
AIAnalysisService.shared.consumeFreeQuery()
#expect(AIAnalysisService.shared.hasFreeQueriesLeft == false)
#expect(AIAnalysisService.shared.freeQueriesRemaining == 0)
}
@Test("Zähler über Limit: remaining bleibt 0, nicht negativ")
func remainingNotNegative() {
AIAnalysisService.shared.freeQueriesUsed = 10
#expect(AIAnalysisService.shared.freeQueriesRemaining == 0)
#expect(AIAnalysisService.shared.hasFreeQueriesLeft == false)
}
@Test("Zähler = limit - 1: noch genau 1 Abfrage verfügbar")
func oneQueryLeft() {
AIAnalysisService.shared.freeQueriesUsed = AIAnalysisService.freeQueryLimit - 1
#expect(AIAnalysisService.shared.hasFreeQueriesLeft == true)
#expect(AIAnalysisService.shared.freeQueriesRemaining == 1)
}
@Test("Zähler = limit: keine Abfragen mehr verfügbar")
func atLimit() {
AIAnalysisService.shared.freeQueriesUsed = AIAnalysisService.freeQueryLimit
#expect(AIAnalysisService.shared.hasFreeQueriesLeft == false)
}
@Test("freeQueriesUsed + freeQueriesRemaining = limit (solange unter limit)")
func usedPlusRemainingEqualsLimit() {
for used in 0...AIAnalysisService.freeQueryLimit {
AIAnalysisService.shared.freeQueriesUsed = used
let remaining = AIAnalysisService.shared.freeQueriesRemaining
#expect(used + remaining == AIAnalysisService.freeQueryLimit)
}
}
}
// MARK: - AppGroup Pro-Status Tests
// Tests laufen serialisiert, da alle dieselbe UserDefaults-Suite nutzen
@Suite("AppGroup Pro-Status", .serialized)
struct AppGroupProStatusTests {
private let testDefaults = UserDefaults(suiteName: "nahbar.test.proStatus")!
init() {
testDefaults.removeObject(forKey: "isPro")
testDefaults.synchronize()
}
@Test("Pro-Status initial false wenn nicht gesetzt")
func proStatusInitiallyFalse() {
testDefaults.removeObject(forKey: "isPro")
#expect(testDefaults.bool(forKey: "isPro") == false)
}
@Test("Pro-Status round-trip: true")
func proStatusRoundTripTrue() {
testDefaults.set(true, forKey: "isPro")
#expect(testDefaults.bool(forKey: "isPro") == true)
}
@Test("Pro-Status round-trip: false")
func proStatusRoundTripFalse() {
testDefaults.set(true, forKey: "isPro")
testDefaults.set(false, forKey: "isPro")
#expect(testDefaults.bool(forKey: "isPro") == false)
}
@Test("Pro-Status nach removeObject ist false")
func proStatusAfterRemoveIsFalse() {
testDefaults.set(true, forKey: "isPro")
testDefaults.removeObject(forKey: "isPro")
#expect(testDefaults.bool(forKey: "isPro") == false)
}
}
// MARK: - FeatureGate Consistency Tests
/// Stellt sicher, dass die Invariante "Max implies Pro" unter allen Transaktions-Kombinationen gilt.
@Suite("FeatureGate Konsistenz")
struct FeatureGateConsistencyTests {
@Test("free: isPro und isMax beide false")
func freeHasNoAccess() {
let gate = FeatureGate.from(foundPro: false, foundMax: false)
#expect(gate.isPro == false)
#expect(gate.isMax == false)
}
@Test("Pro-only: isPro true, isMax false")
func proOnlySetsPro() {
let gate = FeatureGate.from(foundPro: true, foundMax: false)
#expect(gate.isPro == true)
#expect(gate.isMax == false)
}
@Test("Max-only: isMax true setzt isPro automatisch true (Max implies Pro)")
func maxOnlyImpliesPro() {
let gate = FeatureGate.from(foundPro: false, foundMax: true)
#expect(gate.isMax == true)
#expect(gate.isPro == true, "Max-Abonnenten müssen auch Pro-Features erhalten")
}
@Test("Pro + Max: beide true")
func proAndMaxBothTrue() {
let gate = FeatureGate.from(foundPro: true, foundMax: true)
#expect(gate.isPro == true)
#expect(gate.isMax == true)
}
@Test("isConsistent gilt für alle 4 Transaktions-Kombinationen")
func consistencyHoldsForAllCombinations() {
let combinations = [(false, false), (true, false), (false, true), (true, true)]
for (foundPro, foundMax) in combinations {
let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
#expect(gate.isConsistent, "Invariante verletzt für foundPro=\(foundPro), foundMax=\(foundMax)")
}
}
@Test("FeatureGate.free entspricht from(false, false)")
func freeStaticMatchesFromFactory() {
#expect(FeatureGate.free == FeatureGate.from(foundPro: false, foundMax: false))
}
}
// MARK: - FeatureGate Access Tests
/// Verifiziert, dass jedes Feature genau für den richtigen Tier freigeschaltet wird.
@Suite("FeatureGate Feature-Zugriff")
struct FeatureGateAccessTests {
// MARK: Free-User
@Test("Free-User: keine Pro-Features")
func freeUserHasNoProFeatures() {
let gate = FeatureGate.free
#expect(!gate.unlimitedContacts)
#expect(!gate.premiumThemes)
#expect(!gate.shareExtension)
}
@Test("Free-User: keine Max-Features")
func freeUserHasNoMaxFeatures() {
let gate = FeatureGate.free
#expect(!gate.aiAnalysis)
#expect(!gate.giftSuggestions)
#expect(!gate.conversationTopics)
#expect(!gate.unlimitedAIQueries)
}
// MARK: Pro-only
@Test("Pro-User: alle Pro-Features aktiv")
func proUserHasAllProFeatures() {
let gate = FeatureGate.from(foundPro: true, foundMax: false)
#expect(gate.unlimitedContacts)
#expect(gate.premiumThemes)
#expect(gate.shareExtension)
}
@Test("Pro-User: keine Max-Features")
func proUserHasNoMaxFeatures() {
let gate = FeatureGate.from(foundPro: true, foundMax: false)
#expect(!gate.aiAnalysis)
#expect(!gate.giftSuggestions)
#expect(!gate.conversationTopics)
#expect(!gate.unlimitedAIQueries)
}
// MARK: Max-User
@Test("Max-User: alle Pro-Features aktiv (Max implies Pro)")
func maxUserHasAllProFeatures() {
let gate = FeatureGate.from(foundPro: false, foundMax: true)
#expect(gate.unlimitedContacts, "Max-Abonnenten müssen unbegrenzte Kontakte haben")
#expect(gate.premiumThemes, "Max-Abonnenten müssen Premium-Themes haben")
#expect(gate.shareExtension, "Max-Abonnenten müssen die Share-Extension nutzen können")
}
@Test("Max-User: alle Max-Features aktiv")
func maxUserHasAllMaxFeatures() {
let gate = FeatureGate.from(foundPro: false, foundMax: true)
#expect(gate.aiAnalysis, "Max-Abonnenten müssen KI-Analyse nutzen können")
#expect(gate.giftSuggestions, "Max-Abonnenten müssen Geschenkideen nutzen können")
#expect(gate.conversationTopics, "Max-Abonnenten müssen Gesprächsthemen nutzen können")
#expect(gate.unlimitedAIQueries, "Max-Abonnenten müssen unbegrenzte KI-Abfragen haben")
}
}
// MARK: - FeatureGate canUseAI Tests
/// Verifiziert die canUseAI-Logik, die in AddMomentView, TodayView und LogbuchView identisch ist:
/// store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
@Suite("FeatureGate canUseAI")
struct FeatureGateCanUseAITests {
@Test("Max + keine Gratis-Abfragen: KI verfügbar (unbegrenzt)")
func maxUserAlwaysCanUseAI() {
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: false))
}
@Test("Max + Gratis-Abfragen vorhanden: KI verfügbar")
func maxUserWithQueriesCanUseAI() {
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: true))
}
@Test("Nicht-Max + Gratis-Abfragen vorhanden: KI verfügbar")
func nonMaxWithQueriesCanUseAI() {
#expect(FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: true))
}
@Test("Nicht-Max + keine Gratis-Abfragen: KI gesperrt")
func nonMaxWithoutQueriesCannotUseAI() {
#expect(!FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: false))
}
}
// MARK: - FeatureGate shouldConsumeFreeQuery Tests
/// Verifiziert die Verbrauchs-Logik:
/// if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
@Suite("FeatureGate shouldConsumeFreeQuery")
struct FeatureGateFreeQueryConsumptionTests {
@Test("Max-Abonnent: Gratis-Abfrage wird NICHT verbraucht")
func maxUserDoesNotConsumeFreeQuery() {
#expect(!FeatureGate.shouldConsumeFreeQuery(isMax: true))
}
@Test("Nicht-Max-Abonnent: Gratis-Abfrage wird verbraucht")
func nonMaxUserConsumesQuery() {
#expect(FeatureGate.shouldConsumeFreeQuery(isMax: false))
}
}
// MARK: - Paywall-Targeting Tests
/// Dokumentiert die Logik aus SettingsView:
/// PaywallView(targeting: store.isPro ? .max : .pro)
/// Stellt sicher, dass der Einstiegs-Tab beim Öffnen des Paywalls korrekt ist.
@Suite("Paywall Ziel-Tier basierend auf isPro")
struct PaywallTargetingTests {
/// Repliziert die einzeilige Entscheidungslogik aus SettingsView.
private func target(isPro: Bool) -> SubscriptionTier {
isPro ? .max : .pro
}
@Test("Kein Abo → Paywall öffnet Pro-Tab")
func noSubscriptionTargetsPro() {
#expect(target(isPro: false) == .pro)
}
@Test("Pro-only → Paywall öffnet Max-Tab (Upgrade-Pfad)")
func proOnlyTargetsMax() {
#expect(target(isPro: true) == .max)
}
@Test("Ziel-Tiers sind unterschiedlich")
func targetsAreDistinct() {
#expect(target(isPro: false) != target(isPro: true))
}
}
// MARK: - InterestTagHelper Tests
@Suite("InterestTagHelper parse")
struct InterestTagHelperParseTests {
@Test("Leerer String → leeres Array")
func emptyStringReturnsEmpty() {
#expect(InterestTagHelper.parse("") == [])
}
@Test("Einzelner Tag ohne Komma")
func singleTag() {
#expect(InterestTagHelper.parse("Fußball") == ["Fußball"])
}
@Test("Mehrere Tags kommasepariert")
func multipleTags() {
let result = InterestTagHelper.parse("Fußball, Musik, Lesen")
#expect(result == ["Fußball", "Musik", "Lesen"])
}
@Test("Whitespace wird getrimmt")
func whitespaceTrimmed() {
let result = InterestTagHelper.parse(" Kino , Sport ")
#expect(result == ["Kino", "Sport"])
}
@Test("Leere Segmente werden gefiltert")
func emptySegmentsFiltered() {
let result = InterestTagHelper.parse("Kino,,Musik,")
#expect(result == ["Kino", "Musik"])
}
}
@Suite("InterestTagHelper join")
struct InterestTagHelperJoinTests {
@Test("Leeres Array → leerer String")
func emptyArrayReturnsEmpty() {
#expect(InterestTagHelper.join([]) == "")
}
@Test("Einzelner Tag bleibt unverändert")
func singleTagUnchanged() {
#expect(InterestTagHelper.join(["Fußball"]) == "Fußball")
}
@Test("Tags werden alphabetisch sortiert")
func tagsSortedAlphabetically() {
let result = InterestTagHelper.join(["Musik", "Fußball", "Lesen"])
#expect(result == "Fußball, Lesen, Musik")
}
@Test("Sortierung ist case-insensitive")
func sortingCaseInsensitive() {
let result = InterestTagHelper.join(["bier", "Apfel", "Chips"])
#expect(result == "Apfel, bier, Chips")
}
}
@Suite("InterestTagHelper addTag")
struct InterestTagHelperAddTagTests {
@Test("Tag zu leerem String hinzufügen")
func addToEmpty() {
#expect(InterestTagHelper.addTag("Kino", to: "") == "Kino")
}
@Test("Tag zu bestehendem String alphabetisch einsortiert")
func addSortsAlphabetically() {
let result = InterestTagHelper.addTag("Musik", to: "Fußball")
#expect(result == "Fußball, Musik")
}
@Test("Duplikat wird ignoriert")
func duplicateIgnored() {
let result = InterestTagHelper.addTag("Kino", to: "Kino, Sport")
#expect(result == "Kino, Sport")
}
@Test("Duplikat ignoriert (Groß-/Kleinschreibung)")
func duplicateCaseInsensitive() {
let result = InterestTagHelper.addTag("kino", to: "Kino")
#expect(result == "Kino")
}
@Test("Leerer String wird ignoriert")
func emptyTagIgnored() {
let result = InterestTagHelper.addTag("", to: "Kino")
#expect(result == "Kino")
}
}
@Suite("InterestTagHelper removeTag")
struct InterestTagHelperRemoveTagTests {
@Test("Tag aus String entfernen")
func removeExistingTag() {
let result = InterestTagHelper.removeTag("Musik", from: "Fußball, Musik, Sport")
#expect(result == "Fußball, Sport")
}
@Test("Nicht vorhandener Tag → unveränderter String")
func removeNonExistentTag() {
let result = InterestTagHelper.removeTag("Kino", from: "Fußball, Sport")
#expect(result == "Fußball, Sport")
}
@Test("Letzten Tag entfernen → leerer String")
func removeLastTagReturnsEmpty() {
let result = InterestTagHelper.removeTag("Kino", from: "Kino")
#expect(result == "")
}
}
@Suite("InterestTagHelper allSuggestions")
struct InterestTagHelperSuggestionsTests {
@Test("Keine Personen + leere Vorlieben → leere Vorschläge")
func emptyInputsReturnEmpty() {
let result = InterestTagHelper.allSuggestions(from: [], likes: "", dislikes: "")
#expect(result.isEmpty)
}
@Test("Vorschläge aus likes und dislikes kombiniert und sortiert")
func combinesLikesAndDislikes() {
let result = InterestTagHelper.allSuggestions(from: [], likes: "Musik, Kino", dislikes: "Sport")
#expect(result == ["Kino", "Musik", "Sport"])
}
@Test("Duplikate werden dedupliziert")
func deduplicates() {
let result = InterestTagHelper.allSuggestions(from: [], likes: "Kino, Musik", dislikes: "Kino")
#expect(!result.contains { result.filter { $0 == "Kino" }.count > 1 })
#expect(result.filter { $0 == "Kino" }.count == 1)
}
}