Files
nahbar/nahbar/nahbarTests/NahbarPersonalityTests.swift
T
sven 9ca54e6a82 Fix #15: Push-Nachrichten – warmer Ton + Persönlichkeitsanpassung
- PersonalityEngine: callWindowCopy() + aftermathCopy() zentralisieren
  alle Notification-Texte (bisher verstreut und teils unlokalisiert)
- CallWindowManager: 3 Varianten nach Profil (high E / high N / default),
  String(localized:) + Error-Logging ergänzt
- AftermathNotificationManager: Titel "Nachwirkung: %@" → "Wie war's mit %@?",
  Body via PersonalityEngine.aftermathCopy()
- Todo- und Vorhaben-Erinnerungen: subtitle "Dein Vorhaben" für
  persönlicheren Kontext (AddTodoView, EditTodoView, AddMomentView)
- AddMomentView: Logging + Error-Callback nachgezogen (wie AddTodoView)
- 9 neue Tests in NahbarPersonalityTests (NotificationCopyTests)
- Lokalisierung: 4 neue Strings inkl. bisher unlokalisierter Texte

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

620 lines
22 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: - OceanDimension
@Suite("OceanDimension Enum")
struct OceanDimensionTests {
@Test("Genau 5 Dimensionen vorhanden")
func allCasesCount() {
#expect(OceanDimension.allCases.count == 5)
}
@Test("rawValues sind nicht leer")
func rawValuesNotEmpty() {
for dim in OceanDimension.allCases {
#expect(!dim.rawValue.isEmpty, "\(dim) hat leeren rawValue")
}
}
@Test("rawValue round-trip (init(rawValue:))")
func rawValueRoundTrip() {
for dim in OceanDimension.allCases {
let recovered = OceanDimension(rawValue: dim.rawValue)
#expect(recovered == dim, "\(dim.rawValue) kann nicht wiederhergestellt werden")
}
}
@Test("shortLabel hat genau 1 Zeichen")
func shortLabelLength() {
for dim in OceanDimension.allCases {
#expect(dim.shortLabel.count == 1, "\(dim) hat shortLabel '\(dim.shortLabel)' (nicht 1 Zeichen)")
}
}
@Test("shortLabels sind eindeutig")
func shortLabelsUnique() {
let labels = OceanDimension.allCases.map { $0.shortLabel }
#expect(Set(labels).count == labels.count, "Doppelte shortLabels: \(labels)")
}
@Test("Stabile rawValues Regressionswächter")
func stableRawValues() {
#expect(OceanDimension.openness.rawValue == "openness")
#expect(OceanDimension.conscientiousness.rawValue == "conscientiousness")
#expect(OceanDimension.extraversion.rawValue == "extraversion")
#expect(OceanDimension.agreeableness.rawValue == "agreeableness")
#expect(OceanDimension.neuroticism.rawValue == "neuroticism")
}
@Test("axisLabel ist nicht leer")
func axisLabelNotEmpty() {
for dim in OceanDimension.allCases {
#expect(!dim.axisLabel.isEmpty)
}
}
@Test("icon ist nicht leer")
func iconNotEmpty() {
for dim in OceanDimension.allCases {
#expect(!dim.icon.isEmpty)
}
}
}
// MARK: - TraitLevel
@Suite("TraitLevel Schwellenwerte")
struct TraitLevelTests {
@Test("Score 0 → niedrig")
func score0IsLow() { #expect(TraitLevel.from(score: 0) == .low) }
@Test("Score 1 → mittel")
func score1IsMedium() { #expect(TraitLevel.from(score: 1) == .medium) }
@Test("Score 2 → hoch")
func score2IsHigh() { #expect(TraitLevel.from(score: 2) == .high) }
@Test("Score >2 → hoch (overflow sicher)")
func scoreOverflowIsHigh() { #expect(TraitLevel.from(score: 99) == .high) }
@Test("rawValue round-trip")
func rawValueRoundTrip() {
for level in TraitLevel.allCases {
#expect(TraitLevel(rawValue: level.rawValue) == level)
}
}
@Test("Stabile rawValues Regressionswächter")
func stableRawValues() {
#expect(TraitLevel.low.rawValue == "low")
#expect(TraitLevel.medium.rawValue == "medium")
#expect(TraitLevel.high.rawValue == "high")
}
}
// MARK: - QuizQuestion
@Suite("QuizQuestion Statische Fragen")
struct QuizQuestionTests {
@Test("Genau 10 Fragen")
func totalCount() {
#expect(QuizQuestion.all.count == 10)
}
@Test("Genau 2 Fragen pro Dimension")
func twoQuestionsPerDimension() {
for dim in OceanDimension.allCases {
let count = QuizQuestion.all.filter { $0.dimension == dim }.count
#expect(count == 2, "Dimension \(dim.rawValue) hat \(count) statt 2 Fragen")
}
}
@Test("Alle IDs sind eindeutig")
func uniqueIDs() {
let ids = QuizQuestion.all.map { $0.id }
#expect(Set(ids).count == ids.count, "Doppelte Fragen-IDs: \(ids)")
}
@Test("Situationstexte sind nicht leer")
func situationTextsNotEmpty() {
for q in QuizQuestion.all {
#expect(!q.situation.isEmpty, "Frage \(q.id) hat leeren Situationstext")
}
}
@Test("Option-Texte sind nicht leer")
func optionTextsNotEmpty() {
for q in QuizQuestion.all {
#expect(!q.optionA.isEmpty, "Frage \(q.id) hat leeren optionA-Text")
#expect(!q.optionB.isEmpty, "Frage \(q.id) hat leeren optionB-Text")
}
}
@Test("optionAScore ist 0 oder 1")
func optionAScoreIsValid() {
for q in QuizQuestion.all {
#expect(q.optionAScore == 0 || q.optionAScore == 1,
"Frage \(q.id) hat ungültigen optionAScore: \(q.optionAScore)")
}
}
@Test("Neurotizismus-Fragen haben optionAScore 0 (invertiert)")
func neuroticismIsInverted() {
let nQuestions = QuizQuestion.all.filter { $0.dimension == .neuroticism }
for q in nQuestions {
#expect(q.optionAScore == 0,
"Neurotizismus-Frage \(q.id) sollte optionAScore=0 haben (Option A = stabil)")
}
}
@Test("Stabile Fragen-IDs Regressionswächter")
func stableQuestionIDs() {
let ids = QuizQuestion.all.map { $0.id }
#expect(ids == ["O1", "O2", "C1", "C2", "E1", "E2", "A1", "A2", "N1", "N2"])
}
}
// MARK: - PersonalityEngine
@Suite("PersonalityEngine Score-Berechnung")
struct PersonalityEngineTests {
@Test("Alle Option-A → korrekter Score nach optionAScore")
func allOptionAGivesCorrectScore() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profile = PersonalityEngine.computeProfile(from: allA)
for dim in OceanDimension.allCases {
let expected = QuizQuestion.all
.filter { $0.dimension == dim }
.reduce(0) { $0 + $1.optionAScore }
#expect(profile.scores[dim] == expected,
"\(dim.rawValue): erwartet \(expected), bekommen \(profile.scores[dim] ?? -1)")
}
}
@Test("Alle Option-B → invertierter Score")
func allOptionBGivesInvertedScore() {
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
let profile = PersonalityEngine.computeProfile(from: allB)
for dim in OceanDimension.allCases {
let expected = QuizQuestion.all
.filter { $0.dimension == dim }
.reduce(0) { $0 + (1 - $1.optionAScore) }
#expect(profile.scores[dim] == expected,
"\(dim.rawValue): erwartet \(expected), bekommen \(profile.scores[dim] ?? -1)")
}
}
@Test("Neurotizismus invertiertes Scoring: Option A = 0 Punkte, Option B = 1 Punkt")
func neuroticismInvertedScoring() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profileA = PersonalityEngine.computeProfile(from: allA)
#expect(profileA.scores[.neuroticism] == 0,
"N mit Option A: erwartet 0, bekommen \(profileA.scores[.neuroticism] ?? -1)")
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
let profileB = PersonalityEngine.computeProfile(from: allB)
#expect(profileB.scores[.neuroticism] == 2,
"N mit Option B: erwartet 2, bekommen \(profileB.scores[.neuroticism] ?? -1)")
}
@Test("Profil ist complete nach 10 Antworten")
func profileIsCompleteAfterTenAnswers() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profile = PersonalityEngine.computeProfile(from: allA)
#expect(profile.isComplete)
#expect(profile.completedAt != nil)
}
@Test("Scores liegen immer im Bereich 0…2")
func scoresAlwaysInValidRange() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profileA = PersonalityEngine.computeProfile(from: allA)
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
let profileB = PersonalityEngine.computeProfile(from: allB)
for dim in OceanDimension.allCases {
#expect((profileA.scores[dim] ?? -1) >= 0 && (profileA.scores[dim] ?? -1) <= 2)
#expect((profileB.scores[dim] ?? -1) >= 0 && (profileB.scores[dim] ?? -1) <= 2)
}
}
@Test("Fehlende Antworten (übersprungen) führen zu Score 0 für die Dimension")
func skippedAnswersGiveZero() {
let profile = PersonalityEngine.computeProfile(from: [])
for dim in OceanDimension.allCases {
#expect(profile.scores[dim] == 0, "\(dim.rawValue) sollte 0 sein bei leeren Antworten")
}
}
@Test("completedAt liegt zwischen before und after dem Aufruf")
func completedAtIsReasonablyNow() {
let before = Date()
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profile = PersonalityEngine.computeProfile(from: allA)
let after = Date()
if let ts = profile.completedAt {
#expect(ts >= before && ts <= after)
} else {
Issue.record("completedAt sollte gesetzt sein")
}
}
}
// MARK: - PersonalityProfile
@Suite("PersonalityProfile Berechnung & Codable")
struct PersonalityProfileTests {
private func makeProfile(scores: [OceanDimension: Int] = [:]) -> PersonalityProfile {
var full = Dictionary(uniqueKeysWithValues: OceanDimension.allCases.map { ($0, 1) })
for (k, v) in scores { full[k] = v }
return PersonalityProfile(scores: full, completedAt: Date())
}
@Test("level für Score 0 → niedrig")
func levelLowForScoreZero() {
let p = makeProfile(scores: [.openness: 0])
#expect(p.level(for: .openness) == .low)
}
@Test("level für Score 1 → mittel")
func levelMediumForScoreOne() {
let p = makeProfile(scores: [.openness: 1])
#expect(p.level(for: .openness) == .medium)
}
@Test("level für Score 2 → hoch")
func levelHighForScoreTwo() {
let p = makeProfile(scores: [.openness: 2])
#expect(p.level(for: .openness) == .high)
}
@Test("normalized: Score 0 → 0.0")
func normalizedMinIsZero() {
let p = makeProfile(scores: [.openness: 0])
#expect(p.normalized(for: .openness) == 0.0)
}
@Test("normalized: Score 1 → 0.5")
func normalizedMidIsFifty() {
let p = makeProfile(scores: [.openness: 1])
#expect(p.normalized(for: .openness) == 0.5)
}
@Test("normalized: Score 2 → 1.0")
func normalizedMaxIsOne() {
let p = makeProfile(scores: [.openness: 2])
#expect(p.normalized(for: .openness) == 1.0)
}
@Test("summaryText ist nicht leer für alle Kombinationen")
func summaryTextNeverEmpty() {
for scores in [[TraitLevel.low, .low, .low, .low, .low],
[.high, .high, .high, .high, .high],
[.medium, .medium, .medium, .medium, .medium]] {
let profile = PersonalityProfile(
scores: Dictionary(uniqueKeysWithValues:
OceanDimension.allCases.enumerated().map { i, dim in
(dim, scores[i] == .low ? 0 : scores[i] == .medium ? 1 : 2)
}),
completedAt: Date()
)
#expect(!profile.summaryText.isEmpty)
}
}
@Test("isComplete ist false wenn completedAt nil")
func isCompleteIsFalseWhenNilDate() {
let p = PersonalityProfile(scores: [:], completedAt: nil)
#expect(!p.isComplete)
}
@Test("isComplete ist true wenn completedAt gesetzt")
func isCompleteIsTrueWhenDateSet() {
let p = makeProfile()
#expect(p.isComplete)
}
@Test("Codable round-trip via JSONEncoder/Decoder")
func codableRoundTrip() throws {
let original = makeProfile(scores: [.openness: 2, .extraversion: 0, .neuroticism: 1])
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(PersonalityProfile.self, from: data)
#expect(decoded == original)
}
@Test("PersonalityProfile benötigt keinen Netzwerkaufruf")
func doesNotRequireNetwork() {
// Rein synchron kein await, kein async
let p = makeProfile()
let summary = p.summaryText
#expect(!summary.isEmpty)
}
}
// MARK: - PersonalityEngine Behavior Logic
@Suite("PersonalityEngine Verhaltenslogik")
struct PersonalityEngineBehaviorTests {
private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium,
c: TraitLevel = .medium, a: TraitLevel = .medium,
o: TraitLevel = .medium) -> PersonalityProfile {
func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 }
return PersonalityProfile(scores: [
.extraversion: score(e), .neuroticism: score(n),
.conscientiousness: score(c), .agreeableness: score(a), .openness: score(o)
], completedAt: Date())
}
@Test("Hohe Extraversion → kürzeres Nudge-Intervall als niedrige")
func highExtraversionGivesShorterInterval() {
let highE = PersonalityEngine.suggestedNudgeInterval(for: profile(e: .high))
let lowE = PersonalityEngine.suggestedNudgeInterval(for: profile(e: .low))
#expect(highE < lowE)
}
@Test("Hoher Neurotizismus-Score + niedrige Extraversion → 14 Tage")
func lowExtraversionHighNeuroticismGives14Days() {
// N1 und N2 haben optionAScore=0, Option B gibt Punkte
// In unserem Modell: hoher Neurotizismus-Score = mehr N-Punkte
let p = profile(e: .low, n: .high)
#expect(PersonalityEngine.suggestedNudgeInterval(for: p) == 14)
}
@Test("Hohe Gewissenhaftigkeit → sofortiger Rating-Prompt")
func highConscientiousnessGivesImmediatePrompt() {
let p = profile(c: .high)
if case .immediate = PersonalityEngine.ratingPromptTiming(for: p) {
// korrekt
} else {
Issue.record("Hohe Gewissenhaftigkeit sollte immediate prompt liefern")
}
}
@Test("Hoher Neurotizismus → verzögerter Rating-Prompt (7200s)")
func highNeuroticismGivesDelayedPrompt() {
let p = profile(n: .high, c: .low)
if case .delayed(let secs, _) = PersonalityEngine.ratingPromptTiming(for: p) {
#expect(secs == 7200)
} else {
Issue.record("Hoher Neurotizismus sollte delayed prompt liefern")
}
}
@Test("Benachrichtigungstext mit hohem Neurotizismus ist wärmer")
func highNeuroticismNotificationCopyIsWarmer() {
let p = profile(n: .high)
let copy = PersonalityEngine.notificationCopy(contactName: "Alex", profile: p)
#expect(copy.contains("freut sich"))
}
@Test("Benachrichtigungstext ohne Profil liefert Fallback")
func nilProfileGivesFallback() {
let copy = PersonalityEngine.notificationCopy(contactName: "Alex", profile: nil)
#expect(!copy.isEmpty)
}
@Test("RecommendedBadge nicht angezeigt wenn Quiz übersprungen (kein Profil)")
func noBadgeWhenNoProfile() {
let suggestions = PersonalityEngine.sortedSuggestions(
contacts: [],
profile: nil,
lastMeetingDates: [:]
)
for s in suggestions {
#expect(!s.isRecommended)
}
}
@Test("Hohe Offenheit → highlightNovelty true")
func highOpennessHighlightsNovelty() {
let p = profile(o: .high)
#expect(PersonalityEngine.highlightNovelty(for: p))
}
@Test("Niedrige Offenheit → highlightNovelty false")
func lowOpennessDoesNotHighlightNovelty() {
let p = profile(o: .low)
#expect(!PersonalityEngine.highlightNovelty(for: p))
}
}
// MARK: - suggestedActivities Tests
@Suite("PersonalityEngine suggestedActivities")
struct SuggestedActivitiesTests {
@Test("Gibt genau count Elemente zurück")
func returnsRequestedCount() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2)
#expect(result.count == 2)
}
@Test("count: 1 → genau ein Vorschlag")
func countOne() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 1)
#expect(result.count == 1)
}
@Test("Alle zurückgegebenen Texte stammen aus dem Pool")
func resultsAreFromPool() {
let poolTexts = Set(PersonalityEngine.activityPool.map(\.text))
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5)
for text in result {
#expect(poolTexts.contains(text), "'\(text)' nicht im Pool")
}
}
@Test("Pool hat mindestens 20 Einträge")
func poolIsSufficient() {
#expect(PersonalityEngine.activityPool.count >= 20)
}
@Test("Keine Duplikate in einem Ergebnis")
func noDuplicates() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5)
#expect(result.count == Set(result).count)
}
@Test("Ergebnis ist nicht leer wenn Pool vorhanden")
func notEmptyWhenPoolExists() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2)
#expect(!result.isEmpty)
}
@Test("Pool enthält Erlebnis-Aktivitäten (isNovelty)")
func poolContainsNoveltyActivities() {
#expect(PersonalityEngine.activityPool.contains { $0.isNovelty })
}
@Test("Pool enthält 1:1 und Gruppen-Aktivitäten")
func poolContainsBothStyles() {
#expect(PersonalityEngine.activityPool.contains { $0.style == .oneOnOne })
#expect(PersonalityEngine.activityPool.contains { $0.style == .group })
}
@Test("Pool enthält Tag-spezifische Aktivitäten")
func poolContainsTagSpecificActivities() {
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .friends })
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .family })
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .work })
}
}
// MARK: - GenderSelectionScreen Skip-Logik
@Suite("PersonalityQuiz Geschlechtsabfrage überspringen")
struct PersonalityQuizGenderSkipTests {
@Test("Wenn Gender gesetzt → GenderSelectionScreen wird übersprungen (geht zu questions)")
func genderSetLeadsToQuestionsPhase() {
// Spiegelt die nextPhaseAfterIntro()-Logik wider
let gender = "Weiblich"
let shouldSkip = !gender.isEmpty
#expect(shouldSkip)
}
@Test("Wenn Gender leer → GenderSelectionScreen wird angezeigt")
func emptyGenderShowsSelectionScreen() {
let gender = ""
let shouldShow = gender.isEmpty
#expect(shouldShow)
}
@Test("Alle drei validen Werte überspringen den Screen")
func allValidGenderValuesSkipScreen() {
for gender in ["Männlich", "Weiblich", "Divers"] {
#expect(!gender.isEmpty, "\(gender) sollte Screen überspringen")
}
}
}
// MARK: - Notification Copy Tests
@Suite("PersonalityEngine Notification Copy")
struct NotificationCopyTests {
private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium,
c: TraitLevel = .medium) -> PersonalityProfile {
func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 }
return PersonalityProfile(scores: [
.extraversion: score(e), .neuroticism: score(n),
.conscientiousness: score(c), .agreeableness: 1, .openness: 1
], completedAt: Date())
}
// MARK: callWindowCopy
@Test("callWindowCopy: nil-Profil liefert nicht-leeren Fallback")
func callWindowCopyNilProfile() {
let copy = PersonalityEngine.callWindowCopy(profile: nil)
#expect(!copy.isEmpty)
}
@Test("callWindowCopy: hohe Extraversion → direkter Text")
func callWindowCopyHighExtraversion() {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .high))
#expect(copy.contains("freut sich"))
}
@Test("callWindowCopy: hoher Neurotizismus (nicht high E) → weicherer, ermutigender Text")
func callWindowCopyHighNeuroticism() {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .low, n: .high))
#expect(copy.contains("Magst du"))
}
@Test("callWindowCopy: Medium-Profil → Default-Text")
func callWindowCopyDefault() {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .medium, n: .medium))
#expect(!copy.isEmpty)
}
@Test("callWindowCopy: alle Varianten sind nicht leer")
func callWindowCopyAllVariantsNonEmpty() {
for e in TraitLevel.allCases {
for n in TraitLevel.allCases {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: e, n: n))
#expect(!copy.isEmpty, "callWindowCopy leer für e=\(e), n=\(n)")
}
}
}
// MARK: aftermathCopy
@Test("aftermathCopy: nil-Profil liefert nicht-leeren Fallback")
func aftermathCopyNilProfile() {
let copy = PersonalityEngine.aftermathCopy(profile: nil)
#expect(!copy.isEmpty)
}
@Test("aftermathCopy: hoher Neurotizismus → weicher, einladender Text")
func aftermathCopyHighNeuroticism() {
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .high))
#expect(copy.contains("Wenn du magst"))
}
@Test("aftermathCopy: niedriger Neurotizismus → direkter Text")
func aftermathCopyLowNeuroticism() {
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .low))
#expect(copy.contains("Wie wirkt"))
}
@Test("aftermathCopy: alle Varianten sind nicht leer")
func aftermathCopyAllVariantsNonEmpty() {
for n in TraitLevel.allCases {
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: n))
#expect(!copy.isEmpty, "aftermathCopy leer für n=\(n)")
}
}
}
// MARK: - OnboardingStep Regressionswächter (nach Quiz-Erweiterung)
@Suite("OnboardingStep RawValues (Quiz-Erweiterung)")
struct OnboardingStepQuizTests {
@Test("RawValues sind aufsteigend 04")
@MainActor func rawValuesSequential() {
#expect(OnboardingStep.profile.rawValue == 0)
#expect(OnboardingStep.quiz.rawValue == 1)
#expect(OnboardingStep.contacts.rawValue == 2)
#expect(OnboardingStep.tour.rawValue == 3)
#expect(OnboardingStep.complete.rawValue == 4)
}
@Test("allCases enthält genau 5 Schritte")
@MainActor func allCasesCountIsFive() {
#expect(OnboardingStep.allCases.count == 5)
}
}