Files

283 lines
15 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import TermindPresentation
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct HostIconTests {
@Test(arguments: HostGeneralIcon.allCases)
func generalIconsOverrideDetectionWithoutBecomingDistributionMatches(icon: HostGeneralIcon) throws {
let catalog = try fixtureCatalog()
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
#expect(
catalog.resolvedArtwork(overrideID: icon.id, distribution: ubuntu) == .systemImage(icon.systemImageName))
#expect(catalog.resolvedArtwork(overrideID: icon.id, distribution: nil) == .systemImage(icon.systemImageName))
#expect(catalog.resolvedEntry(overrideID: icon.id, distribution: ubuntu) == nil)
#expect(catalog.resolvedArtwork(overrideID: nil, distribution: ubuntu) == .asset("LinuxDistro-ubuntu"))
#expect(catalog.resolvedArtwork(overrideID: nil, distribution: nil) == .systemImage("server.rack"))
}
@Test func artworkPreservesMatchingAndUnknownOverrideFallbacks() throws {
let catalog = try fixtureCatalog()
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
#expect(catalog.resolvedArtwork(overrideID: "future-icon", distribution: ubuntu) == .systemImage("server.rack"))
#expect(catalog.resolvedArtwork(overrideID: "windows", distribution: ubuntu) == .asset("HostOS-windows"))
#expect(
catalog.resolvedArtwork(
overrideID: nil, distribution: DetectedDistribution(id: "custom", likeIDs: ["debian"]))
== .asset("LinuxDistro-debian"))
#expect(
catalog.resolvedArtwork(overrideID: nil, distribution: DetectedDistribution(id: "new-linux"))
== .asset("HostOS-linux"))
}
@Test func darwinResolvesWithoutLinuxMetadata() throws {
for kernel in ["Darwin", "darwin", "DARWIN"] {
let info = RemoteSystemInfo(kernelName: kernel, kernelRelease: "25.0", architecture: "arm64")
#expect(info.distributionDetection == .resolved(DetectedDistribution(id: "macos")))
#expect(info.distributionID == nil)
#expect(!info.factLines.contains("distribution_id: macos"))
}
#expect(RemoteSystemInfo(kernelName: "Windows_NT").distributionDetection == .resolved(nil))
}
@Test func manualArtworkNeverChangesDetectionAndAutomaticUsesLatestProbe() throws {
let catalog = try fixtureCatalog()
let host = Host(
name: "Server", host: "server.invalid", detectedDistribution: DetectedDistribution(id: "ubuntu"))
let library = HostLibraryModel(hosts: [host])
var draft = host
draft.iconOverrideID = "windows"
#expect(library.host(host.id)?.iconOverrideID == nil)
library.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Darwin"), for: host.id)
var reconnects = 0
library.didChangeHostConnection = { _ in reconnects += 1 }
library.didChangeTelnetConnection = { _ in reconnects += 1 }
#expect(library.saveHost(draft))
var saved = try #require(library.host(host.id))
#expect(saved.iconOverrideID == "windows")
#expect(saved.detectedDistribution?.id == "macos")
#expect(
catalog.resolvedEntry(overrideID: saved.iconOverrideID, distribution: saved.detectedDistribution)?.id
== "windows")
library.recordRemoteSystemInfo(
RemoteSystemInfo(distributionID: "debian", kernelName: "Linux"), for: host.id)
saved = try #require(library.host(host.id))
#expect(saved.iconOverrideID == "windows")
saved.iconOverrideID = nil
#expect(library.saveHost(saved))
#expect(
catalog.resolvedEntry(overrideID: nil, distribution: library.host(host.id)?.detectedDistribution)?.id
== "debian")
#expect(reconnects == 0)
}
@Test func catalogResolvesOverridesAndPreservesLinuxFallbacks() throws {
let catalog = try fixtureCatalog()
let ubuntu = try #require(DetectedDistribution(id: "ubuntu"))
#expect(catalog.resolvedEntry(overrideID: "macos", distribution: ubuntu)?.asset == "HostOS-macos")
#expect(catalog.resolvedEntry(overrideID: "windows", distribution: ubuntu)?.asset == "HostOS-windows")
#expect(catalog.resolvedEntry(overrideID: "server", distribution: ubuntu) == nil)
#expect(catalog.resolvedEntry(overrideID: "future-os", distribution: ubuntu) == nil)
#expect(catalog.resolvedEntry(overrideID: nil, distribution: ubuntu)?.asset == "LinuxDistro-ubuntu")
#expect(
catalog.entry(matching: try #require(DetectedDistribution(id: "custom", likeIDs: ["debian"])))?.id
== "debian")
#expect(catalog.selectableEntries.map(\.id) == ["bsd", "debian", "linux", "macos", "ubuntu", "windows"])
#expect(Set(catalog.entries.map(\.id)).count == catalog.entries.count)
}
private func fixtureCatalog() throws -> HostIconCatalog {
try JSONDecoder().decode(
HostIconCatalog.self,
from: Data(
"""
{"entries":[
{"id":"ubuntu","title":"Ubuntu","asset":"LinuxDistro-ubuntu","ids":["ubuntu"],"hex":"E95420"},
{"id":"debian","title":"Debian","asset":"LinuxDistro-debian","ids":["debian"],"hex":"A81D33"},
{"id":"macos","title":"macOS","asset":"HostOS-macos","ids":["macos"],"hex":"000000"},
{"id":"windows","title":"Windows","asset":"HostOS-windows","ids":["windows"],"hex":"0078D4"},
{"id":"linux","title":"Linux","asset":"HostOS-linux","ids":["linux"],"hex":"FCC624"},
{"id":"bsd","title":"BSD","asset":"HostOS-bsd","ids":["bsd","dragonfly"],"hex":"AB2B28"}
]}
""".utf8))
}
@Test func genericArtworkDoesNotReplaceExactMatchesOrManualChoices() throws {
let catalog = try fixtureCatalog()
let unknown = try #require(DetectedDistribution(id: "new-linux"))
#expect(catalog.entry(matching: unknown)?.id == "linux")
#expect(catalog.entry(matching: try #require(DetectedDistribution(id: "dragonfly")))?.id == "bsd")
#expect(
catalog.entry(
matching: try #require(DetectedDistribution(id: "custom", likeIDs: ["missing", "debian", "ubuntu"])))?
.id
== "debian")
#expect(
catalog.entry(matching: try #require(DetectedDistribution(id: "ubuntu", likeIDs: ["debian"])))?.id
== "ubuntu")
#expect(catalog.resolvedEntry(overrideID: "server", distribution: unknown) == nil)
#expect(catalog.resolvedEntry(overrideID: "future-icon", distribution: unknown) == nil)
#expect(catalog.resolvedEntry(overrideID: nil, distribution: nil) == nil)
}
@Test func alphabeticalOrderIgnoresCaseAndUsesStableIDsForTies() throws {
var catalog = try fixtureCatalog()
let template = try #require(catalog.entries.first)
catalog.entries = [("z", "alpha"), ("b", "Beta"), ("a", "ALPHA"), ("w", "Windows")].map { id, title in
var entry = template
entry.id = id
entry.title = title
return entry
}
#expect(catalog.selectableEntries.map(\.id) == ["a", "z", "b", "w"])
catalog.entries.reverse()
#expect(catalog.selectableEntries.map(\.id) == ["a", "z", "b", "w"])
}
@Test func legacyDomainJSONDefaultsToAutomaticAndUnknownIDsSurvive() throws {
let host = Host(name: "Host", host: "example.invalid")
let data = try JSONEncoder().encode(host)
#expect(try JSONDecoder().decode(Host.self, from: data).iconOverrideID == nil)
for id in HostGeneralIcon.allCases.map(\.id) + ["windows", "macos", "future-icon"] {
var manual = host
manual.iconOverrideID = id
#expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(manual)) == manual)
#expect(PersistentHost(manual).value == manual)
}
}
@Test func savedChoicesSyncAndCanBeCleared() throws {
let source = try TermindPersistence.makeContainer(inMemory: true)
let destination = try TermindPersistence.makeContainer(inMemory: true)
let store = HostPersistence(context: source.mainContext)
let remote = HostPersistence(context: destination.mainContext)
let library = HostLibraryModel(persistence: store)
var host = Host(
name: "Host", host: "example.invalid", detectedDistribution: DetectedDistribution(id: "dragonfly"))
#expect(library.saveHost(host))
host = try #require(library.host(host.id))
let journal = VaultSyncJournal(context: source.mainContext)
let choices: [String?] =
HostGeneralIcon.allCases.map { $0.id } + [
"windows", "macos", "freebsd", "bsd", "linux", "future-icon", nil,
]
for choice in choices {
host.iconOverrideID = choice
#expect(library.saveHost(host))
let envelope = try #require(try journal.envelope(for: .init(kind: .host, id: host.id)))
#expect(envelope.formatVersion == SyncRecordFormats.vault.writingVersion(for: "host"))
_ = try remote.applyRemote([VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))])
#expect(try remote.load().hosts.first?.iconOverrideID == choice)
#expect(try remote.load().hosts.first?.detectedDistribution?.id == "dragonfly")
}
}
@Test func tailscaleChoicesSurviveRediscoveryAndWireRoundTrips() throws {
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net"))
let peer = TailscalePeer(
stableID: "n1", name: "Mac", dnsName: "mac.example.ts.net", online: true, sshHostKeys: ["ssh-ed25519 test"])
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer], groupID: group.id)
var host = try #require(library.hosts.first)
for choice: String? in ["windows", "cloud", "virtual_machine", nil] {
host.iconOverrideID = choice
#expect(library.saveTailscaleHost(host))
library.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Darwin"), for: host.id)
library.replaceTailscalePeers([peer], groupID: group.id)
let projected = try #require(library.host(host.id))
#expect(projected.iconOverrideID == choice)
#expect(projected.detectedDistribution?.id == "macos")
let payload = VaultRecordPayload.hostGroup(try #require(library.group(group.id)))
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let restored = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))
#expect(restored == envelope)
let container = try TermindPersistence.makeContainer(inMemory: true)
let store = HostPersistence(context: container.mainContext)
_ = try store.applyRemote([restored])
let other = HostLibraryModel(persistence: store)
other.replaceTailscalePeers([peer], groupID: group.id)
#expect(other.host(host.id)?.iconOverrideID == choice)
}
}
@Test func olderWireFormatsRejectLossyWritesAndDefaultToAutomatic() throws {
var host = Host(name: "Host", host: "example.invalid", sessionPersistence: .init(enabled: false))
for version in [1, 2, 4] {
host.iconOverrideID = nil
var envelope = VaultEnvelope(
key: .init(kind: .host, id: host.id), version: .baseline(writerID: UUID()),
payload: .host(host), formatVersion: version)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
host.iconOverrideID = "windows"
envelope.payload = .host(host)
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) }
}
for version in [1, 2, 4, 5] {
let deletion = VaultEnvelope(
key: .init(kind: .host, id: host.id), version: .baseline(writerID: UUID()),
isDeleted: true, formatVersion: version)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(deletion)) == deletion)
}
}
@Test func failedSaveDoesNotPublishDraftArtwork() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let store = HostPersistence(context: container.mainContext)
let library = HostLibraryModel(persistence: store)
let host = Host(name: "Host", host: "example.invalid")
#expect(library.saveHost(host))
let before = try #require(library.host(host.id))
let journal = VaultSyncJournal(context: container.mainContext)
let key = VaultRecordKey(kind: .host, id: host.id)
let intent = try journal.envelope(for: key)
let corrupt = try PersistentWorkspaceTemplate(WorkspaceTemplate(name: "Broken", layout: .pane(.empty)))
corrupt.layoutData = Data("invalid".utf8)
container.mainContext.insert(corrupt)
try container.mainContext.save()
var draft = before
draft.iconOverrideID = "windows"
#expect(!library.saveHost(draft))
#expect(library.host(host.id) == before)
#expect(try journal.envelope(for: key) == intent)
}
@Test func version53StoreMigratesThenRetainsArtworkOnReopen() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Hosts.store")
var host = Host(
name: "Existing", host: "example.invalid", port: 2222, tags: ["production"], colorTag: .teal,
detectedDistribution: DetectedDistribution(id: "ubuntu"),
sessionPersistence: .init(enabled: true))
do {
let schema = Schema(versionedSchema: TermindSchemaV53.self)
let legacy = try ModelContainer(
for: schema,
configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none))
legacy.mainContext.insert(TermindSchemaV53HostModels.PersistentHost(host))
try legacy.mainContext.save()
}
do {
let migrated = try TermindPersistence.makeContainer(url: url)
let store = HostPersistence(context: migrated.mainContext)
#expect(try store.load().hosts == [host])
host.iconOverrideID = "macos"
_ = try store.save(.init(hosts: [host]))
}
let reopened = try TermindPersistence.makeContainer(url: url)
#expect(try HostPersistence(context: reopened.mainContext).load().hosts == [host])
}
}