Add persistence layer
This commit is contained in:
parent
a5ee2edf5d
commit
0addde59d3
34 changed files with 2199 additions and 34 deletions
89
Tests/LuminateCoreTests/AppPathsTests.swift
Normal file
89
Tests/LuminateCoreTests/AppPathsTests.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//
|
||||
// AppPathsTests.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
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
|
||||
@Suite struct AppPathsTests {
|
||||
@Test("An absolute override is used verbatim")
|
||||
func overrideWins() {
|
||||
let path = AppPaths.dataDirectory(
|
||||
environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"],
|
||||
home: URL(filePath: "/home/test"),
|
||||
temporaryDirectory: URL(filePath: "/tmp")
|
||||
)
|
||||
#expect(path == URL(filePath: "/override/luminate"))
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
@Test("Injected home receives the Application Support application directory")
|
||||
func applicationSupportHome() {
|
||||
let path = AppPaths.dataDirectory(
|
||||
environment: [:],
|
||||
home: URL(filePath: "/Users/test"),
|
||||
temporaryDirectory: URL(filePath: "/tmp")
|
||||
)
|
||||
#expect(path.path == "/Users/test/Library/Application Support/luminate")
|
||||
}
|
||||
#else
|
||||
@Test("Absolute XDG data home receives the lowercase application directory")
|
||||
func xdgDataHome() {
|
||||
let path = AppPaths.dataDirectory(
|
||||
environment: ["XDG_DATA_HOME": "/data"],
|
||||
home: URL(filePath: "/home/test"),
|
||||
temporaryDirectory: URL(filePath: "/tmp")
|
||||
)
|
||||
#expect(path.path == "/data/luminate")
|
||||
}
|
||||
|
||||
@Test("Relative XDG data home is ignored")
|
||||
func relativeXDGDataHome() {
|
||||
let path = AppPaths.dataDirectory(
|
||||
environment: ["XDG_DATA_HOME": "relative"],
|
||||
home: URL(filePath: "/home/test"),
|
||||
temporaryDirectory: URL(filePath: "/tmp")
|
||||
)
|
||||
#expect(path.path == "/home/test/.local/share/luminate")
|
||||
}
|
||||
|
||||
@Test("Root home is a valid Linux data location")
|
||||
func rootHome() {
|
||||
let path = AppPaths.dataDirectory(
|
||||
environment: [:],
|
||||
home: URL(filePath: "/"),
|
||||
temporaryDirectory: URL(filePath: "/tmp")
|
||||
)
|
||||
#expect(path.path == "/.local/share/luminate")
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test("Missing home falls back to the temporary directory")
|
||||
func missingHome() {
|
||||
let path = AppPaths.dataDirectory(
|
||||
environment: [:],
|
||||
home: nil,
|
||||
temporaryDirectory: URL(filePath: "/tmp/luminate-test")
|
||||
)
|
||||
#expect(path.path == "/tmp/luminate-test/luminate")
|
||||
}
|
||||
}
|
||||
45
Tests/LuminateCoreTests/PreferenceValueTests.swift
Normal file
45
Tests/LuminateCoreTests/PreferenceValueTests.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// PreferenceValueTests.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 Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
|
||||
/// Verifies typed conversions at the preference storage boundary.
|
||||
@Suite struct PreferenceValueTests {
|
||||
@Test("Boolean decoding accepts only zero and one")
|
||||
func booleanDecoding() {
|
||||
#expect(Bool(preference: .integer(0)) == false)
|
||||
#expect(Bool(preference: .integer(1)) == true)
|
||||
#expect(Bool(preference: .integer(2)) == nil)
|
||||
}
|
||||
|
||||
@Test("Double decoding accepts integer storage")
|
||||
func doubleIntegerDecoding() {
|
||||
#expect(Double(preference: .integer(3)) == 3.0)
|
||||
}
|
||||
|
||||
@Test("Non-finite doubles encode as zero")
|
||||
func nonFiniteDoubleEncoding() {
|
||||
#expect(Double.nan.preferenceValue == .real(0))
|
||||
#expect(Double.infinity.preferenceValue == .real(0))
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,18 @@ import Testing
|
|||
)
|
||||
}
|
||||
|
||||
@Test("An unconfigured client rejects operations before network access")
|
||||
func unconfiguredClientThrows() async {
|
||||
let client = JellyfinClient(
|
||||
configuration: JellyfinClientConfiguration(serverURL: nil)
|
||||
)
|
||||
|
||||
#expect(await client.serverURL == nil)
|
||||
await #expect(throws: JellyfinClientError.notConfigured) {
|
||||
_ = try await client.items()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A 401 maps to unauthorized")
|
||||
func unauthorizedMaps() async {
|
||||
var api = MockJellyfinAPI()
|
||||
|
|
|
|||
109
Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift
Normal file
109
Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// SQLitePreferenceStoreTests.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
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
@testable import LuminateStore
|
||||
|
||||
@Suite struct SQLitePreferenceStoreTests {
|
||||
@Test("A fresh database has schema version one and no preferences")
|
||||
func freshDatabase() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = try SQLitePreferenceStore(url: url)
|
||||
#expect(store.initialSnapshot.isEmpty)
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
#expect(try database.userVersion == 1)
|
||||
}
|
||||
|
||||
@Test("Queued values survive flush and reopen")
|
||||
func persistsValue() async throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = try SQLitePreferenceStore(url: url)
|
||||
store.enqueue("server.url", .text("http://jellyfin.test:8096"))
|
||||
try await store.flush()
|
||||
|
||||
let reopened = try SQLitePreferenceStore(url: url)
|
||||
#expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096"))
|
||||
}
|
||||
|
||||
@Test("A nil queued value deletes the stored row")
|
||||
func deletesValue() async throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = try SQLitePreferenceStore(url: url)
|
||||
store.enqueue("auth.accessToken", .text("secret"))
|
||||
store.enqueue("auth.accessToken", nil)
|
||||
try await store.flush()
|
||||
|
||||
let reopened = try SQLitePreferenceStore(url: url)
|
||||
#expect(reopened.initialSnapshot["auth.accessToken"] == nil)
|
||||
}
|
||||
|
||||
@Test("Every SQLite storage class round trips")
|
||||
func storageClassesRoundTrip() async throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = try SQLitePreferenceStore(url: url)
|
||||
let values: [String: StoredPreference] = [
|
||||
"integer": .integer(42),
|
||||
"real": .real(3.5),
|
||||
"text": .text("value"),
|
||||
"blob": .blob(Data([1, 2, 3])),
|
||||
"emptyBlob": .blob(Data()),
|
||||
"nulText": .text("a\0b"),
|
||||
]
|
||||
for (key, value) in values {
|
||||
store.enqueue(key, value)
|
||||
}
|
||||
try await store.flush()
|
||||
|
||||
let reopened = try SQLitePreferenceStore(url: url)
|
||||
#expect(reopened.initialSnapshot == values)
|
||||
}
|
||||
|
||||
@Test("Writes to one key are applied in submission order")
|
||||
func preservesOrder() async throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = try SQLitePreferenceStore(url: url)
|
||||
store.enqueue("server.url", .text("first"))
|
||||
store.enqueue("server.url", .text("second"))
|
||||
try await store.flush()
|
||||
|
||||
let reopened = try SQLitePreferenceStore(url: url)
|
||||
#expect(reopened.initialSnapshot["server.url"] == .text("second"))
|
||||
}
|
||||
|
||||
private func makeDatabaseURL() -> (URL, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: "luminate-store-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
return (directory.appending(path: "preferences.sqlite"), directory)
|
||||
}
|
||||
}
|
||||
157
Tests/LuminateStoreTests/SchemaMigratorTests.swift
Normal file
157
Tests/LuminateStoreTests/SchemaMigratorTests.swift
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
//
|
||||
// SchemaMigratorTests.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
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
@testable import LuminateStore
|
||||
|
||||
@Suite struct SchemaMigratorTests {
|
||||
@Test("Migrating a current database is a no-op")
|
||||
func currentDatabase() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
try SchemaMigrator.migrate(database)
|
||||
let snapshot = try SQLitePreferenceStore(url: url).initialSnapshot
|
||||
try SchemaMigrator.migrate(database)
|
||||
#expect(try database.userVersion == SchemaMigrator.latestVersion)
|
||||
#expect(try SQLitePreferenceStore(url: url).initialSnapshot == snapshot)
|
||||
}
|
||||
|
||||
@Test("A newer minimum reader version rejects the database")
|
||||
func newerReaderVersion() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
try SchemaMigrator.migrate(database)
|
||||
try database.exec(
|
||||
"UPDATE schema_metadata SET minimum_reader_version = \(SchemaMigrator.latestVersion + 1);"
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try SQLitePreferenceStore(url: url)
|
||||
Issue.record("Expected a newer-reader migration error")
|
||||
} catch let error as PreferenceStoreError {
|
||||
#expect(
|
||||
error
|
||||
== .requiresNewerApplication(
|
||||
minimumReaderVersion: SchemaMigrator.latestVersion + 1,
|
||||
supportedVersion: SchemaMigrator.latestVersion
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A non-Luminate application identifier is rejected")
|
||||
func foreignApplication() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
try database.setApplicationID(1234)
|
||||
|
||||
do {
|
||||
try SchemaMigrator.migrate(database)
|
||||
Issue.record("Expected a foreign-database error")
|
||||
} catch let error as PreferenceStoreError {
|
||||
#expect(error == .notALuminateDatabase(applicationID: 1234))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A higher compatible schema version remains readable")
|
||||
func compatibleHigherVersion() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
try SchemaMigrator.migrate(database)
|
||||
try database.setUserVersion(SchemaMigrator.latestVersion + 1)
|
||||
let statement = try SQLiteStatement(
|
||||
database: database,
|
||||
sql: "INSERT INTO preference (key, value) VALUES (?1, ?2);"
|
||||
)
|
||||
try statement.bind(1, text: "server.url")
|
||||
try statement.bind(2, .text("http://compatible"))
|
||||
_ = try statement.step()
|
||||
|
||||
let store = try SQLitePreferenceStore(url: url)
|
||||
#expect(store.initialSnapshot["server.url"] == .text("http://compatible"))
|
||||
}
|
||||
|
||||
@Test("A failed migration rolls back its statements and version")
|
||||
func failedMigrationRollsBack() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
let failingMigration = StoreMigration(
|
||||
version: 2,
|
||||
name: "failing migration",
|
||||
minimumReaderVersion: 1,
|
||||
statements: [
|
||||
"CREATE TABLE version_two_marker (id INTEGER);",
|
||||
"CREATE TABLE preference (x);",
|
||||
]
|
||||
)
|
||||
|
||||
#expect(throws: PreferenceStoreError.self) {
|
||||
try SchemaMigrator.migrate(database, migrations: SchemaMigrator.migrations + [failingMigration])
|
||||
}
|
||||
#expect(try database.userVersion == 1)
|
||||
#expect(try tableExists("version_two_marker", in: database) == false)
|
||||
}
|
||||
|
||||
@Test("Migration versions must be strictly increasing")
|
||||
func rejectsMalformedMigrationOrder() throws {
|
||||
let (url, directory) = makeDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let database = try SQLiteDatabase(url: url)
|
||||
let migrations = [
|
||||
StoreMigration(version: 2, name: "second", minimumReaderVersion: 1, statements: []),
|
||||
StoreMigration(version: 1, name: "first", minimumReaderVersion: 1, statements: []),
|
||||
]
|
||||
|
||||
#expect(throws: PreferenceStoreError.malformedMigrationList(version: 1)) {
|
||||
try SchemaMigrator.migrate(database, migrations: migrations)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether SQLite exposes a table with the requested name.
|
||||
private func tableExists(_ name: String, in database: SQLiteDatabase) throws -> Bool {
|
||||
let statement = try SQLiteStatement(
|
||||
database: database,
|
||||
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1;"
|
||||
)
|
||||
try statement.bind(1, text: name)
|
||||
return try statement.step()
|
||||
}
|
||||
|
||||
private func makeDatabaseURL() -> (URL, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: "luminate-schema-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
return (directory.appending(path: "preferences.sqlite"), directory)
|
||||
}
|
||||
}
|
||||
60
Tests/LuminateTests/PreferencePersistenceTests.swift
Normal file
60
Tests/LuminateTests/PreferencePersistenceTests.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// PreferencePersistenceTests.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
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
@testable import LuminateStore
|
||||
@testable import LuminateUI
|
||||
|
||||
/// Verifies preference persistence across the UI and SQLite layers.
|
||||
@MainActor @Suite struct PreferencePersistenceTests {
|
||||
@Test("A preference written through a slot survives flush and reopen without a main-loop turn")
|
||||
func survivesFlushAndReopen() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: "luminate-e2e-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appending(path: "preferences.sqlite")
|
||||
|
||||
let preferences = Preferences(store: try SQLitePreferenceStore(url: url))
|
||||
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
|
||||
try await preferences.flush()
|
||||
|
||||
let reopened = try SQLitePreferenceStore(url: url)
|
||||
#expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096"))
|
||||
}
|
||||
|
||||
@Test("Bootstrap points the shared coordinator at the on-disk store")
|
||||
func bootstrapInstallsSharedCoordinator() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: "luminate-bootstrap-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appending(path: "preferences.sqlite")
|
||||
|
||||
Preferences.bootstrap(PreferenceBackend.open(url: url))
|
||||
Preferences.shared[.serverURL] = URL(string: "http://jellyfin.test:8096")
|
||||
try await Preferences.shared.flush()
|
||||
|
||||
let reopened = try SQLitePreferenceStore(url: url)
|
||||
#expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096"))
|
||||
}
|
||||
}
|
||||
138
Tests/LuminateUITests/PreferenceTests.swift
Normal file
138
Tests/LuminateUITests/PreferenceTests.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
//
|
||||
// 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" }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue