315 lines
14 KiB
Swift
315 lines
14 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import Testing
|
|
import UIKit
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileCardTests {
|
|
@Test func workspaceConnectionIndicatorHidesDisconnectedWorkspaces() {
|
|
#expect(
|
|
MobileWorkspaceConnectionIndicator.resolve(
|
|
connected: false, busy: false, reconnecting: false
|
|
) == nil
|
|
)
|
|
#expect(
|
|
MobileWorkspaceConnectionIndicator.resolve(
|
|
connected: true, busy: false, reconnecting: false
|
|
) == .connected
|
|
)
|
|
#expect(
|
|
MobileWorkspaceConnectionIndicator.resolve(
|
|
connected: false, busy: true, reconnecting: false
|
|
) == .busy
|
|
)
|
|
#expect(
|
|
MobileWorkspaceConnectionIndicator.resolve(
|
|
connected: false, busy: false, reconnecting: true
|
|
) == .busy
|
|
)
|
|
}
|
|
|
|
@Test func cardColumnsFollowContainerWidthOnEveryDevice() {
|
|
for width: CGFloat in [0, 288, 370, 660, 1024] {
|
|
#expect(MobileCardLayout.columns(width: width, accessibilitySize: true) == 1)
|
|
}
|
|
#expect(MobileCardLayout.columns(width: 0, accessibilitySize: false) == 1)
|
|
#expect(MobileCardLayout.columns(width: 651, accessibilitySize: false) == 1)
|
|
#expect(MobileCardLayout.columns(width: 652, accessibilitySize: false) == 2)
|
|
#expect(MobileCardLayout.columns(width: 983, accessibilitySize: false) == 2)
|
|
#expect(MobileCardLayout.columns(width: 984, accessibilitySize: false) == 3)
|
|
}
|
|
|
|
@Test func standardCardHeightDoesNotChangeWithHostTags() {
|
|
let noTags = measuredHeight(of: standardCard(tags: []))
|
|
let oneTag = measuredHeight(of: standardCard(tags: ["production"]))
|
|
let manyTags = measuredHeight(
|
|
of: standardCard(tags: ["production", "a-very-long-tag-that-must-not-wrap", "hidden"]))
|
|
let actionNoTags = measuredHeight(of: standardActionCard(tags: []))
|
|
let actionManyTags = measuredHeight(
|
|
of: standardActionCard(tags: ["production", "a-very-long-tag-that-must-not-wrap", "hidden"]))
|
|
|
|
#expect(noTags >= MobileCardLayout.minimumHeight)
|
|
#expect(abs(noTags - oneTag) <= 1)
|
|
#expect(abs(noTags - manyTags) <= 1)
|
|
#expect(actionNoTags >= MobileCardLayout.minimumHeight)
|
|
#expect(abs(actionNoTags - actionManyTags) <= 1)
|
|
}
|
|
|
|
@Test func hostCardsShowAtMostTwoTags() {
|
|
#expect(Array(MobileCardLayout.visibleTags([])).isEmpty)
|
|
#expect(Array(MobileCardLayout.visibleTags(["one"])) == ["one"])
|
|
#expect(Array(MobileCardLayout.visibleTags(["one", "two", "three"])) == ["one", "two"])
|
|
}
|
|
|
|
@Test func forwardingCardsMatchHostHeightWithOrWithoutNames() {
|
|
for width: CGFloat in [320, 398, 600] {
|
|
let hostHeight = measuredHeight(of: standardActionCard(tags: ["production"]), width: width)
|
|
for kind in PortForwardingRule.Kind.allCases {
|
|
for name in ["", "Database with a long display name that stays on one line"] {
|
|
let rule = PortForwardingRule(
|
|
hostID: UUID(), kind: kind, targetHost: "database.production.example.invalid", label: name)
|
|
let card = MobileActionCard(
|
|
action: {},
|
|
content: { MobileForwardingCardContent(rule: rule, hostName: "Production", runtime: .stopped) },
|
|
actions: { EmptyView() })
|
|
#expect(abs(measuredHeight(of: card, width: width) - hostHeight) <= 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test func unnamedForwardingUsesTheHostAndSSHArgument() {
|
|
for (kind, argument) in [(PortForwardingRule.Kind.local, "-L"), (.remote, "-R"), (.dynamic, "-D")] {
|
|
var rule = PortForwardingRule(hostID: UUID(), kind: kind)
|
|
#expect(rule.displayName(hostName: "AWS") == "AWS (\(argument))")
|
|
rule.label = "Database"
|
|
#expect(rule.displayName(hostName: "AWS") == "Database")
|
|
rule.label = " "
|
|
#expect(rule.displayName(hostName: "AWS") == "AWS (\(argument))")
|
|
}
|
|
}
|
|
|
|
@Test func knownHostEditorDistinguishesAddAndEditTitles() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let saved = KnownHostEntry(hostname: "saved.example", fingerprint: "SHA256:saved")
|
|
#expect(app.knownHosts.save(saved))
|
|
|
|
let add = MobileKnownHostEditor(app: app, entry: KnownHostEntry(hostname: ""))
|
|
let edit = MobileKnownHostEditor(app: app, entry: saved)
|
|
|
|
#expect(add.isNew)
|
|
#expect(add.editorTitle == String(localized: "Add Host Key"))
|
|
#expect(!edit.isNew)
|
|
#expect(edit.editorTitle == String(localized: "Edit Host Key"))
|
|
}
|
|
|
|
@Test func hostSectionsFoldNestedGroupsKeepEmptyGroupsAndLeaveSearchAvailable() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let root = HostGroup(name: "Production")
|
|
let child = HostGroup(name: "Edge", parentID: root.id)
|
|
let empty = HostGroup(name: "Empty")
|
|
let rootHost = TermindDomain.Host(name: "Gateway", host: "gateway.example", groupID: root.id)
|
|
let childHost = TermindDomain.Host(name: "Child Node", host: "edge.example", groupID: child.id)
|
|
let ungroupedHost = TermindDomain.Host(name: "Loose", host: "loose.example")
|
|
|
|
app.library.upsert(root)
|
|
app.library.upsert(child)
|
|
app.library.upsert(empty)
|
|
app.library.upsert(rootHost)
|
|
app.library.upsert(childHost)
|
|
app.library.upsert(ungroupedHost)
|
|
|
|
let expanded = HostLibrarySectionLayout.sections(library: app.library)
|
|
#expect(
|
|
expanded.map(\.id)
|
|
== [.group(root.id), .group(child.id), .group(empty.id), .ungrouped])
|
|
#expect(expanded.map(\.title) == ["Production", "Production / Edge", "Empty", "Ungrouped"])
|
|
#expect(expanded.first(where: { $0.id == .group(empty.id) })?.hosts.isEmpty == true)
|
|
|
|
app.library.setExpanded(false, for: root.id)
|
|
let collapsed = HostLibrarySectionLayout.sections(library: app.library)
|
|
#expect(collapsed.map(\.id) == [.group(root.id), .group(empty.id), .ungrouped])
|
|
#expect(app.library.searchHosts("Child Node").map(\.id) == [childHost.id])
|
|
|
|
app.library.setUngroupedExpanded(false)
|
|
#expect(
|
|
HostLibrarySectionLayout.sections(library: app.library)
|
|
.first(where: { $0.id == .ungrouped })?.isExpanded == false)
|
|
|
|
let stored = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(stored.groups.first(where: { $0.id == root.id })?.isExpanded == true)
|
|
}
|
|
|
|
@Test func distributionBrandColorsAreValidAndReadableOnHostIconSurfaces() throws {
|
|
#expect(LinuxDistributionBrandColor.color(hex: "not-a-color", colorScheme: .light) == nil)
|
|
|
|
for scheme in [ColorScheme.light, .dark] {
|
|
let color = try #require(LinuxDistributionBrandColor.color(hex: "000000", colorScheme: scheme))
|
|
let traits = UITraitCollection(userInterfaceStyle: scheme == .dark ? .dark : .light)
|
|
let foreground = try components(UIColor(color))
|
|
let surface = try components(
|
|
UIColor.secondarySystemGroupedBackground.resolvedColor(with: traits))
|
|
let tintedSurface = mix(surface, foreground, amount: 0.16)
|
|
#expect(contrastRatio(foreground, tintedSurface) >= 3)
|
|
}
|
|
}
|
|
|
|
@Test func openingSavedHostsRecordsSSHAndSFTPForTheCardTimestamp() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
|
|
let ssh = try #require(app.open(hostID: host.id, kind: .ssh))
|
|
let sftp = try #require(app.open(hostID: host.id, kind: .sftp))
|
|
|
|
#expect(Set(app.connectionHistory.entries(for: host.id).map(\.kind)) == [.ssh, .sftp])
|
|
#expect(
|
|
app.connectionHistory.entries(for: host.id).allSatisfy {
|
|
app.devices.name(for: $0.sourceDeviceID)?.isEmpty == false
|
|
})
|
|
#expect(app.connectionHistory.entries(for: host.id).allSatisfy { $0.endedAt == nil })
|
|
#expect(app.lastConnectedAt(for: host.id) != nil)
|
|
let restored = ConnectionHistoryModel(
|
|
repository: SwiftDataConnectionHistoryRepository(context: ModelContext(app.container)))
|
|
#expect(Set(restored.entries(for: host.id).map(\.kind)) == [.ssh, .sftp])
|
|
|
|
app.close(ssh)
|
|
app.close(sftp)
|
|
#expect(app.connectionHistory.entries(for: host.id).allSatisfy { $0.endedAt != nil })
|
|
}
|
|
|
|
@Test func hostAndIdentityColorsRemainDraftsUntilSavedAndRoundTripThroughPersistence() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
var host = TermindDomain.Host(name: "Test", host: "example.invalid", colorTag: .teal)
|
|
var identity = Identity(name: "Deploy", username: "deploy", colorTag: .purple)
|
|
app.library.upsert(host)
|
|
#expect(app.library.upsert(identity))
|
|
host.colorTag = .pink
|
|
identity.colorTag = .orange
|
|
#expect(app.library.host(host.id)?.colorTag == .teal)
|
|
#expect(app.credentials.identities.first?.colorTag == .purple)
|
|
app.library.upsert(host)
|
|
#expect(app.library.upsert(identity))
|
|
let saved = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(saved.hosts.first?.colorTag == .pink)
|
|
#expect(saved.identities.first?.colorTag == .orange)
|
|
host.colorTag = .none
|
|
identity.colorTag = .none
|
|
app.library.upsert(host)
|
|
#expect(app.library.upsert(identity))
|
|
let cleared = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(cleared.hosts.first?.colorTag == TagColor.none)
|
|
#expect(cleared.identities.first?.colorTag == TagColor.none)
|
|
}
|
|
|
|
@Test func generatedImportedAndEditedKeysPreserveColorWithoutSavingCancelledDrafts() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
generator.label = "Colored Key"
|
|
generator.colorTag = .green
|
|
#expect(generator.isDirty)
|
|
try await generator.save()
|
|
let key = try #require(app.credentials.keys.first)
|
|
#expect(key.colorTag == .green)
|
|
let credential = try #require(try app.credentials.keyCredential(for: key))
|
|
guard case .software(let secret) = credential else {
|
|
Issue.record("Expected software key")
|
|
return
|
|
}
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
importer.label = "Imported Color"
|
|
importer.privateKey = secret.privateKey
|
|
importer.colorTag = .blue
|
|
try await importer.save()
|
|
#expect(app.credentials.keys.first(where: { $0.label == "Imported Color" })?.colorTag == .blue)
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.colorTag == .green && !editor.isDirty)
|
|
editor.colorTag = .red
|
|
#expect(editor.isDirty)
|
|
#expect(app.credentials.key(key.id)?.colorTag == .green)
|
|
editor.colorTag = .none
|
|
try await editor.save()
|
|
let updated = try #require(app.credentials.key(key.id))
|
|
let reopened = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated)
|
|
await reopened.loadPrivateKey()
|
|
#expect(reopened.colorTag == .none && !reopened.isDirty)
|
|
#expect(updated.fingerprint == key.fingerprint)
|
|
}
|
|
|
|
private func standardCard(tags: [String]) -> some View {
|
|
MobileCard { standardCardContent(tags: tags) }
|
|
}
|
|
|
|
private func standardActionCard(tags: [String]) -> some View {
|
|
MobileActionCard(
|
|
action: {},
|
|
content: { standardCardContent(tags: tags) },
|
|
actions: { EmptyView() }
|
|
)
|
|
}
|
|
|
|
private func standardCardContent(tags: [String]) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
MobileCardIcon(symbol: "server.rack")
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Production").font(.headline).lineLimit(1)
|
|
Text("deploy@example.invalid").font(.caption.monospaced()).lineLimit(1)
|
|
}
|
|
}
|
|
HStack(spacing: 6) {
|
|
ForEach(MobileCardLayout.visibleTags(tags), id: \.self) { MobileCardChip(text: $0) }
|
|
Spacer(minLength: 6)
|
|
Text("1 hr ago").font(.caption2).lineLimit(1).fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func measuredHeight<V: View>(of view: V, width: CGFloat = 320) -> CGFloat {
|
|
let controller = UIHostingController(rootView: view.environment(\.dynamicTypeSize, .large))
|
|
return controller.sizeThatFits(in: CGSize(width: width, height: 1_000)).height
|
|
}
|
|
|
|
private func components(_ color: UIColor) throws -> (CGFloat, CGFloat, CGFloat) {
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
#expect(color.getRed(&red, green: &green, blue: &blue, alpha: nil))
|
|
return (red, green, blue)
|
|
}
|
|
|
|
private func mix(
|
|
_ lhs: (CGFloat, CGFloat, CGFloat), _ rhs: (CGFloat, CGFloat, CGFloat), amount: CGFloat
|
|
) -> (CGFloat, CGFloat, CGFloat) {
|
|
(
|
|
lhs.0 + (rhs.0 - lhs.0) * amount,
|
|
lhs.1 + (rhs.1 - lhs.1) * amount,
|
|
lhs.2 + (rhs.2 - lhs.2) * amount
|
|
)
|
|
}
|
|
|
|
private func contrastRatio(
|
|
_ lhs: (CGFloat, CGFloat, CGFloat), _ rhs: (CGFloat, CGFloat, CGFloat)
|
|
) -> CGFloat {
|
|
let lighter = max(luminance(lhs), luminance(rhs))
|
|
let darker = min(luminance(lhs), luminance(rhs))
|
|
return (lighter + 0.05) / (darker + 0.05)
|
|
}
|
|
|
|
private func luminance(_ color: (CGFloat, CGFloat, CGFloat)) -> CGFloat {
|
|
func linear(_ value: CGFloat) -> CGFloat {
|
|
value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
|
}
|
|
return 0.2126 * linear(color.0) + 0.7152 * linear(color.1) + 0.0722 * linear(color.2)
|
|
}
|
|
}
|