CreateMoodSheet's Hex-Picker war broken — `hexColors`-Getter retournierte hardcoded `#7c3aed` für jede Farbe statt der gewählten. Custom-Moods landeten so alle als 3× lila in der DB. Fix: - HexColor.swift: `Color.toHexString()` über PlatformColor (UIColor iOS / NSColor macOS) — sRGB-Roundtrip mit Komponenten-Extraktion. - CreateMoodSheet: `[String]` (Hex) als SOT statt `[Color]`. ColorPicker binded über konvertierende `Binding<Color>` (Get: Hex→Color, Set: Color→Hex). Zusätzlich TextField pro Farbe für direkten Hex-Input (lowercase, mit/ohne `#`, validiert auf 6/8 Hex-Chars). - `+ Farbe hinzufügen` generiert random Hex statt fixem Lila. Tests/UnitTests/ neu: - Decode-Roundtrip Mood (mit + ohne userId), MoodSequence, Preferences gegen API-Wire-Format aus moodlit-api Routen. - AnimationSpeed-Enum akzeptiert alle 3 Wire-Werte (slow/normal/fast). - DefaultMoods.all hat 24 Einträge, alle isPreset, unique IDs, deckt bekannte Slugs ab (fire, breath, ocean, sunrise, sunset, matrix), alle referenzierten Animation-Werte sind im Enum. - HexColor.Color(hex:) smoke (#prefix, lowercase/uppercase, 8-char). 11/11 Tests grün via xcodebuild iOS-Sim. project.yml um MoodlitNativeUnitTests-Target (bundle.unit-test, iOS-only, Bundle-ID ev.mana.moodlit.tests) erweitert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
4.9 KiB
Swift
172 lines
4.9 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import Testing
|
|
@testable import MoodlitNative
|
|
|
|
/// Decode-Roundtrip gegen das Wire-Format der moodlit-api.
|
|
/// SOT: `Code/moodlit/apps/api/src/routes/{moods,sequences,preferences}.ts`.
|
|
///
|
|
/// Wenn diese Tests rot werden, hat sich entweder die API-Antwort-
|
|
/// Form geändert oder unsere Swift-Codable-Annotationen — beides
|
|
/// muss sofort gefixt werden, sonst crasht der App-Mount.
|
|
struct DomainCodingTests {
|
|
|
|
// MARK: - Mood
|
|
|
|
@Test func moodDecodesFromApiResponse() throws {
|
|
let json = """
|
|
{
|
|
"id": "abc-123",
|
|
"userId": "user-xyz",
|
|
"name": "Mein Mood",
|
|
"colors": ["#ff6b35", "#ff4500"],
|
|
"animation": "candle",
|
|
"isPreset": false,
|
|
"createdAt": "2026-05-18T10:00:00.000Z",
|
|
"updatedAt": "2026-05-18T10:00:00.000Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let mood = try JSONDecoder().decode(Mood.self, from: json)
|
|
#expect(mood.id == "abc-123")
|
|
#expect(mood.userId == "user-xyz")
|
|
#expect(mood.colors == ["#ff6b35", "#ff4500"])
|
|
#expect(mood.animation == .candle)
|
|
#expect(mood.isPreset == false)
|
|
}
|
|
|
|
@Test func moodDecodesWithNullUserId() throws {
|
|
// Presets kommen mit `userId: null` aus dem Server, falls je
|
|
// einer den `/presets`-Endpoint hinzufügt — robust decoden.
|
|
let json = """
|
|
{
|
|
"id": "fire",
|
|
"userId": null,
|
|
"name": "Fire",
|
|
"colors": ["#ff6b35"],
|
|
"animation": "candle",
|
|
"isPreset": true,
|
|
"createdAt": "1970-01-01T00:00:00.000Z",
|
|
"updatedAt": "1970-01-01T00:00:00.000Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let mood = try JSONDecoder().decode(Mood.self, from: json)
|
|
#expect(mood.userId == nil)
|
|
#expect(mood.isPreset == true)
|
|
}
|
|
|
|
// MARK: - MoodSequence
|
|
|
|
@Test func sequenceDecodesFromApiResponse() throws {
|
|
let json = """
|
|
{
|
|
"id": "seq-1",
|
|
"userId": "user-xyz",
|
|
"name": "Entspannen",
|
|
"moodIds": ["fire", "breath", "ocean"],
|
|
"durationSec": 30,
|
|
"transitionSec": 2,
|
|
"createdAt": "2026-05-18T10:00:00.000Z",
|
|
"updatedAt": "2026-05-18T10:00:00.000Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let seq = try JSONDecoder().decode(MoodSequence.self, from: json)
|
|
#expect(seq.id == "seq-1")
|
|
#expect(seq.moodIds == ["fire", "breath", "ocean"])
|
|
#expect(seq.durationSec == 30)
|
|
#expect(seq.transitionSec == 2)
|
|
}
|
|
|
|
// MARK: - Preferences
|
|
|
|
@Test func preferencesDecodesFromApiResponse() throws {
|
|
let json = """
|
|
{
|
|
"userId": "user-xyz",
|
|
"animationSpeed": "normal",
|
|
"brightness": 80,
|
|
"autoTimerMinutes": 0,
|
|
"autoMoodSwitch": false,
|
|
"autoMoodSwitchInterval": 5,
|
|
"favoriteIds": ["fire", "ocean"],
|
|
"createdAt": "2026-05-18T10:00:00.000Z",
|
|
"updatedAt": "2026-05-18T10:00:00.000Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let prefs = try JSONDecoder().decode(Preferences.self, from: json)
|
|
#expect(prefs.userId == "user-xyz")
|
|
#expect(prefs.animationSpeed == .normal)
|
|
#expect(prefs.brightness == 80)
|
|
#expect(prefs.favoriteIds == ["fire", "ocean"])
|
|
}
|
|
|
|
@Test func preferencesAnimationSpeedAcceptsAllThreeValues() async throws {
|
|
for value in ["slow", "normal", "fast"] {
|
|
let json = """
|
|
{
|
|
"userId": "u", "animationSpeed": "\(value)",
|
|
"brightness": 100, "autoTimerMinutes": 0,
|
|
"autoMoodSwitch": false, "autoMoodSwitchInterval": 5,
|
|
"favoriteIds": [],
|
|
"createdAt": "1970-01-01T00:00:00.000Z",
|
|
"updatedAt": "1970-01-01T00:00:00.000Z"
|
|
}
|
|
""".data(using: .utf8)!
|
|
let prefs = try JSONDecoder().decode(Preferences.self, from: json)
|
|
let decoded = prefs.animationSpeed.rawValue
|
|
#expect(decoded == value)
|
|
}
|
|
}
|
|
|
|
// MARK: - Defaults
|
|
|
|
@Test func defaultMoodsHas24Presets() throws {
|
|
#expect(DefaultMoods.all.count == 24)
|
|
}
|
|
|
|
@Test func defaultMoodsAreAllPresets() throws {
|
|
let allArePresets = DefaultMoods.all.allSatisfy { $0.isPreset }
|
|
#expect(allArePresets)
|
|
}
|
|
|
|
@Test func defaultMoodsHaveUniqueIds() throws {
|
|
let ids = DefaultMoods.all.map { $0.id }
|
|
let unique = Set(ids).count
|
|
#expect(unique == ids.count)
|
|
}
|
|
|
|
@Test func defaultMoodsContainsKnownSlugs() throws {
|
|
// Diese Slugs sind Wire-IDs, Sequences referenzieren sie.
|
|
// Ein Rebrand würde DB-Daten zerschießen.
|
|
for slug in ["fire", "breath", "ocean", "sunrise", "sunset", "matrix"] {
|
|
#expect(DefaultMoods.byId(slug) != nil, "Missing preset slug: \(slug)")
|
|
}
|
|
}
|
|
|
|
@Test func defaultMoodsCoverAllAnimationsUsedByPresets() throws {
|
|
// Jedes Preset muss eine Animation referenzieren, die im Enum
|
|
// definiert ist — Swift's Enum-Decoding würde sonst beim Lift
|
|
// neuer Animation-Types brechen.
|
|
let animationsInUse = Set(DefaultMoods.all.map { $0.animation })
|
|
let isEmpty = animationsInUse.isEmpty
|
|
#expect(!isEmpty)
|
|
// Sanity: alle in-use sind valide raw-values
|
|
for anim in animationsInUse {
|
|
let roundtripped = AnimationType(rawValue: anim.rawValue)
|
|
#expect(roundtripped == anim)
|
|
}
|
|
}
|
|
|
|
// MARK: - HexColor
|
|
|
|
@Test func hexColorParsesHashPrefix() throws {
|
|
// Smoke: keine Crashes bei verschiedenen Input-Formaten
|
|
_ = Color(hex: "#ff0000")
|
|
_ = Color(hex: "ff0000")
|
|
_ = Color(hex: "#FF0000")
|
|
_ = Color(hex: "#ff0000ff")
|
|
}
|
|
}
|