Files
Termind/FrameworkTests/TerminalCustomThemeTests.swift

616 lines
33 KiB
Swift

// 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<PersistentAIConfigurationSyncEntry>())
#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<PersistentAIConfigurationSyncEntry>()).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) }
}
}