Files
yattee/YatteeTests/SettingsTests.swift
Arkadiusz Fal b632f11b2f Add separate light/dark mode accent colors
Adds an option in Appearance settings to use different accent colors
in light and dark mode, with a toggle to keep a single shared color
(the default, preserving existing behavior).

- New synced settings keys: accentColorDark, customAccentColorDark,
  useSeparateDarkAccentColor; dark values fall back to the light
  selection until explicitly set, so no migration is needed
- resolvedAccentColor returns a dynamic platform color (UIColor trait
  provider / NSColor appearance provider) when the toggle is on, so
  the root tint and all direct readers adapt to light/dark
  automatically without consumer changes
- Accent Color section shows two stacked preset+custom grids with
  Light/Dark headers when enabled, extracted into AccentColorGrid;
  CustomAccentColorButton now takes bindings so the macOS color panel
  edits whichever target was last opened
- New DarkAccentColorTests covering sync contract, default-off state,
  fallbacks, and toggle-off resolution

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-05 21:06:26 +02:00

334 lines
11 KiB
Swift

//
// SettingsTests.swift
// YatteeTests
//
// Tests for settings and preferences types.
//
import Testing
import Foundation
import SwiftUI
@testable import Yattee
// MARK: - AppTheme Tests
@Suite("AppTheme Tests")
@MainActor
struct AppThemeTests {
@Test("AppTheme cases")
func allCases() {
let cases = AppTheme.allCases
#expect(cases.contains(.system))
#expect(cases.contains(.light))
#expect(cases.contains(.dark))
}
@Test("AppTheme colorScheme mapping")
func colorSchemeMapping() {
#expect(AppTheme.system.colorScheme == nil)
#expect(AppTheme.light.colorScheme == .light)
#expect(AppTheme.dark.colorScheme == .dark)
}
@Test("AppTheme is Codable")
func codable() throws {
for theme in AppTheme.allCases {
let encoded = try JSONEncoder().encode(theme)
let decoded = try JSONDecoder().decode(AppTheme.self, from: encoded)
#expect(theme == decoded)
}
}
}
// MARK: - AccentColor Tests
@Suite("AccentColor Tests")
@MainActor
struct AccentColorTests {
@Test("AccentColor cases")
func allCases() {
let cases = AccentColor.allCases
#expect(cases.contains(.default))
#expect(cases.contains(.red))
#expect(cases.contains(.blue))
#expect(cases.contains(.green))
#expect(cases.contains(.purple))
}
@Test("AccentColor is Codable")
func codable() throws {
for color in AccentColor.allCases {
let encoded = try JSONEncoder().encode(color)
let decoded = try JSONDecoder().decode(AccentColor.self, from: encoded)
#expect(color == decoded)
}
}
@Test("Presets exclude custom and retired indigo")
func presetsExcludeHiddenCases() {
#expect(!AccentColor.presets.contains(.custom))
#expect(!AccentColor.presets.contains(.indigo))
#expect(AccentColor.presets.count == AccentColor.allCases.count - 2)
}
@Test("Retired indigo still decodes and resolves")
func retiredIndigoStillWorks() throws {
let decoded = try JSONDecoder().decode(AccentColor.self, from: Data("\"indigo\"".utf8))
#expect(decoded == .indigo)
#expect(AccentColor(rawValue: "indigo") == .indigo)
}
}
// MARK: - Dark Accent Color Tests
@Suite("Dark Accent Color Tests", .serialized)
@MainActor
struct DarkAccentColorTests {
private static let touchedKeys = [
SettingsKey.accentColor,
.customAccentColor,
.accentColorDark,
.customAccentColorDark,
.useSeparateDarkAccentColor,
]
private func withCleanDefaults(_ body: (SettingsManager) throws -> Void) rethrows {
let removeAll = {
for key in Self.touchedKeys {
UserDefaults.standard.removeObject(forKey: key.rawValue)
}
}
removeAll()
defer { removeAll() }
try body(SettingsManager())
}
@Test("New keys are part of the settings key set and sync globally")
func keysSyncContract() {
for key in [SettingsKey.accentColorDark, .customAccentColorDark, .useSeparateDarkAccentColor] {
#expect(SettingsKey.allCases.contains(key))
#expect(!key.isLocalOnly)
#expect(!key.isPlatformSpecific)
}
}
@Test("Separate dark accent color is disabled by default")
func separateDarkDisabledByDefault() {
withCleanDefaults { settings in
#expect(settings.useSeparateDarkAccentColor == false)
}
}
@Test("Dark accent color falls back to light selection until set")
func darkFallsBackToLight() {
withCleanDefaults { settings in
settings.accentColor = .green
#expect(settings.accentColorDark == .green)
settings.accentColorDark = .purple
#expect(settings.accentColorDark == .purple)
#expect(settings.accentColor == .green)
}
}
@Test("Custom dark accent color falls back to light custom color until set")
func customDarkFallsBackToLight() {
withCleanDefaults { settings in
settings.customAccentColor = Color(hex: "#336699")!
#expect(settings.customAccentColorDark.toHexString() == "#336699")
settings.customAccentColorDark = Color(hex: "#993366")!
#expect(settings.customAccentColorDark.toHexString() == "#993366")
#expect(settings.customAccentColor.toHexString() == "#336699")
}
}
@Test("Resolved accent color equals light resolution when toggle is off")
func resolvedMatchesLightWhenToggleOff() {
withCleanDefaults { settings in
settings.accentColor = .teal
settings.accentColorDark = .red
settings.useSeparateDarkAccentColor = false
#expect(settings.resolvedAccentColor == AccentColor.teal.color)
}
}
}
// MARK: - VideoQuality Tests
@Suite("VideoQuality Tests")
@MainActor
struct VideoQualityTests {
@Test("VideoQuality cases")
func allCases() {
let cases = VideoQuality.allCases
#expect(cases.contains(.auto))
#expect(cases.contains(.hd4k))
#expect(cases.contains(.hd1440p))
#expect(cases.contains(.hd1080p))
#expect(cases.contains(.hd720p))
#expect(cases.contains(.sd480p))
#expect(cases.contains(.sd360p))
}
@Test("VideoQuality raw values")
func rawValues() {
#expect(VideoQuality.auto.rawValue == "auto")
#expect(VideoQuality.hd4k.rawValue == "4k")
#expect(VideoQuality.hd1440p.rawValue == "1440p")
#expect(VideoQuality.hd1080p.rawValue == "1080p")
}
@Test("VideoQuality recommendedForPlatform returns valid quality")
func recommendedForPlatform() {
let recommended = VideoQuality.recommendedForPlatform
#expect(VideoQuality.allCases.contains(recommended))
}
@Test("VideoQuality is Codable")
func codable() throws {
for quality in VideoQuality.allCases {
let encoded = try JSONEncoder().encode(quality)
let decoded = try JSONDecoder().decode(VideoQuality.self, from: encoded)
#expect(quality == decoded)
}
}
}
// MARK: - SponsorBlockCategory Tests
@Suite("SponsorBlockCategory Tests")
@MainActor
struct SponsorBlockCategoryTests {
@Test("SponsorBlockCategory cases")
func allCases() {
let cases = SponsorBlockCategory.allCases
#expect(cases.contains(.sponsor))
#expect(cases.contains(.selfpromo))
#expect(cases.contains(.interaction))
#expect(cases.contains(.intro))
#expect(cases.contains(.outro))
#expect(cases.contains(.preview))
#expect(cases.contains(.musicOfftopic))
#expect(cases.contains(.filler))
#expect(cases.contains(.highlight))
}
@Test("SponsorBlockCategory display names are not empty")
func displayNames() {
// Display names use localized strings, so we just verify they're not empty
for category in SponsorBlockCategory.allCases {
#expect(!category.displayName.isEmpty)
}
}
@Test("SponsorBlockCategory raw values")
func rawValues() {
#expect(SponsorBlockCategory.sponsor.rawValue == "sponsor")
#expect(SponsorBlockCategory.musicOfftopic.rawValue == "music_offtopic")
}
@Test("SponsorBlockCategory defaultEnabled set")
func defaultEnabled() {
let defaults = SponsorBlockCategory.defaultEnabled
#expect(defaults.contains(.sponsor))
#expect(defaults.contains(.selfpromo))
#expect(defaults.contains(.interaction))
#expect(defaults.contains(.intro))
#expect(defaults.contains(.outro))
#expect(!defaults.contains(.preview))
#expect(!defaults.contains(.musicOfftopic))
#expect(!defaults.contains(.filler))
}
@Test("SponsorBlockCategory is Codable")
func codable() throws {
for category in SponsorBlockCategory.allCases {
let encoded = try JSONEncoder().encode(category)
let decoded = try JSONDecoder().decode(SponsorBlockCategory.self, from: encoded)
#expect(category == decoded)
}
}
@Test("SponsorBlockCategory Set is Codable")
func setCodable() throws {
let categories: Set<SponsorBlockCategory> = [.sponsor, .intro, .outro]
let encoded = try JSONEncoder().encode(categories)
let decoded = try JSONDecoder().decode(Set<SponsorBlockCategory>.self, from: encoded)
#expect(categories == decoded)
}
}
// MARK: - UserAgentGenerator Tests
@Suite("UserAgentGenerator Tests")
struct UserAgentGeneratorTests {
@Test("Random user agent is not empty")
func randomNotEmpty() {
let ua = UserAgentGenerator.generateRandom()
#expect(!ua.isEmpty)
}
@Test("Random user agent starts with Mozilla")
func randomStartsWithMozilla() {
for _ in 0..<10 {
let ua = UserAgentGenerator.generateRandom()
#expect(ua.hasPrefix("Mozilla/5.0"))
}
}
@Test("Random user agent contains browser identifier")
func randomContainsBrowser() {
// Run multiple times to test randomness
var containsChrome = false
var containsFirefox = false
var containsSafari = false
var containsEdge = false
for _ in 0..<100 {
let ua = UserAgentGenerator.generateRandom()
if ua.contains("Chrome/") { containsChrome = true }
if ua.contains("Firefox/") { containsFirefox = true }
if ua.contains("Safari/") { containsSafari = true }
if ua.contains("Edg/") { containsEdge = true }
}
// At least some browser types should appear (probabilistic)
#expect(containsChrome || containsFirefox || containsSafari || containsEdge)
}
@Test("Default user agent is valid")
func defaultUserAgent() {
let ua = UserAgentGenerator.defaultUserAgent
#expect(!ua.isEmpty)
#expect(ua.hasPrefix("Mozilla/5.0"))
}
@Test("Random user agent has reasonable length")
func reasonableLength() {
for _ in 0..<10 {
let ua = UserAgentGenerator.generateRandom()
// User agents are typically 80-200 characters
#expect(ua.count > 50)
#expect(ua.count < 300)
}
}
@Test("Random user agent contains platform info")
func containsPlatformInfo() {
for _ in 0..<20 {
let ua = UserAgentGenerator.generateRandom()
// Should contain Windows, Macintosh, or similar
let containsPlatform = ua.contains("Windows") || ua.contains("Macintosh") || ua.contains("Intel Mac")
#expect(containsPlatform)
}
}
}