172 lines
6.4 KiB
Swift
172 lines
6.4 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] = []
|
|
}
|
|
|
|
/// 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() {
|
|
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")
|
|
pump {
|
|
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() {
|
|
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"
|
|
pump { recorder.values.count > 1 }
|
|
|
|
#expect(recorder.values == [false, true])
|
|
}
|
|
|
|
private func pump(until condition: () -> Bool = { false }, turns: Int = 200) {
|
|
for _ in 0..<turns {
|
|
if condition() { return }
|
|
_ = preference_g_main_context_iteration(nil, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// UPSTREAM: Portico should expose a main-loop pump for tests so suites do not need @_silgen_name.
|
|
@_silgen_name("g_main_context_iteration")
|
|
private nonisolated func preference_g_main_context_iteration(
|
|
_ context: UnsafeMutableRawPointer?,
|
|
_ mayBlock: Int32
|
|
) -> Int32
|