luminate/Tests/LuminateUITests/PreferenceTests.swift

238 lines
9.3 KiB
Swift

//
// PreferenceTests.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
@_spi(SGTKInternal) import Gtk
import LuminateCore
@_spi(Portico) import Portico
import Testing
@testable import LuminateUI
/// Test view that renders the current server URL preference.
private struct PreferenceLabel: View {
@Preference(.serverURL) private var serverURL: URL?
var body: some View {
Label(str: "").label { serverURL?.absoluteString ?? "No server configured" }
}
}
/// Records each value a tracked closure observes, so a test can assert re-evaluation.
private final class SessionRecorder {
var values: [Bool] = []
}
/// Captures the projected binding of a mounted `@Preference`, so a test can act on it after mount.
@MainActor private final class BindingCapture {
var binding: Portico.Binding<URL?>?
}
private struct BindingProbeView: View {
@Preference(.serverURL) private var serverURL: URL?
let capture: BindingCapture
var body: some View {
capture.binding = $serverURL
return Label(str: "").label { serverURL?.absoluteString ?? "" }
}
}
/// Exercises preference mounting, observation, and persistence wiring.
@Suite(.serialized) @MainActor struct PreferenceTests {
@Test("An unset preference reads nil at mount")
func unsetAtMount() {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
let label =
AnyView(PreferenceLabel().environment(\.preferences, preferences))
.makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
#expect(label.getText() == "No server configured")
}
@Test("An un-injected preference resolves through the shared coordinator")
func resolvesWithoutInjection() {
guard Gtk.initCheck() else { return }
Preferences.shared[.serverURL] = URL(string: "http://shared.test")
defer { Preferences.shared[.serverURL] = nil }
let context = MountContext()
let label = AnyView(PreferenceLabel()).makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
#expect(label.getText() == "http://shared.test")
}
@Test("Writing a preference updates the same mounted label")
func liveUpdate() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
let label =
AnyView(PreferenceLabel().environment(\.preferences, preferences))
.makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
let pointer = label.pointer
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
await asyncPump {
label.getText() == "http://jellyfin.test:8096"
}
#expect(label.pointer == pointer)
#expect(label.getText() == "http://jellyfin.test:8096")
}
@Test("A preference write reaches the in-memory backend after flush")
func persistsToBackend() async throws {
let store = EphemeralPreferenceStore()
let preferences = Preferences(store: store)
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
try await store.flush()
#expect(await store.storedValues["server.url"] == .text("http://jellyfin.test:8096"))
}
@Test("A seeded store presents its value at mount")
func restoresAtMount() {
guard Gtk.initCheck() else { return }
let store = EphemeralPreferenceStore(
initialSnapshot: ["server.url": .text("http://seeded.test")]
)
let preferences = Preferences(store: store)
let context = MountContext()
let label =
AnyView(PreferenceLabel().environment(\.preferences, preferences))
.makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
#expect(label.getText() == "http://seeded.test")
}
@Test("Setting a preference to nil enqueues a deletion")
func deletesFromBackend() async throws {
let store = EphemeralPreferenceStore(
initialSnapshot: ["server.url": .text("http://seeded.test")]
)
let preferences = Preferences(store: store)
preferences.slot(for: .serverURL).value = nil
try await store.flush()
#expect(await store.storedValues["server.url"] == nil)
}
@Test("A stored token reports an authenticated coordinator")
func authenticatedWithStoredToken() {
let store = EphemeralPreferenceStore(initialSnapshot: ["auth.accessToken": .text("tok")])
#expect(Preferences(store: store).isAuthenticated)
}
@Test("A missing or empty token reports an unauthenticated coordinator")
func unauthenticatedWithoutToken() {
let preferences = Preferences(store: EphemeralPreferenceStore())
#expect(preferences.isAuthenticated == false)
preferences.slot(for: .accessToken).value = ""
#expect(preferences.isAuthenticated == false)
}
@Test("Writing the access token re-evaluates a tracker that read the session state")
func authenticationDrivesTrackedReevaluation() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let recorder = SessionRecorder()
let tracker = DependencyTracker { recorder.values.append(preferences.isAuthenticated) }
tracker.run()
defer { tracker.teardown() }
preferences.slot(for: .accessToken).value = "tok"
await asyncPump { recorder.values.count > 1 }
#expect(recorder.values == [false, true])
}
@Test("A binding writes through to the preference slot")
func bindingWritesThrough() async throws {
let store = EphemeralPreferenceStore()
let preferences = Preferences(store: store)
let binding = preferences.binding(for: .serverURL)
binding.wrappedValue = URL(string: "http://jellyfin.test:8096")
#expect(preferences[.serverURL] == URL(string: "http://jellyfin.test:8096"))
try await store.flush()
#expect(await store.storedValues["server.url"] == .text("http://jellyfin.test:8096"))
}
@Test("A stored value of the wrong type is treated as unset")
func restoredValueTypeMismatchIsIgnored() {
guard Gtk.initCheck() else { return }
let store = EphemeralPreferenceStore(initialSnapshot: ["server.url": .integer(1)])
let preferences = Preferences(store: store)
let context = MountContext()
let label =
AnyView(PreferenceLabel().environment(\.preferences, preferences))
.makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
#expect(label.getText() == "No server configured")
}
@Test("The projected binding writes back through wrappedValue")
func projectedValueWritesThrough() throws {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let capture = BindingCapture()
let context = MountContext()
_ = AnyView(BindingProbeView(capture: capture).environment(\.preferences, preferences))
.makeWidget(context)
defer { context.registry.teardown() }
let binding = try #require(capture.binding)
binding.wrappedValue = URL(string: "http://projected.test")
#expect(preferences[.serverURL] == URL(string: "http://projected.test"))
}
/// Drives the GLib default main context, yielding each turn so the Observation bridge's
/// main-actor hop can run. swift-testing does not run `@MainActor` bodies on the process
/// initial thread, so `_porticoObservationDidChange` defers through a `Task` that only
/// runs at a suspension point; a non-suspending pump would starve it forever.
private func asyncPump(until condition: () -> Bool = { false }, turns: Int = 500) async {
for _ in 0..<turns {
if condition() { return }
_ = preference_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
}
// UPSTREAM: Portico should expose an async main-loop pump for tests (e.g.
// `_porticoPumpMainContext(until:turns:)`) so suites do not need @_silgen_name and do not
// rediscover the sync-vs-async trap: a synchronous pump cannot observe Observation-driven
// updates because swift-testing never runs @MainActor bodies on the process initial thread.
@_silgen_name("g_main_context_iteration")
private nonisolated func preference_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?,
_ mayBlock: Int32
) -> Int32