7605a2d30c
- 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>
490 lines
16 KiB
Swift
490 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|