diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ec8c12..50f14675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Namespaced `disabledPlugins` UserDefaults key to `com.TablePro.disabledPlugins` with automatic migration +- Removed unused plugin capability types (sqlDialect, aiProvider, cellRenderer, sidebarPanel) - SQLite driver extracted from built-in bundle to downloadable plugin, reducing app size - Unified error formatting across all database drivers via default `PluginDriverError.errorDescription`, removing 10 per-driver implementations - Standardized async bridging: 5 queue-based drivers (MySQL, PostgreSQL, MongoDB, Redis, MSSQL) now use shared `pluginDispatchAsync` helper @@ -26,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Export plugin options (CSV, XLSX, JSON, SQL, MQL) now persist across app restarts +- Plugins can declare settings views rendered in Settings > Plugins - True prepared statements for MSSQL (`sp_executesql`) and ClickHouse (HTTP query parameters), eliminating string interpolation for parameterized queries - Batch query operations for MSSQL, Oracle, and ClickHouse, eliminating N+1 query patterns for column, foreign key, and database metadata fetching; SQLite adds a batched `fetchAllForeignKeys` override within PRAGMA limitations - `PluginDriverError` protocol in TableProPluginKit for structured error reporting from driver plugins, with richer connection error messages showing error codes and SQL states diff --git a/Plugins/CSVExportPlugin/CSVExportModels.swift b/Plugins/CSVExportPlugin/CSVExportModels.swift index 7b8300f3..d9841be4 100644 --- a/Plugins/CSVExportPlugin/CSVExportModels.swift +++ b/Plugins/CSVExportPlugin/CSVExportModels.swift @@ -5,7 +5,7 @@ import Foundation -public enum CSVDelimiter: String, CaseIterable, Identifiable { +public enum CSVDelimiter: String, CaseIterable, Identifiable, Codable { case comma = "," case semicolon = ";" case tab = "\\t" @@ -27,7 +27,7 @@ public enum CSVDelimiter: String, CaseIterable, Identifiable { } } -public enum CSVQuoteHandling: String, CaseIterable, Identifiable { +public enum CSVQuoteHandling: String, CaseIterable, Identifiable, Codable { case always = "Always" case asNeeded = "Quote if needed" case never = "Never" @@ -43,7 +43,7 @@ public enum CSVQuoteHandling: String, CaseIterable, Identifiable { } } -public enum CSVLineBreak: String, CaseIterable, Identifiable { +public enum CSVLineBreak: String, CaseIterable, Identifiable, Codable { case lf = "\\n" case crlf = "\\r\\n" case cr = "\\r" @@ -59,7 +59,7 @@ public enum CSVLineBreak: String, CaseIterable, Identifiable { } } -public enum CSVDecimalFormat: String, CaseIterable, Identifiable { +public enum CSVDecimalFormat: String, CaseIterable, Identifiable, Codable { case period = "." case comma = "," @@ -68,7 +68,7 @@ public enum CSVDecimalFormat: String, CaseIterable, Identifiable { public var separator: String { rawValue } } -public struct CSVExportOptions: Equatable { +public struct CSVExportOptions: Equatable, Codable { public var convertNullToEmpty: Bool = true public var convertLineBreakToSpace: Bool = false public var includeFieldNames: Bool = true diff --git a/Plugins/CSVExportPlugin/CSVExportPlugin.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift index d1b3828e..b55ed369 100644 --- a/Plugins/CSVExportPlugin/CSVExportPlugin.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -20,9 +20,17 @@ final class CSVExportPlugin: ExportFormatPlugin { // swiftlint:disable:next force_try static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#) - var options = CSVExportOptions() + private let storage = PluginSettingsStorage(pluginId: "csv") - required init() {} + var options = CSVExportOptions() { + didSet { storage.save(options) } + } + + required init() { + if let saved = storage.load(CSVExportOptions.self) { + options = saved + } + } func optionsView() -> AnyView? { AnyView(CSVExportOptionsView(plugin: self)) diff --git a/Plugins/JSONExportPlugin/JSONExportModels.swift b/Plugins/JSONExportPlugin/JSONExportModels.swift index 0dc6e7b1..c8e43457 100644 --- a/Plugins/JSONExportPlugin/JSONExportModels.swift +++ b/Plugins/JSONExportPlugin/JSONExportModels.swift @@ -5,7 +5,7 @@ import Foundation -public struct JSONExportOptions: Equatable { +public struct JSONExportOptions: Equatable, Codable { public var prettyPrint: Bool = true public var includeNullValues: Bool = true public var preserveAllAsStrings: Bool = false diff --git a/Plugins/JSONExportPlugin/JSONExportPlugin.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift index 3f56123c..10dde193 100644 --- a/Plugins/JSONExportPlugin/JSONExportPlugin.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -17,9 +17,17 @@ final class JSONExportPlugin: ExportFormatPlugin { static let defaultFileExtension = "json" static let iconName = "curlybraces" - var options = JSONExportOptions() + private let storage = PluginSettingsStorage(pluginId: "json") - required init() {} + var options = JSONExportOptions() { + didSet { storage.save(options) } + } + + required init() { + if let saved = storage.load(JSONExportOptions.self) { + options = saved + } + } func optionsView() -> AnyView? { AnyView(JSONExportOptionsView(plugin: self)) diff --git a/Plugins/MQLExportPlugin/MQLExportModels.swift b/Plugins/MQLExportPlugin/MQLExportModels.swift index ddd630c5..1e7d3947 100644 --- a/Plugins/MQLExportPlugin/MQLExportModels.swift +++ b/Plugins/MQLExportPlugin/MQLExportModels.swift @@ -5,7 +5,7 @@ import Foundation -public struct MQLExportOptions: Equatable { +public struct MQLExportOptions: Equatable, Codable { public var batchSize: Int = 500 public init() {} diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 35f3e369..4e3daafb 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -24,9 +24,17 @@ final class MQLExportPlugin: ExportFormatPlugin { PluginExportOptionColumn(id: "data", label: "Data", width: 44) ] - var options = MQLExportOptions() + private let storage = PluginSettingsStorage(pluginId: "mql") - required init() {} + var options = MQLExportOptions() { + didSet { storage.save(options) } + } + + required init() { + if let saved = storage.load(MQLExportOptions.self) { + options = saved + } + } func defaultTableOptionValues() -> [Bool] { [true, true, true] diff --git a/Plugins/SQLExportPlugin/SQLExportModels.swift b/Plugins/SQLExportPlugin/SQLExportModels.swift index 2a567b38..45d7255f 100644 --- a/Plugins/SQLExportPlugin/SQLExportModels.swift +++ b/Plugins/SQLExportPlugin/SQLExportModels.swift @@ -5,7 +5,7 @@ import Foundation -public struct SQLExportOptions: Equatable { +public struct SQLExportOptions: Equatable, Codable { public var compressWithGzip: Bool = false public var batchSize: Int = 500 diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 6785ac8f..bfaa6148 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -25,12 +25,21 @@ final class SQLExportPlugin: ExportFormatPlugin { PluginExportOptionColumn(id: "data", label: "Data", width: 44) ] - var options = SQLExportOptions() + private let storage = PluginSettingsStorage(pluginId: "sql") + + var options = SQLExportOptions() { + didSet { storage.save(options) } + } + var ddlFailures: [String] = [] private static let logger = Logger(subsystem: "com.TablePro", category: "SQLExportPlugin") - required init() {} + required init() { + if let saved = storage.load(SQLExportOptions.self) { + options = saved + } + } func defaultTableOptionValues() -> [Bool] { [true, true, true] diff --git a/Plugins/TableProPluginKit/PluginCapability.swift b/Plugins/TableProPluginKit/PluginCapability.swift index a4324ae4..371ff0a7 100644 --- a/Plugins/TableProPluginKit/PluginCapability.swift +++ b/Plugins/TableProPluginKit/PluginCapability.swift @@ -4,8 +4,4 @@ public enum PluginCapability: Int, Codable, Sendable { case databaseDriver case exportFormat case importFormat - case sqlDialect - case aiProvider - case cellRenderer - case sidebarPanel } diff --git a/Plugins/TableProPluginKit/PluginSettingsStorage.swift b/Plugins/TableProPluginKit/PluginSettingsStorage.swift new file mode 100644 index 00000000..ac26eff3 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginSettingsStorage.swift @@ -0,0 +1,38 @@ +// +// PluginSettingsStorage.swift +// TableProPluginKit +// + +import Foundation + +public final class PluginSettingsStorage { + private let pluginId: String + private let defaults = UserDefaults.standard + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init(pluginId: String) { + self.pluginId = pluginId + } + + private func key(for optionKey: String) -> String { + "com.TablePro.plugin.\(pluginId).\(optionKey)" + } + + public func save(_ value: T, forKey optionKey: String = "settings") { + guard let data = try? encoder.encode(value) else { return } + defaults.set(data, forKey: key(for: optionKey)) + } + + public func load(_ type: T.Type, forKey optionKey: String = "settings") -> T? { + guard let data = defaults.data(forKey: key(for: optionKey)) else { return nil } + return try? decoder.decode(type, from: data) + } + + public func removeAll() { + let prefix = "com.TablePro.plugin.\(pluginId)." + for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(prefix) { + defaults.removeObject(forKey: key) + } + } +} diff --git a/Plugins/XLSXExportPlugin/XLSXExportModels.swift b/Plugins/XLSXExportPlugin/XLSXExportModels.swift index 233bb2f6..e8242b36 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportModels.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportModels.swift @@ -5,7 +5,7 @@ import Foundation -public struct XLSXExportOptions: Equatable { +public struct XLSXExportOptions: Equatable, Codable { public var includeHeaderRow: Bool = true public var convertNullToEmpty: Bool = true diff --git a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift index 115a8e45..08b8b326 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift @@ -17,9 +17,17 @@ final class XLSXExportPlugin: ExportFormatPlugin { static let defaultFileExtension = "xlsx" static let iconName = "tablecells" - var options = XLSXExportOptions() + private let storage = PluginSettingsStorage(pluginId: "xlsx") - required init() {} + var options = XLSXExportOptions() { + didSet { storage.save(options) } + } + + required init() { + if let saved = storage.load(XLSXExportOptions.self) { + options = saved + } + } func optionsView() -> AnyView? { AnyView(XLSXExportOptionsView(plugin: self)) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 953166f5..324acf89 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -12,6 +12,8 @@ import TableProPluginKit final class PluginManager { static let shared = PluginManager() static let currentPluginKitVersion = 1 + private static let disabledPluginsKey = "com.TablePro.disabledPlugins" + private static let legacyDisabledPluginsKey = "disabledPlugins" private(set) var plugins: [PluginEntry] = [] @@ -25,6 +27,8 @@ final class PluginManager { private(set) var importPlugins: [String: any ImportFormatPlugin] = [:] + private(set) var pluginInstances: [String: any TableProPlugin] = [:] + private var builtInPluginsDir: URL? { Bundle.main.builtInPlugInsURL } private var userPluginsDir: URL { @@ -33,8 +37,8 @@ final class PluginManager { } var disabledPluginIds: Set { - get { Set(UserDefaults.standard.stringArray(forKey: "disabledPlugins") ?? []) } - set { UserDefaults.standard.set(Array(newValue), forKey: "disabledPlugins") } + get { Set(UserDefaults.standard.stringArray(forKey: Self.disabledPluginsKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: Self.disabledPluginsKey) } } private static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager") @@ -43,11 +47,22 @@ final class PluginManager { private init() {} + private func migrateDisabledPluginsKey() { + let defaults = UserDefaults.standard + if let legacy = defaults.stringArray(forKey: Self.legacyDisabledPluginsKey) { + if defaults.stringArray(forKey: Self.disabledPluginsKey) == nil { + defaults.set(legacy, forKey: Self.disabledPluginsKey) + } + defaults.removeObject(forKey: Self.legacyDisabledPluginsKey) + } + } + // MARK: - Loading /// Discover and load all plugins. Discovery is synchronous (reads Info.plist), /// then bundle loading is deferred to the next run loop iteration so it doesn't block app launch. func loadPlugins() { + migrateDisabledPluginsKey() discoverAllPlugins() Task { @MainActor in self.loadPendingPlugins() @@ -206,6 +221,7 @@ final class PluginManager { // MARK: - Capability Registration private func registerCapabilities(_ instance: any TableProPlugin, pluginId: String) { + pluginInstances[pluginId] = instance let declared = Set(type(of: instance).capabilities) if let driver = instance as? any DriverPlugin { @@ -265,6 +281,7 @@ final class PluginManager { } private func unregisterCapabilities(pluginId: String) { + pluginInstances.removeValue(forKey: pluginId) driverPlugins = driverPlugins.filter { _, value in guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true } if let principalClass = entry.bundle.principalClass as? any DriverPlugin.Type { @@ -511,6 +528,8 @@ final class PluginManager { try fm.removeItem(at: entry.url) } + PluginSettingsStorage(pluginId: id).removeAll() + var disabled = disabledPluginIds disabled.remove(id) disabledPluginIds = disabled diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index 99669f11..63ed415a 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -177,6 +177,9 @@ struct BrowsePluginsView: View { do { _ = try await pluginManager.installFromRegistry(plugin) { fraction in installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) + if fraction >= 1.0 { + installTracker.markInstalling(pluginId: plugin.id) + } } installTracker.completeInstall(pluginId: plugin.id) } catch { diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 507dc199..888d084a 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -177,6 +177,12 @@ struct InstalledPluginsView: View { .foregroundStyle(.secondary) } + if let exportPlugin = pluginManager.pluginInstances[plugin.id] as? any ExportFormatPlugin, + let exportSettings = exportPlugin.optionsView() { + Divider() + exportSettings + } + if plugin.source == .userInstalled { HStack { Spacer() @@ -247,10 +253,6 @@ private extension PluginCapability { case .databaseDriver: String(localized: "Database Driver") case .exportFormat: String(localized: "Export Format") case .importFormat: String(localized: "Import Format") - case .sqlDialect: String(localized: "SQL Dialect") - case .aiProvider: String(localized: "AI Provider") - case .cellRenderer: String(localized: "Cell Renderer") - case .sidebarPanel: String(localized: "Sidebar Panel") } } } diff --git a/TableProTests/Core/Plugins/PluginSettingsTests.swift b/TableProTests/Core/Plugins/PluginSettingsTests.swift new file mode 100644 index 00000000..21d49b61 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginSettingsTests.swift @@ -0,0 +1,280 @@ +// +// PluginSettingsTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("PluginSettingsStorage") +struct PluginSettingsStorageTests { + + private let testPluginId = "test.settings.\(UUID().uuidString)" + + private func cleanup(storage: PluginSettingsStorage) { + storage.removeAll() + } + + @Test("save and load round-trips a Codable value") + func saveAndLoad() { + let storage = PluginSettingsStorage(pluginId: testPluginId) + defer { cleanup(storage: storage) } + + struct TestOptions: Codable, Equatable { + var flag: Bool + var count: Int + } + + let original = TestOptions(flag: true, count: 42) + storage.save(original) + let loaded = storage.load(TestOptions.self) + + #expect(loaded == original) + } + + @Test("load returns nil when no data exists") + func loadReturnsNilWhenEmpty() { + let storage = PluginSettingsStorage(pluginId: testPluginId) + defer { cleanup(storage: storage) } + + struct EmptyOptions: Codable { + var value: String + } + + let result = storage.load(EmptyOptions.self) + #expect(result == nil) + } + + @Test("save overwrites previous value") + func saveOverwritesPrevious() { + let storage = PluginSettingsStorage(pluginId: testPluginId) + defer { cleanup(storage: storage) } + + storage.save(10) + storage.save(20) + let loaded = storage.load(Int.self) + + #expect(loaded == 20) + } + + @Test("different keys store independently") + func differentKeysIndependent() { + let storage = PluginSettingsStorage(pluginId: testPluginId) + defer { cleanup(storage: storage) } + + storage.save("alpha", forKey: "keyA") + storage.save("beta", forKey: "keyB") + + #expect(storage.load(String.self, forKey: "keyA") == "alpha") + #expect(storage.load(String.self, forKey: "keyB") == "beta") + } + + @Test("removeAll clears all keys for plugin") + func removeAllClearsKeys() { + let storage = PluginSettingsStorage(pluginId: testPluginId) + + storage.save("value1", forKey: "key1") + storage.save("value2", forKey: "key2") + storage.removeAll() + + #expect(storage.load(String.self, forKey: "key1") == nil) + #expect(storage.load(String.self, forKey: "key2") == nil) + } + + @Test("removeAll does not affect other plugins") + func removeAllIsolatedToPlugin() { + let storageA = PluginSettingsStorage(pluginId: testPluginId) + let otherPluginId = "test.settings.other.\(UUID().uuidString)" + let storageB = PluginSettingsStorage(pluginId: otherPluginId) + defer { + cleanup(storage: storageA) + cleanup(storage: storageB) + } + + storageA.save("fromA") + storageB.save("fromB") + storageA.removeAll() + + #expect(storageA.load(String.self) == nil) + #expect(storageB.load(String.self) == "fromB") + } + + @Test("keys are namespaced with com.TablePro.plugin prefix") + func keysNamespaced() { + let pluginId = "test.namespace.\(UUID().uuidString)" + let storage = PluginSettingsStorage(pluginId: pluginId) + defer { cleanup(storage: storage) } + + storage.save(true) + + let expectedKey = "com.TablePro.plugin.\(pluginId).settings" + let value = UserDefaults.standard.data(forKey: expectedKey) + #expect(value != nil) + } + + @Test("load returns nil for type mismatch") + func loadTypeMismatch() { + let storage = PluginSettingsStorage(pluginId: testPluginId) + defer { cleanup(storage: storage) } + + storage.save("a string value") + + struct DifferentType: Codable { + var number: Int + } + + let result = storage.load(DifferentType.self) + #expect(result == nil) + } +} + +@Suite("PluginCapability") +struct PluginCapabilityTests { + + @Test("only has 3 cases: databaseDriver, exportFormat, importFormat") + func onlyThreeCases() { + let allCases: [PluginCapability] = [.databaseDriver, .exportFormat, .importFormat] + #expect(allCases.count == 3) + } + + @Test("raw values are stable integers") + func rawValuesStable() { + #expect(PluginCapability.databaseDriver.rawValue == 0) + #expect(PluginCapability.exportFormat.rawValue == 1) + #expect(PluginCapability.importFormat.rawValue == 2) + } + + @Test("Codable round-trip preserves value") + func codableRoundTrip() throws { + let original = PluginCapability.exportFormat + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginCapability.self, from: data) + #expect(decoded == original) + } + + @Test("decoding removed raw value 3 fails gracefully") + func decodingRemovedRawValueFails() { + let json = Data("3".utf8) + let decoded = try? JSONDecoder().decode(PluginCapability.self, from: json) + #expect(decoded == nil) + } +} + +@Suite("DisabledPlugins Key Migration", .serialized) +struct DisabledPluginsMigrationTests { + + @Test("migration moves legacy key to namespaced key") + func migrationMovesKey() { + let testKey = "disabledPlugins" + let namespacedKey = "com.TablePro.disabledPlugins" + let defaults = UserDefaults.standard + + // Save current state + let savedNamespaced = defaults.stringArray(forKey: namespacedKey) + let savedLegacy = defaults.stringArray(forKey: testKey) + + defer { + // Restore original state + if let saved = savedNamespaced { + defaults.set(saved, forKey: namespacedKey) + } else { + defaults.removeObject(forKey: namespacedKey) + } + if let saved = savedLegacy { + defaults.set(saved, forKey: testKey) + } else { + defaults.removeObject(forKey: testKey) + } + } + + // Set up legacy key + defaults.removeObject(forKey: namespacedKey) + defaults.set(["plugin.a", "plugin.b"], forKey: testKey) + + // Simulate what migrateDisabledPluginsKey does + if let legacy = defaults.stringArray(forKey: testKey) { + if defaults.stringArray(forKey: namespacedKey) == nil { + defaults.set(legacy, forKey: namespacedKey) + } + defaults.removeObject(forKey: testKey) + } + + #expect(defaults.stringArray(forKey: namespacedKey) == ["plugin.a", "plugin.b"]) + #expect(defaults.stringArray(forKey: testKey) == nil) + } + + @Test("migration is no-op when legacy key absent") + func migrationNoOpWhenAbsent() { + let testKey = "disabledPlugins" + let namespacedKey = "com.TablePro.disabledPlugins" + let defaults = UserDefaults.standard + + let savedNamespaced = defaults.stringArray(forKey: namespacedKey) + let savedLegacy = defaults.stringArray(forKey: testKey) + + defer { + if let saved = savedNamespaced { + defaults.set(saved, forKey: namespacedKey) + } else { + defaults.removeObject(forKey: namespacedKey) + } + if let saved = savedLegacy { + defaults.set(saved, forKey: testKey) + } else { + defaults.removeObject(forKey: testKey) + } + } + + defaults.removeObject(forKey: testKey) + defaults.set(["existing.plugin"], forKey: namespacedKey) + + // Simulate migration + if let legacy = defaults.stringArray(forKey: testKey) { + if defaults.stringArray(forKey: namespacedKey) == nil { + defaults.set(legacy, forKey: namespacedKey) + } + defaults.removeObject(forKey: testKey) + } + + #expect(defaults.stringArray(forKey: namespacedKey) == ["existing.plugin"]) + } + + @Test("migration preserves namespaced key when both keys exist") + func migrationPreservesNamespacedWhenBothExist() { + let testKey = "disabledPlugins" + let namespacedKey = "com.TablePro.disabledPlugins" + let defaults = UserDefaults.standard + + let savedNamespaced = defaults.stringArray(forKey: namespacedKey) + let savedLegacy = defaults.stringArray(forKey: testKey) + + defer { + if let saved = savedNamespaced { + defaults.set(saved, forKey: namespacedKey) + } else { + defaults.removeObject(forKey: namespacedKey) + } + if let saved = savedLegacy { + defaults.set(saved, forKey: testKey) + } else { + defaults.removeObject(forKey: testKey) + } + } + + defaults.set(["legacy.plugin"], forKey: testKey) + defaults.set(["current.plugin"], forKey: namespacedKey) + + // Simulate migration + if let legacy = defaults.stringArray(forKey: testKey) { + if defaults.stringArray(forKey: namespacedKey) == nil { + defaults.set(legacy, forKey: namespacedKey) + } + defaults.removeObject(forKey: testKey) + } + + #expect(defaults.stringArray(forKey: namespacedKey) == ["current.plugin"]) + #expect(defaults.stringArray(forKey: testKey) == nil) + } +}