// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import CloudKit import Foundation import SwiftData import TermindApplication import TermindDomain import TermindInfrastructure import TermindPersistence import Testing @Suite @MainActor struct TerminalCustomThemeTests { @Test func copyingPreservesThemeContentAndTheDifferentDraftBaselines() { let original = TerminalCustomTheme( name: "Original", appearance: .both, baseThemeName: "Termind Light", configuration: "background = 123456") let copy = original.copy(named: "Original Copy") #expect(copy.id != original.id) #expect(copy.name == "Original Copy") #expect(copy.appearance == .both && copy.baseThemeName == "Termind Light") #expect(copy.configuration == "background = 123456") let newDraft = TerminalCustomThemeDraft(copy, isNew: true) #expect(newDraft.isNew && !newDraft.isDirty) #expect(newDraft.original == copy) let recovered = TerminalCustomThemeDraft(original, isNew: false) recovered.value.configuration = "background = ABCDEF" recovered.makeCopy(name: "Recovered") #expect(recovered.isNew && recovered.isDirty) #expect(recovered.original == original) #expect(recovered.id != original.id && recovered.id != copy.id) #expect(recovered.value.configuration == "background = ABCDEF") #expect(recovered.value.appearance == .both && recovered.value.baseThemeName == "Termind Light") let model = TerminalSettingsModel() #expect(model.saveCustomTheme(original, isNew: true)) #expect(newDraft.save(in: model)) #expect(recovered.save(in: model)) #expect(!recovered.isNew && !recovered.isDirty) #expect(model.themeLibrary.themes.first { $0.id == original.id } == original) } @Test func choicesAreIndependentAndDeletingAnActiveThemeRestoresBothFallbacks() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let model = TerminalSettingsModel(persistence: fixture.store) let first = theme("First") let second = theme("Second", appearance: .dark) #expect(model.saveCustomTheme(first, isNew: true)) #expect(model.saveCustomTheme(second, isNew: true)) #expect(model.themeLibrary.selections.isEmpty) #expect(model.selectTheme(named: "GitHub Light", isDark: false)) #expect(model.selectCustomTheme(id: first.id, isDark: false)) #expect(model.themeLibrary.selectedTheme(isDark: true) == nil) #expect(!model.selectCustomTheme(id: second.id, isDark: false)) #expect(model.selectCustomTheme(id: first.id, isDark: true)) #expect(model.deleteCustomTheme(id: first.id)) #expect(model.themeLibrary.selection(isDark: false)?.builtInThemeName == "GitHub Light") #expect(model.themeLibrary.selection(isDark: false)?.customThemeID == nil) #expect(model.themeLibrary.selection(isDark: true)?.customThemeID == nil) #expect(try fixture.store.loadTerminalThemes().themes == [second]) } @Test func draftsDoNotApplyUntilSavedAndCannotResurrectDeletedThemes() { let model = TerminalSettingsModel() let original = theme("Personal") #expect(model.saveCustomTheme(original, isNew: true)) let draft = TerminalCustomThemeDraft(original, isNew: false) draft.value.configuration = "background = 123456" #expect(model.themeLibrary.themes.first?.configuration == "") var remote = original remote.name = "Renamed remotely" model.applyRemoteThemes(.init(themes: [remote])) draft.refresh(from: model) #expect(draft.value.name == original.name) #expect(draft.save(in: model)) #expect(!draft.isDirty) #expect(model.themeLibrary.themes.first?.configuration == "background = 123456") model.applyRemoteThemes(.init()) draft.value.configuration = "background = ABCDEF" #expect(draft.isDeleted(in: model)) #expect(!draft.save(in: model)) draft.makeCopy(name: "Recovered") #expect(draft.id != original.id) #expect(draft.save(in: model)) #expect(model.themeLibrary.themes.first?.name == "Recovered") } @Test func changingApplicabilityRepairsOnlyTheUnsupportedSelection() { let model = TerminalSettingsModel() var value = theme("Both") #expect(model.saveCustomTheme(value, isNew: true)) #expect(model.selectCustomTheme(id: value.id, isDark: false)) #expect(model.selectCustomTheme(id: value.id, isDark: true)) value.appearance = .light #expect(model.saveCustomTheme(value, isNew: false)) #expect(model.themeLibrary.selectedTheme(isDark: false)?.id == value.id) #expect(model.themeLibrary.selection(isDark: true)?.customThemeID == nil) } @Test func failedLibraryWriteLeavesTheModelAndDraftUntouched() { let store = FailingThemeStore() let model = TerminalSettingsModel(persistence: store) let draft = TerminalCustomThemeDraft(theme("New"), isNew: true) draft.value.configuration = "foreground = 123456" #expect(!draft.save(in: model)) #expect(draft.isDirty) #expect(draft.isNew) #expect(model.themeLibrary.themes.isEmpty) #expect(model.storageError != nil) store.fails = false #expect(draft.save(in: model)) #expect(model.themeLibrary.themes.first?.configuration == "foreground = 123456") #expect(model.storageError == nil) } @Test func legacyCloudImportUsesStableIDsAndDoesNotRepeat() throws { let first = try Fixture() let second = try Fixture() defer { first.cleanUp() second.cleanUp() } let payload = SettingsRecordPayload.terminal( .init( darkCustomEnabled: true, darkCustomConfiguration: "background = 123456", lightCustomConfiguration: "foreground = ABCDEF")) let envelope = SettingsEnvelope( key: payload.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: payload) for fixture in [first, second] { let imported = try fixture.store.applyRemote([envelope]).snapshot.terminalThemes #expect(imported.themes.count == 2) #expect(imported.selectedTheme(isDark: true)?.configuration == "background = 123456") #expect(imported.selectedTheme(isDark: false) == nil) _ = try fixture.store.applyRemote([envelope]) try fixture.store.prepare() #expect(try fixture.store.loadTerminalThemes().themes.count == 2) let seeds = try fixture.store.journal.pendingEnvelopes().filter { $0.key.kind == .terminalCustomTheme || $0.key.kind == .terminalThemeSelection } #expect(seeds.allSatisfy { $0.version.modifiedAt == VaultVersion.baselineDate }) } #expect(try first.store.loadTerminalThemes() == second.store.loadTerminalThemes()) } @Test(arguments: ["custom", "builtIn", "default"]) func acceptedLegacyWinnerReconcilesUntouchedSeeds(_ kind: String) throws { let old = TerminalPreferences( darkTheme: "Old Dark", darkCustomEnabled: kind != "builtIn", darkCustomConfiguration: kind == "builtIn" ? "" : "background = 112233") let fixture = try Fixture(legacy: old) let fresh = try Fixture() defer { fixture.cleanUp() fresh.cleanUp() } let seeded = try fixture.store.loadTerminalThemes() let winner = kind == "default" ? TerminalPreferences() : TerminalPreferences( darkTheme: "New Dark", darkCustomEnabled: kind == "custom", darkCustomConfiguration: kind == "custom" ? "background = ABCDEF" : "") let envelope = legacyEnvelope(winner) let result = try fixture.store.applyRemote([envelope]) let library = result.snapshot.terminalThemes #expect(result.acceptedKeys == [envelope.key]) #expect(library.selection(isDark: true)?.builtInThemeName == winner.darkTheme) #expect(library.selectedTheme(isDark: true)?.configuration == (kind == "custom" ? "background = ABCDEF" : nil)) #expect(library.selection(isDark: false) == nil) #expect(Set(seeded.themes).isSubset(of: Set(library.themes))) let selectionKey = SettingsRecordKey(kind: .terminalThemeSelection, id: "dark") #expect(result.needsUpload.contains(selectionKey)) #expect(try fixture.store.journal.envelope(for: selectionKey)?.version.modifiedAt == VaultVersion.baselineDate) if kind == "custom" { let independentlyImported = try fresh.store.applyRemote([envelope]).snapshot.terminalThemes #expect(library.selectedTheme(isDark: true)?.id == independentlyImported.selectedTheme(isDark: true)?.id) } _ = try fixture.store.applyRemote([envelope]) try fixture.store.prepare() #expect(try fixture.store.loadTerminalThemes() == library) let rows = try fixture.container.mainContext.fetch(FetchDescriptor()) #expect(rows.filter { $0.recordName == selectionKey.recordName }.count == 1) let rejected = try fixture.store.applyRemote([legacyEnvelope(old, time: 50)]) #expect(rejected.acceptedKeys.isEmpty) #expect(rejected.snapshot.terminalThemes == library) let unrelated = SettingsRecordPayload.ssh(.init()) _ = try fixture.store.applyRemote([ SettingsEnvelope( key: unrelated.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: unrelated) ]) #expect(try fixture.store.loadTerminalThemes() == library) } @Test(arguments: ["selection", "edit", "selectionTombstone", "themeTombstone"]) func nativeChangesProtectOnlyTheirAppearanceFromLegacyReconciliation(_ change: String) throws { let old = TerminalPreferences( darkCustomEnabled: true, lightCustomEnabled: true, darkCustomConfiguration: "background = 112233", lightCustomConfiguration: "background = DDEEFF") let fixture = try Fixture(legacy: old) defer { fixture.cleanUp() } var library = try fixture.store.loadTerminalThemes() let darkTheme = try #require(library.selectedTheme(isDark: true)) let themeKey = SettingsRecordPayload.terminalCustomTheme(darkTheme).key let selectionKey = SettingsRecordKey(kind: .terminalThemeSelection, id: "dark") switch change { case "selection": library.select(.init(appearance: .dark, builtInThemeName: "Explicit Dark")) try fixture.store.saveTerminalThemes(library) case "edit": let index = try #require(library.themes.firstIndex { $0.id == darkTheme.id }) library.themes[index].configuration = "background = 778899" try fixture.store.saveTerminalThemes(library) case "selectionTombstone": _ = try fixture.store.applyRemote([], deletions: [selectionKey]) default: _ = try fixture.store.applyRemote([], deletions: [themeKey]) } let protected = try fixture.store.loadTerminalThemes() let selectionBefore = try fixture.store.journal.envelope(for: selectionKey) let themeBefore = try fixture.store.journal.envelope(for: themeKey) let winner = TerminalPreferences( darkCustomEnabled: true, lightCustomEnabled: true, darkCustomConfiguration: "background = 445566", lightCustomConfiguration: "background = AABBCC") let result = try fixture.store.applyRemote([legacyEnvelope(winner)]) #expect(result.snapshot.terminalThemes.selection(isDark: true) == protected.selection(isDark: true)) #expect(result.snapshot.terminalThemes.selectedTheme(isDark: true) == protected.selectedTheme(isDark: true)) #expect(result.snapshot.terminalThemes.selectedTheme(isDark: false)?.configuration == "background = AABBCC") #expect(try fixture.store.journal.envelope(for: selectionKey) == selectionBefore) #expect(try fixture.store.journal.envelope(for: themeKey) == themeBefore) #expect(!result.needsUpload.contains(selectionKey)) #expect(!result.snapshot.terminalThemes.themes.contains { $0.configuration == "background = 445566" }) try fixture.store.prepare() #expect(try fixture.store.loadTerminalThemes() == result.snapshot.terminalThemes) } @Test func legacyReconciliationDoesNotReviveAnEarlierDeletedImportedTheme() throws { let old = TerminalPreferences(darkCustomEnabled: true, darkCustomConfiguration: "background = 112233") let fixture = try Fixture(legacy: old) defer { fixture.cleanUp() } let original = try #require(try fixture.store.loadTerminalThemes().selectedTheme(isDark: true)) let replacement = TerminalPreferences(darkCustomEnabled: true, darkCustomConfiguration: "background = 445566") _ = try fixture.store.applyRemote([legacyEnvelope(replacement)]) let key = SettingsRecordPayload.terminalCustomTheme(original).key _ = try fixture.store.applyRemote([], deletions: [key]) let tombstone = try fixture.store.journal.envelope(for: key) let result = try fixture.store.applyRemote([legacyEnvelope(old, time: 200)]) #expect(!result.snapshot.terminalThemes.themes.contains { $0.id == original.id }) #expect(result.snapshot.terminalThemes.selectedTheme(isDark: true) == nil) #expect(try fixture.store.journal.envelope(for: key) == tombstone) #expect(!result.needsUpload.contains(key)) } @Test(arguments: [false, true]) func nativeRecordsAcceptedWithLegacySettingsKeepPrecedence(_ editsTheme: Bool) throws { let old = TerminalPreferences(darkCustomEnabled: true, darkCustomConfiguration: "background = 112233") let fixture = try Fixture(legacy: old) defer { fixture.cleanUp() } let library = try fixture.store.loadTerminalThemes() let native: SettingsEnvelope if editsTheme { var value = try #require(library.selectedTheme(isDark: true)) value.configuration = "background = 778899" let payload = SettingsRecordPayload.terminalCustomTheme(value) native = SettingsEnvelope( key: payload.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: payload) } else { let payload = SettingsRecordPayload.terminalThemeSelection(try #require(library.selection(isDark: true))) native = SettingsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) } let result = try fixture.store.applyRemote([ legacyEnvelope(.init(darkCustomEnabled: true, darkCustomConfiguration: "background = ABCDEF")), native, ]) #expect(result.acceptedKeys.contains(native.key)) #expect(try fixture.store.journal.envelope(for: native.key) == native) #expect(result.snapshot.terminalThemes.selection(isDark: true) == library.selection(isDark: true)) #expect( result.snapshot.terminalThemes.selectedTheme(isDark: true)?.configuration == (editsTheme ? "background = 778899" : "background = 112233")) #expect(result.snapshot.terminalThemes.themes.count == 1) } @Test func unrelatedRemoteTrafficDoesNotMigrateLocallyChangedLegacyPreferences() throws { let fixture = try Fixture() defer { fixture.cleanUp() } try fixture.store.saveTerminal(.init(darkCustomEnabled: true, darkCustomConfiguration: "background = 112233")) let payload = SettingsRecordPayload.ssh(.init()) _ = try fixture.store.applyRemote([ SettingsEnvelope( key: payload.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: payload) ]) #expect(try fixture.store.loadTerminalThemes() == TerminalThemeLibrary()) } @Test(arguments: [false, true]) func reconciledBaselineSelectionReachesAnotherDeviceWithoutOverridingExplicitChoices(_ includeLegacy: Bool) throws { let source = try Fixture(legacy: .init(darkTheme: "Old Dark")) let destination = try Fixture() defer { source.cleanUp() destination.cleanUp() } _ = try destination.store.applyRemote(source.store.journal.pendingEnvelopes()) let key = SettingsRecordKey(kind: .terminalThemeSelection, id: "dark") let old = try #require(try source.store.journal.envelope(for: key)) let legacy = legacyEnvelope(.init(darkTheme: "New Dark")) _ = try source.store.applyRemote([legacy]) let reconciled = try #require(try source.store.journal.envelope(for: key)) #expect(reconciled.version == old.version) #expect(reconciled.payload != old.payload) let incoming = includeLegacy ? [legacy, reconciled] : [reconciled] let received = try destination.store.applyRemote(incoming) #expect(received.acceptedKeys.contains(key)) #expect(received.snapshot.terminalThemes.selection(isDark: true)?.builtInThemeName == "New Dark") #expect(try destination.store.journal.envelope(for: key) == reconciled) var library = try destination.store.loadTerminalThemes() library.select(.init(appearance: .dark, builtInThemeName: "Explicit Dark")) try destination.store.saveTerminalThemes(library) let explicit = try destination.store.journal.envelope(for: key) _ = try destination.store.applyRemote([old, reconciled]) #expect(try destination.store.journal.envelope(for: key) == explicit) #expect(try destination.store.loadTerminalThemes() == library) } @Test func legacySeedReconciliationRollsBackJournalProjectionAndUploadIntent() throws { let fixture = try Fixture( legacy: .init(darkCustomEnabled: true, darkCustomConfiguration: "background = 112233")) defer { fixture.cleanUp() } for envelope in try fixture.store.journal.pendingEnvelopes() { try fixture.store.journal.markUploaded(envelope.key, version: envelope.version) } try fixture.container.mainContext.save() let library = try fixture.store.loadTerminalThemes() let preferences = try fixture.store.loadTerminal() let before = try journalContents(fixture.container.mainContext) let pending = try fixture.store.journal.pendingEnvelopes() let winner = legacyEnvelope(.init(darkCustomEnabled: true, darkCustomConfiguration: "background = ABCDEF")) #expect(throws: (any Error).self) { try fixture.store.applyRemote([winner], beforeSave: { throw CocoaError(.fileWriteNoPermission) }) } #expect(try fixture.store.loadTerminalThemes() == library) #expect(try fixture.store.loadTerminal() == preferences) #expect(try journalContents(fixture.container.mainContext) == before) #expect(try fixture.store.journal.pendingEnvelopes() == pending) #expect(TerminalPreferences(defaults: fixture.defaults) == preferences) let result = try fixture.store.applyRemote([winner]) #expect(result.snapshot.terminalThemes.selectedTheme(isDark: true)?.configuration == "background = ABCDEF") } @Test func reconciledLegacySeedsRetainStableIDsAndContinueFollowingWinnersAfterReopening() throws { let directory = FileManager.default.temporaryDirectory.appending(path: "ThemeReconciliation-\(UUID())") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appending(path: "store.sqlite") let domain = "ThemeReconciliation-\(UUID())" let defaults = UserDefaults(suiteName: domain)! defer { defaults.removePersistentDomain(forName: domain) } let winner = legacyEnvelope(.init(darkCustomEnabled: true, darkCustomConfiguration: "background = ABCDEF")) var retained = TerminalThemeLibrary() do { let container = try TermindPersistence.makeContainer(url: url) container.mainContext.insert( try PersistentTerminalPreferences( .init(darkCustomEnabled: true, darkCustomConfiguration: "background = 112233"))) let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) try store.prepare() retained = try store.applyRemote([winner]).snapshot.terminalThemes #expect(retained.themes.count == 2) } do { let container = try TermindPersistence.makeContainer(url: url) let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) try store.prepare() #expect(try store.loadTerminalThemes() == retained) let repeated = try store.applyRemote([winner]) #expect(repeated.acceptedKeys.isEmpty) #expect(repeated.snapshot.terminalThemes == retained) let reset = try store.applyRemote([legacyEnvelope(.init(), time: 200)]) #expect(reset.snapshot.terminalThemes.selectedTheme(isDark: true) == nil) #expect(reset.snapshot.terminalThemes.selection(isDark: true)?.builtInThemeName == "Termind Dark") #expect(reset.snapshot.terminalThemes.themes == retained.themes) retained = reset.snapshot.terminalThemes } let container = try TermindPersistence.makeContainer(url: url) let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) try store.prepare() #expect(try store.loadTerminalThemes() == retained) let restored = try store.applyRemote([ legacyEnvelope( .init(darkCustomEnabled: true, darkCustomConfiguration: "background = ABCDEF"), time: 300) ]) #expect(restored.snapshot.terminalThemes.selectedTheme(isDark: true)?.configuration == "background = ABCDEF") #expect(restored.snapshot.terminalThemes.themes == retained.themes) } @Test func nativeBatchFailureRollsBackThemesAndSelectionTogether() throws { let fixture = try Fixture() defer { fixture.cleanUp() } let custom = theme("Atomic") let payloads: [SettingsRecordPayload] = [ .terminalCustomTheme(custom), .terminalThemeSelection( .init(appearance: .dark, builtInThemeName: "Termind Dark", customThemeID: custom.id)), ] let envelopes = payloads.map { SettingsEnvelope(key: $0.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: $0) } #expect(throws: (any Error).self) { try fixture.store.applyRemote(envelopes, beforeSave: { throw CocoaError(.fileWriteNoPermission) }) } #expect(try fixture.store.loadTerminalThemes() == TerminalThemeLibrary()) for envelope in envelopes { #expect(try fixture.store.journal.envelope(for: envelope.key) == nil) } } @Test func nativeRecordsRoundTripWithoutJSONAndValidateTheirFields() throws { let value = theme("Native", configuration: "background = 112233\n# UTF-8: \u{4e3b}\u{9898}") let payloads: [SettingsRecordPayload] = [ .terminalCustomTheme(value), .terminalThemeSelection( .init(appearance: .light, builtInThemeName: "Termind Light", customThemeID: value.id)), .terminalThemeSelection(.init(appearance: .dark, builtInThemeName: "Termind Dark")), ] for payload in payloads { let envelope = SettingsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) let record = try nativeRecord(envelope) #expect(record["payload"] == nil) #expect(record["payloadAsset"] == nil) #expect(record["payloadHash"] == nil) #expect(try SettingsSyncCoordinator.envelope(from: record) == envelope) record["appearance"] = "unknown" as NSString #expect(throws: (any Error).self) { try SettingsSyncCoordinator.envelope(from: record) } record["formatVersion"] = NSNumber(value: 99) #expect(throws: CloudSyncCompatibilityError.self) { try SettingsSyncCoordinator.envelope(from: record) } } let tombstone = SettingsEnvelope( key: payloads[0].key, version: .baseline(writerID: UUID()), isDeleted: true) let record = try nativeRecord(tombstone) #expect(record["name"] == nil) #expect(record["configuration"] == nil) #expect(try SettingsSyncCoordinator.envelope(from: record) == tombstone) } @Test func largeConfigurationUsesRawUTF8AssetAndRejectsCorruption() throws { let value = theme("Large", configuration: String(repeating: "# line\n", count: 100_001)) let payload = SettingsRecordPayload.terminalCustomTheme(value) let envelope = SettingsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) let record = try nativeRecord(envelope) let asset = try #require(record["configurationAsset"] as? CKAsset) let url = try #require(asset.fileURL) defer { try? FileManager.default.removeItem(at: url) } #expect(record["configuration"] == nil) #expect(try String(contentsOf: url, encoding: .utf8) == value.configuration) #expect(try SettingsSyncCoordinator.envelope(from: record) == envelope) record["configurationHash"] = "corrupt" as NSString #expect(throws: (any Error).self) { try SettingsSyncCoordinator.envelope(from: record) } } @Test func selectionCanArriveBeforeThemeAndLegacyWritesDoNotEraseTheLibrary() throws { let source = try Fixture() let destination = try Fixture() defer { source.cleanUp() destination.cleanUp() } let model = TerminalSettingsModel(persistence: source.store) let value = theme("Synced") #expect(model.saveCustomTheme(value, isNew: true)) #expect(model.selectCustomTheme(id: value.id, isDark: false)) let entries = try source.store.journal.pendingEnvelopes() let choices = entries.filter { $0.key.kind == .terminalThemeSelection } let themes = entries.filter { $0.key.kind == .terminalCustomTheme } let pending = try destination.store.applyRemote(choices).snapshot.terminalThemes #expect(pending.selection(isDark: false)?.customThemeID == value.id) #expect(pending.selectedTheme(isDark: false) == nil) let resolved = try destination.store.applyRemote(themes).snapshot.terminalThemes #expect(resolved.selectedTheme(isDark: false)?.id == value.id) #expect(try !destination.store.journal.pendingEnvelopes().contains { $0.key.kind == .terminalCustomTheme }) let legacy = SettingsRecordPayload.terminal(.init(lightTheme: "Other Legacy Theme")) let oldWriter = SettingsEnvelope( key: legacy.key, version: .init(modifiedAt: .distantFuture, counter: 0, writerID: UUID()), payload: legacy) let afterLegacy = try destination.store.applyRemote([oldWriter]).snapshot.terminalThemes #expect(afterLegacy == resolved) #expect(model.deleteCustomTheme(id: value.id)) let deletion = try source.store.journal.pendingEnvelopes().filter { $0.key.kind == .terminalCustomTheme || $0.key.kind == .terminalThemeSelection } let removed = try destination.store.applyRemote(deletion).snapshot.terminalThemes #expect(removed.themes.isEmpty) #expect(removed.selection(isDark: false)?.customThemeID == nil) try destination.store.prepare() #expect(try destination.store.loadTerminalThemes().themes.isEmpty) } @Test func independentDeviceEditsMergePerThemeRatherThanReplacingTheWholeLibrary() throws { let first = try Fixture() let second = try Fixture() defer { first.cleanUp() second.cleanUp() } let a = theme("A") let b = theme("B") try first.store.saveTerminalThemes(.init(themes: [a, b])) _ = try second.store.applyRemote(first.store.journal.pendingEnvelopes()) var left = try first.store.loadTerminalThemes() var right = try second.store.loadTerminalThemes() left.themes[left.themes.firstIndex(where: { $0.id == a.id })!].name = "A edited" right.themes[right.themes.firstIndex(where: { $0.id == b.id })!].name = "B edited" try first.store.saveTerminalThemes(left) try second.store.saveTerminalThemes(right) let leftChanges = try first.store.journal.pendingEnvelopes() let rightChanges = try second.store.journal.pendingEnvelopes() _ = try first.store.applyRemote(rightChanges) _ = try second.store.applyRemote(leftChanges) for fixture in [first, second] { let names = Set(try fixture.store.loadTerminalThemes().themes.map(\.name)) #expect(names == ["A edited", "B edited"]) } } @Test func historicalCustomConfigurationsMigrateOnceAndSurviveStoreReopening() throws { let directory = FileManager.default.temporaryDirectory.appending(path: "ThemeMigration-\(UUID())") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appending(path: "store.sqlite") let defaults = UserDefaults(suiteName: "ThemeMigration-\(UUID())")! let schema = Schema(versionedSchema: TermindSchemaV56.self) var old: ModelContainer? = try ModelContainer( for: schema, configurations: ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none)) old!.mainContext.insert( try PersistentTerminalPreferences( .init( darkCustomEnabled: true, darkCustomConfiguration: "background = 123456", lightCustomConfiguration: "foreground = ABCDEF"))) try old!.mainContext.save() old = nil var retained: TerminalThemeLibrary? do { let container = try TermindPersistence.makeContainer(url: url) let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) try store.prepare() retained = try store.loadTerminalThemes() #expect(retained?.themes.count == 2) #expect(retained?.selectedTheme(isDark: true)?.configuration == "background = 123456") #expect(retained?.selectedTheme(isDark: false) == nil) try store.prepare() #expect(try store.loadTerminalThemes() == retained) } let reopened = try TermindPersistence.makeContainer(url: url) let store = SettingsSyncPersistence(context: reopened.mainContext, defaults: defaults) try store.prepare() #expect(try store.loadTerminalThemes() == retained) let model = TerminalSettingsModel(persistence: store) for value in model.themeLibrary.themes { #expect(model.deleteCustomTheme(id: value.id)) } try store.prepare() #expect(try store.loadTerminalThemes().themes.isEmpty) } private func theme( _ name: String, appearance: TerminalThemeAppearance = .both, configuration: String = "" ) -> TerminalCustomTheme { .init(name: name, appearance: appearance, baseThemeName: "Termind Dark", configuration: configuration) } private func legacyEnvelope(_ preferences: TerminalPreferences, time: TimeInterval = 100) -> SettingsEnvelope { let payload = SettingsRecordPayload.terminal(preferences) return SettingsEnvelope( key: payload.key, version: .init(modifiedAt: Date(timeIntervalSince1970: time), counter: 0, writerID: UUID()), payload: payload) } private func journalContents(_ context: ModelContext) throws -> [String: Data] { Dictionary( uniqueKeysWithValues: try context.fetch(FetchDescriptor()).map { ($0.recordName, $0.envelopeData) }) } private func nativeRecord(_ envelope: SettingsEnvelope) throws -> CKRecord { try SettingsSyncCoordinator.makeRecord( .init(envelope: envelope), recordID: .init( recordName: envelope.key.recordName, zoneID: .init(zoneName: SettingsSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName))) } private struct Fixture { let container: ModelContainer let store: SettingsSyncPersistence let domain = "TerminalCustomThemeTests.\(UUID())" let defaults: UserDefaults init(legacy: TerminalPreferences? = nil) throws { container = try TermindPersistence.makeContainer(inMemory: true) defaults = UserDefaults(suiteName: domain)! if let legacy { container.mainContext.insert(try PersistentTerminalPreferences(legacy)) } store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) try store.prepare() } func cleanUp() { defaults.removePersistentDomain(forName: domain) } } } @MainActor private final class FailingThemeStore: TerminalPreferencesPersisting { var fails = true var usesNativeTerminalThemes: Bool { true } func loadTerminal() throws -> TerminalPreferences { .init() } func saveTerminal(_ value: TerminalPreferences) throws {} func saveTerminalThemes(_ value: TerminalThemeLibrary) throws { if fails { throw CocoaError(.fileWriteNoPermission) } } }