238 lines
9.3 KiB
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
|