fix(ui): balance card spacing across platforms
This commit is contained in:
1 parent
6b315ecea4
commit
42b0ecfecf
10 files changed
+689
-55
No files matched your search
@@ -40,19 +40,27 @@ public struct SnippetCardContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
if let packagePath {
|
||||
Label(packagePath, systemImage: "folder")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
// Keep the footer's line height when a search result has no package.
|
||||
Label(" ", systemImage: "folder")
|
||||
.font(.caption)
|
||||
.hidden()
|
||||
.accessibilityHidden(true)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if let packagePath {
|
||||
Label(packagePath, systemImage: "folder")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text(modifiedAt.formatted(.relative(presentation: .numeric).locale(locale)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text(modifiedAt.formatted(.relative(presentation: .numeric).locale(locale)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
struct MobileHostCardContent: View {
|
||||
let host: TermindDomain.Host
|
||||
let address: String
|
||||
let status: ConnectionStatus?
|
||||
let lastConnectedAt: Date?
|
||||
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
HostConnectionIcon(
|
||||
host: host, status: status,
|
||||
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1)
|
||||
Text(address).font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
footer
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
ZStack {
|
||||
// Reserve the chip's actual line height and padding, including Dynamic Type.
|
||||
MobileCardChip(text: " ")
|
||||
.hidden()
|
||||
.accessibilityHidden(true)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ForEach(MobileCardLayout.visibleTags(host.tags), id: \.self) { tag in
|
||||
MobileCardChip(text: tag)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text(
|
||||
lastConnectedAt
|
||||
.map { $0.formatted(.relative(presentation: .numeric).locale(locale)) }
|
||||
?? String(localized: AppStrings.Vault.never)
|
||||
)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,42 +766,16 @@ struct MobileVaultView: View {
|
||||
MobileActionCard {
|
||||
activateHost(host.id)
|
||||
} content: {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
HostConnectionIcon(
|
||||
host: host,
|
||||
status: scene?.workspaceActivatedByHostRow(for: host).flatMap {
|
||||
MobileWorkspaceConnectionIndicator.resolve(
|
||||
connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil
|
||||
)?.status
|
||||
},
|
||||
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1)
|
||||
Text(app.library.preferredTerminalLoginAddress(for: host)).font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(MobileCardLayout.visibleTags(host.tags), id: \.self) { tag in
|
||||
MobileCardChip(text: tag)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text(
|
||||
app.lastConnectedAt(for: host.id)
|
||||
.map { $0.formatted(.relative(presentation: .numeric).locale(locale)) }
|
||||
?? String(localized: AppStrings.Vault.never)
|
||||
)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
|
||||
MobileHostCardContent(
|
||||
host: host,
|
||||
address: app.library.preferredTerminalLoginAddress(for: host),
|
||||
status: scene?.workspaceActivatedByHostRow(for: host).flatMap {
|
||||
MobileWorkspaceConnectionIndicator.resolve(
|
||||
connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil
|
||||
)?.status
|
||||
},
|
||||
lastConnectedAt: app.lastConnectedAt(for: host.id)
|
||||
)
|
||||
} actions: {
|
||||
let sections = MobileHostMenuPolicy.sections(
|
||||
for: host, hasTailscalePeer: app.tailscale.peer(for: host) != nil)
|
||||
|
||||
@@ -115,7 +115,7 @@ struct MobileCard<Content: View>: View {
|
||||
var body: some View {
|
||||
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
|
||||
.padding(16)
|
||||
.frame(minHeight: MobileCardLayout.minimumHeight, alignment: .topLeading)
|
||||
.frame(minHeight: MobileCardLayout.minimumHeight, alignment: .leading)
|
||||
.mobileCardSurface()
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ struct MobileActionCard<Content: View, Actions: View>: View {
|
||||
@ViewBuilder var actions: Actions
|
||||
|
||||
init(
|
||||
alignment: Alignment = .topLeading,
|
||||
alignment: Alignment = .leading,
|
||||
action: @escaping () -> Void,
|
||||
@ViewBuilder content: () -> Content,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct MobileCardLayoutTests {
|
||||
@Test(arguments: [20.0, 74.0, 120.0], [320.0, 600.0])
|
||||
func bothCardSurfacesBalanceExtraHeight(contentHeight: CGFloat, width: CGFloat) async throws {
|
||||
let content = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1).frame(height: contentHeight)
|
||||
let cards = [
|
||||
AnyView(MobileCard { content }),
|
||||
AnyView(MobileActionCard(action: {}, content: { content }, actions: { EmptyView() })),
|
||||
]
|
||||
for card in cards {
|
||||
let image = try await render(card, width: width)
|
||||
let bytes = try pixels(image)
|
||||
let rows = (0..<image.height).filter { y in
|
||||
let offset = (y * image.width + image.width / 2) * 4
|
||||
return bytes[offset] > 230 && bytes[offset + 1] < 25 && bytes[offset + 2] < 25
|
||||
}
|
||||
let first = try #require(rows.first)
|
||||
let last = try #require(rows.last)
|
||||
let topInset = CGFloat(first) / 2
|
||||
let bottomInset = CGFloat(image.height - last - 1) / 2
|
||||
#expect(abs(topInset - bottomInset) <= 0.5)
|
||||
#expect(topInset >= 16 && bottomInset >= 16)
|
||||
// Glass antialiasing can soften one device pixel at either content edge.
|
||||
#expect(abs(CGFloat(rows.count) / 2 - contentHeight) <= 1)
|
||||
#expect(CGFloat(image.height) / 2 == max(MobileCardLayout.minimumHeight, contentHeight + 32))
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0])
|
||||
func tagCountKeepsHostContentAlignedIncludingLargeText(language: String, width: CGFloat) async throws {
|
||||
for typeSize in [DynamicTypeSize.large, .xxxLarge, .accessibility3] {
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
var images: [CGImage] = []
|
||||
for tagCount in 0...2 {
|
||||
images.append(
|
||||
try await render(
|
||||
card(tagCount: tagCount)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.dynamicTypeSize, typeSize)
|
||||
.environment(\.colorScheme, scheme),
|
||||
width: width
|
||||
)
|
||||
)
|
||||
}
|
||||
let reference = try #require(images.last)
|
||||
let header = CGRect(x: 0, y: 0, width: width, height: 58)
|
||||
let timestamp = CGRect(
|
||||
x: width - 75, y: 58, width: 75, height: CGFloat(reference.height) / 2 - 58)
|
||||
let expectedHeader = try pixels(reference, in: header)
|
||||
let expectedTimestamp = try pixels(reference, in: timestamp)
|
||||
for image in images.dropLast() {
|
||||
#expect(image.width == reference.width && image.height == reference.height)
|
||||
let headerMatches = try pixels(image, in: header) == expectedHeader
|
||||
let timestampMatches = try pixels(image, in: timestamp) == expectedTimestamp
|
||||
#expect(headerMatches, "Removing tags must not move the host header.")
|
||||
#expect(timestampMatches, "Removing tags must not move the timestamp.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0])
|
||||
func snippetFolderDoesNotMoveTheHeader(language: String, width: CGFloat) async throws {
|
||||
let snippet = Snippet(title: "Production database maintenance", body: "sudo systemctl restart postgresql")
|
||||
let date = Date().addingTimeInterval(-86_400)
|
||||
var images: [CGImage] = []
|
||||
for path in [nil, "Operations / Production / Database"] {
|
||||
images.append(
|
||||
try await render(
|
||||
MobileActionCard(
|
||||
action: {},
|
||||
content: {
|
||||
SnippetCardContent(
|
||||
snippet: snippet, validTargetCount: 12, packagePath: path, modifiedAt: date)
|
||||
}, actions: { EmptyView() }
|
||||
)
|
||||
.environment(\.locale, Locale(identifier: language)),
|
||||
width: width))
|
||||
}
|
||||
let empty = try #require(images.first)
|
||||
let folder = try #require(images.last)
|
||||
#expect(empty.height == folder.height)
|
||||
let header = CGRect(x: 0, y: 0, width: width, height: 58)
|
||||
let emptyHeader = try pixels(empty, in: header)
|
||||
let folderHeader = try pixels(folder, in: header)
|
||||
let headersMatch = emptyHeader == folderHeader
|
||||
#expect(headersMatch)
|
||||
if !headersMatch {
|
||||
for (index, image) in images.enumerated() {
|
||||
let png = try #require(UIImage(cgImage: image, scale: 2, orientation: .up).pngData())
|
||||
Attachment.record(png, named: "snippet-folder-\(language)-\(Int(width))-\(index).png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0])
|
||||
func exportHostCardReviewWhenRequested(language: String, width: CGFloat) async throws {
|
||||
guard ProcessInfo.processInfo.environment["TERMIND_HOST_CARD_SCREENSHOTS"] == "1" else { return }
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let image = try await render(
|
||||
VStack(spacing: 16) {
|
||||
ForEach(0...2, id: \.self) { count in
|
||||
card(tagCount: count)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, scheme),
|
||||
width: width + 32
|
||||
)
|
||||
let data = try #require(UIImage(cgImage: image, scale: 2, orientation: .up).pngData())
|
||||
Attachment.record(data, named: "host-cards-ios-\(language)-\(Int(width))-\(scheme).png")
|
||||
}
|
||||
}
|
||||
|
||||
private func card(tagCount: Int) -> some View {
|
||||
var host = TermindDomain.Host(name: "THome", host: "thome-router.internal.endpoint.example.invalid")
|
||||
host.tags = Array(["router", "home"].prefix(tagCount))
|
||||
return MobileActionCard(
|
||||
action: {},
|
||||
content: {
|
||||
MobileHostCardContent(
|
||||
host: host, address: host.host, status: nil,
|
||||
lastConnectedAt: Date().addingTimeInterval(-4 * 24 * 60 * 60))
|
||||
},
|
||||
actions: { EmptyView() }
|
||||
)
|
||||
}
|
||||
|
||||
func render(_ content: some View, width: CGFloat) async throws -> CGImage {
|
||||
let controller = UIHostingController(rootView: content.frame(width: width))
|
||||
controller.safeAreaRegions = []
|
||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: 10_000))
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
// Native glass requires a window. Keep this one offscreen and non-key so
|
||||
// default layout tests never take over presentation or keyboard focus.
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.frame = CGRect(origin: CGPoint(x: -10_000, y: 0), size: size)
|
||||
window.rootViewController = controller
|
||||
window.isHidden = false
|
||||
defer {
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
}
|
||||
window.layoutIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 2
|
||||
let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in
|
||||
controller.view.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true)
|
||||
}
|
||||
return try #require(image.cgImage)
|
||||
}
|
||||
|
||||
private func pixels(_ image: CGImage, in rect: CGRect? = nil) throws -> [UInt8] {
|
||||
let crop: CGImage
|
||||
if let rect {
|
||||
crop = try #require(image.cropping(to: rect.applying(CGAffineTransform(scaleX: 2, y: 2))))
|
||||
} else {
|
||||
crop = image
|
||||
}
|
||||
var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4)
|
||||
try bytes.withUnsafeMutableBytes { buffer in
|
||||
let context = try #require(
|
||||
CGContext(
|
||||
data: buffer.baseAddress, width: crop.width, height: crop.height,
|
||||
bitsPerComponent: 8, bytesPerRow: crop.width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
)
|
||||
context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
/// Opt-in coverage of every non-host mobile card consumer, using only in-memory records.
|
||||
@Suite(
|
||||
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_CARD_LAYOUT_REVIEW"] == "1"),
|
||||
.serialized)
|
||||
@MainActor
|
||||
struct MobileCardPageLayoutReviewTests {
|
||||
@Test(arguments: ["en", "zh-Hans"], [352.0, 800.0])
|
||||
func allMobileCardPages(language: String, width: CGFloat) async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = TermindDomain.Host(name: "Production gateway", host: "gateway.production.example.invalid")
|
||||
app.library.upsert(host)
|
||||
let key = SSHKeyRef(label: "Deployment", algorithm: .ed25519, credentialRef: "review-key")
|
||||
try app.credentials.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: "rendering-fixture")))
|
||||
try app.credentials.upsert(
|
||||
SSHKeyRef(
|
||||
label: "Long production private key", algorithm: .rsa, credentialRef: "review-missing",
|
||||
storage: .iCloud, keySize: 4096, hasPassphrase: true))
|
||||
app.library.upsert(Identity(name: "Deployment", username: "deploy", keyID: key.id))
|
||||
app.library.upsert(Identity(name: "Missing key", username: "missing", keyID: UUID()))
|
||||
app.library.upsert(Identity(name: "No secret", username: "unfinished"))
|
||||
#expect(
|
||||
app.knownHosts.save(
|
||||
KnownHostEntry(
|
||||
hostname: host.host, algorithm: .ed25519, fingerprint: "SHA256:fixture",
|
||||
addedAt: .now.addingTimeInterval(-86_400))))
|
||||
#expect(
|
||||
app.knownHosts.save(
|
||||
KnownHostEntry(
|
||||
hostname: "long-address.internal.production.example.invalid", algorithm: .rsa,
|
||||
fingerprint: "SHA256:fixture-rsa", addedAt: .now.addingTimeInterval(-604_800))))
|
||||
let package = SnippetPackage(name: "Operations / Production / Database")
|
||||
app.snippets.upsert(package)
|
||||
app.snippets.upsert(Snippet(title: "Inspect disk usage", body: "df -h"))
|
||||
app.snippets.upsert(
|
||||
Snippet(
|
||||
title: "Restart production database", body: "sudo systemctl restart postgresql", packageID: package.id))
|
||||
app.snippets.upsert(
|
||||
Snippet(title: "Remove old database files", body: "rm -rf /var/lib/old-data", packageID: package.id))
|
||||
for kind in PortForwardingRule.Kind.allCases {
|
||||
try app.forwardingLibrary.save(
|
||||
PortForwardingRule(
|
||||
hostID: host.id, kind: kind, targetHost: "database.production.example.invalid", targetPort: 5432))
|
||||
}
|
||||
for state in [SFTPTransfer.State.running, .completed, .failed("Connection interrupted")] {
|
||||
app.library.enqueueSFTPTransfer(
|
||||
SFTPTransfer(
|
||||
hostID: host.id, localPath: "/tmp/production-database-backup.tar.gz",
|
||||
remotePath: "/srv/backups/production-database-backup.tar.gz", byteCount: 100_000,
|
||||
transferredBytes: 42_000, state: state))
|
||||
}
|
||||
_ = app.metrics.add(host.id, pro: true)
|
||||
let server = try #require(app.metrics.servers.first)
|
||||
app.metrics.core.setEnabled(false, for: server)
|
||||
server.isConnecting = false
|
||||
server.status = ""
|
||||
server.point = MetricsPoint(
|
||||
date: .now, cpu: 42, memory: 67, diskRead: 125_000, diskWrite: 8_000,
|
||||
networkReceive: 400_000, networkSend: 9_000)
|
||||
defer { app.metrics.pause() }
|
||||
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let pages: [(String, AnyView)] = [
|
||||
("known-hosts", AnyView(MobileKnownHostsView(app: app))),
|
||||
("keys-identities", AnyView(MobileCredentialsView(app: app))),
|
||||
("port-forwarding", AnyView(MobileForwardingView(app: app))),
|
||||
("snippets", AnyView(MobileSnippetsView(app: app))),
|
||||
("metrics", AnyView(MobileMetricsView(app: app))),
|
||||
("themes", AnyView(MobileTerminalThemeView(model: app.terminalSettings, isDark: scheme == .dark))),
|
||||
("transfers", AnyView(MobileTransfersView(transfers: app.transfers, hosts: app.library))),
|
||||
]
|
||||
for (name, page) in pages {
|
||||
let image = try await MobileCardLayoutTests().render(
|
||||
page
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, scheme)
|
||||
.preferredColorScheme(scheme)
|
||||
.frame(width: width, height: 900),
|
||||
width: width)
|
||||
let png = try #require(UIImage(cgImage: image, scale: 2, orientation: .up).pngData())
|
||||
Attachment.record(png, named: "cards-ios-\(name)-\(language)-\(Int(width))-\(scheme).png")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,7 +470,7 @@ struct HostsPagePresentation: ManagedCardPresentationModifier {
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
private struct HostCard: View {
|
||||
struct HostCard: View {
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
let host: Host
|
||||
@@ -555,6 +555,8 @@ private struct HostCard: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
// Match CardChip's vertical padding even when there are no tags.
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,11 +568,9 @@ struct CardTile<Content: View>: View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Metrics.Space.l)
|
||||
// A floor rather than a height: the two-row card this page is built
|
||||
// around already measures 80pt, so this changes nothing today. It is
|
||||
// here so a card that loses a row — a host with no tags, a future
|
||||
// one-line tile — still sits level with its neighbours.
|
||||
.frame(minHeight: Metrics.Card.minHeight, alignment: .top)
|
||||
// Keep short cards level with their neighbours and split any extra
|
||||
// height evenly above and below the content.
|
||||
.frame(minHeight: Metrics.Card.minHeight, alignment: .center)
|
||||
.background(
|
||||
isHovering && !isSelected ? Color.primary.opacity(0.035) : .clear,
|
||||
in: .rect(cornerRadius: Metrics.Radius.large)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
/// Actual card consumers, not copies of their layouts. Never opens a connection.
|
||||
@Suite(
|
||||
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_CARD_LAYOUT_REVIEW"] == "1"),
|
||||
.serialized)
|
||||
@MainActor
|
||||
struct CardPageLayoutReviewTests {
|
||||
@Test(.requiresVisibleWindow, arguments: ["en", "zh-Hans"], [320.0, 800.0])
|
||||
func allManagedCardPages(language: String, width: CGFloat) async throws {
|
||||
NSApp.unhideWithoutActivation()
|
||||
let host = TermindDomain.Host(name: "Production gateway", host: "gateway.production.example.invalid")
|
||||
let key = SSHKeyRef(label: "Deployment", algorithm: .ed25519, credentialRef: "review-key")
|
||||
let locked = SSHKeyRef(
|
||||
label: "Long production private key", algorithm: .rsa, credentialRef: "review-missing",
|
||||
storage: .iCloud, keySize: 4096, hasPassphrase: true)
|
||||
let package = SnippetPackage(name: "Operations / Production / Database")
|
||||
let library = HostLibraryModel(
|
||||
hosts: [host], keys: [key, locked],
|
||||
identities: [
|
||||
Identity(name: "Deployment", username: "deploy", keyID: key.id),
|
||||
Identity(name: "Missing key", username: "missing", keyID: UUID()),
|
||||
Identity(name: "No secret", username: "unfinished"),
|
||||
],
|
||||
snippets: [
|
||||
Snippet(title: "Inspect disk usage", body: "df -h"),
|
||||
Snippet(
|
||||
title: "Restart production database", body: "sudo systemctl restart postgresql",
|
||||
packageID: package.id),
|
||||
Snippet(title: "Remove old database files", body: "rm -rf /var/lib/old-data", packageID: package.id),
|
||||
],
|
||||
snippetPackages: [package],
|
||||
portForwardings: PortForwardingRule.Kind.allCases.map {
|
||||
PortForwardingRule(
|
||||
hostID: host.id, kind: $0, targetHost: "database.production.example.invalid", targetPort: 5432)
|
||||
},
|
||||
knownHosts: [
|
||||
KnownHostEntry(hostname: host.host, algorithm: .ed25519, addedAt: .now.addingTimeInterval(-86_400)),
|
||||
KnownHostEntry(
|
||||
hostname: "long-address.internal.production.example.invalid", algorithm: .rsa,
|
||||
addedAt: .now.addingTimeInterval(-604_800)),
|
||||
])
|
||||
try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: "rendering-fixture")))
|
||||
let app = AppModel(hosts: library)
|
||||
defer { app.shutdownForTermination() }
|
||||
let navigation = WindowModel()
|
||||
navigation.terminalThemeQueries[.light] = "Solarized"
|
||||
navigation.terminalThemeQueries[.dark] = "Solarized"
|
||||
let server = ServerMetric(selection: MetricsSelection(id: host.id), name: host.name)
|
||||
server.isConnecting = false
|
||||
server.status = ""
|
||||
server.point = MetricsPoint(
|
||||
date: .now, cpu: 42, memory: 67, diskRead: 125_000, diskWrite: 8_000,
|
||||
networkReceive: 400_000, networkSend: 9_000)
|
||||
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let pages: [(String, AnyView)] = [
|
||||
(
|
||||
"hosts",
|
||||
AnyView(
|
||||
CardPage {
|
||||
CardGrid {
|
||||
ForEach(0...2, id: \.self) { count in
|
||||
hostCard(host, tagCount: count)
|
||||
}
|
||||
}
|
||||
})
|
||||
),
|
||||
("known-hosts", AnyView(KnownHostsPageView(state: KnownHostsPageState()))),
|
||||
("keys-identities", AnyView(KeychainPageView())),
|
||||
("port-forwarding", AnyView(PortForwardingPageView(state: PortForwardingPageState()))),
|
||||
("snippets", AnyView(SnippetsPageView())),
|
||||
("metrics", AnyView(CardPage { ServerMetricsCard(server: server, model: app.serverMetrics) })),
|
||||
(
|
||||
"themes",
|
||||
AnyView(TerminalThemePage(scheme: scheme == .dark ? .dark : .light, editConfiguration: {}))
|
||||
),
|
||||
]
|
||||
for (name, page) in pages {
|
||||
let content = page.environment(app).environment(navigation).environment(VaultWorkEditingSession())
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, scheme)
|
||||
.frame(width: width, height: 760)
|
||||
let controller = NSHostingController(rootView: content)
|
||||
controller.sizingOptions = []
|
||||
let window = NSWindow(
|
||||
contentRect: CGRect(x: 0, y: 0, width: width, height: 760),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua)
|
||||
window.contentViewController = controller
|
||||
window.setContentSize(NSSize(width: width, height: 760))
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: width, height: 760)
|
||||
// Native glass needs an unoccluded window, but not keyboard focus.
|
||||
window.orderFrontRegardless()
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
}
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
#expect(window.contentView?.bounds.size == CGSize(width: width, height: 760))
|
||||
window.title = "Termind Card Review | cards-macos-\(name)-\(language)-\(Int(width))-\(scheme)"
|
||||
// cacheDisplay omits native glass compositing. A screenshot runner
|
||||
// captures this window number with screencapture -l instead.
|
||||
print(
|
||||
"TERMIND_CARD_LAYOUT_WINDOW|\(window.windowNumber)|cards-macos-\(name)-\(language)-\(Int(width))-\(scheme)"
|
||||
)
|
||||
fflush(nil)
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
#expect(!window.isKeyWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hostCard(_ host: TermindDomain.Host, tagCount: Int) -> some View {
|
||||
var host = host
|
||||
host.tags = Array(["router", "home"].prefix(tagCount))
|
||||
return HostCard(
|
||||
host: host, isSelected: false, select: {}, extendSelection: {},
|
||||
actionHosts: [host], requestDelete: {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite @MainActor
|
||||
struct CardTileLayoutTests {
|
||||
@Test(arguments: [20.0, 56.0, 100.0], [240.0, 380.0])
|
||||
func minimumHeightKeepsContentCenteredWithoutClipping(contentHeight: CGFloat, width: CGFloat) throws {
|
||||
let image = try render(
|
||||
CardTile {
|
||||
Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1)
|
||||
.frame(height: contentHeight)
|
||||
}
|
||||
.frame(width: width)
|
||||
)
|
||||
let bitmap = NSBitmapImageRep(cgImage: image)
|
||||
let contentRows = (0..<bitmap.pixelsHigh).filter { y in
|
||||
guard let color = bitmap.colorAt(x: bitmap.pixelsWide / 2, y: y)?.usingColorSpace(.sRGB) else {
|
||||
return false
|
||||
}
|
||||
return color.redComponent > 0.9 && color.greenComponent < 0.1 && color.blueComponent < 0.1
|
||||
}
|
||||
let firstRow = try #require(contentRows.first)
|
||||
let lastRow = try #require(contentRows.last)
|
||||
let topInset = CGFloat(firstRow) / 2
|
||||
let bottomInset = CGFloat(bitmap.pixelsHigh - lastRow - 1) / 2
|
||||
|
||||
#expect(abs(topInset - bottomInset) <= 0.5)
|
||||
#expect(topInset >= Metrics.Space.l && bottomInset >= Metrics.Space.l)
|
||||
#expect(CGFloat(contentRows.count) / 2 == contentHeight)
|
||||
#expect(CGFloat(image.width) / 2 == width)
|
||||
#expect(CGFloat(image.height) / 2 == max(Metrics.Card.minHeight, contentHeight + 2 * Metrics.Space.l))
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [240.0, 380.0])
|
||||
func tagCountDoesNotMoveTheHeaderOrTimestamp(language: String, width: CGFloat) throws {
|
||||
for connected in [false, true] {
|
||||
let fixture = HostCardFixture(connected: connected)
|
||||
defer { fixture.app.shutdownForTermination() }
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let images = try (0...2).map { tagCount in
|
||||
try render(
|
||||
fixture.card(tagCount: tagCount)
|
||||
.frame(width: width)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, scheme)
|
||||
)
|
||||
}
|
||||
let reference = try #require(images.last)
|
||||
let headerHeight = Metrics.Space.l + Metrics.Card.icon + Metrics.Space.xs
|
||||
let header = CGRect(x: 0, y: 0, width: width, height: headerHeight)
|
||||
let timestamp = CGRect(
|
||||
x: width - 90, y: headerHeight, width: 90,
|
||||
height: CGFloat(reference.height) / 2 - headerHeight)
|
||||
let expectedHeader = try pixels(reference, in: header)
|
||||
let expectedTimestamp = try pixels(reference, in: timestamp)
|
||||
|
||||
// Compare actual rendering across different tag counts, including text baselines.
|
||||
for image in images.dropLast() {
|
||||
#expect(image.width == reference.width && image.height == reference.height)
|
||||
let headerMatches = try pixels(image, in: header) == expectedHeader
|
||||
let timestampMatches = try pixels(image, in: timestamp) == expectedTimestamp
|
||||
#expect(headerMatches, "The icon, name and address must not move when tags are removed.")
|
||||
#expect(timestampMatches, "The timestamp must not move when tags are removed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [240.0, 380.0])
|
||||
func snippetFolderDoesNotMoveTheHeader(language: String, width: CGFloat) throws {
|
||||
let snippet = Snippet(title: "Production database maintenance", body: "sudo systemctl restart postgresql")
|
||||
let date = Date().addingTimeInterval(-86_400)
|
||||
let images = try [nil, "Operations / Production / Database"].map { path in
|
||||
try render(
|
||||
CardTile {
|
||||
SnippetCardContent(snippet: snippet, validTargetCount: 12, packagePath: path, modifiedAt: date)
|
||||
}
|
||||
.frame(width: width)
|
||||
.environment(\.locale, Locale(identifier: language)))
|
||||
}
|
||||
let empty = try #require(images.first)
|
||||
let folder = try #require(images.last)
|
||||
#expect(empty.height == folder.height)
|
||||
let header = CGRect(x: 0, y: 0, width: width, height: 44)
|
||||
let emptyHeader = try pixels(empty, in: header)
|
||||
let folderHeader = try pixels(folder, in: header)
|
||||
let headersMatch = emptyHeader == folderHeader
|
||||
#expect(headersMatch)
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [240.0, 380.0])
|
||||
func exportHostCardReviewWhenRequested(language: String, width: CGFloat) throws {
|
||||
guard let directory = ProcessInfo.processInfo.environment["TERMIND_HOST_CARD_SCREENSHOTS"] else { return }
|
||||
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
|
||||
let fixture = HostCardFixture(connected: true)
|
||||
defer { fixture.app.shutdownForTermination() }
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let image = try render(
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(0...2, id: \.self) { tagCount in
|
||||
fixture.card(tagCount: tagCount).frame(width: width)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(TermindColor.windowSurface)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.colorScheme, scheme)
|
||||
)
|
||||
let bitmap = NSBitmapImageRep(cgImage: image)
|
||||
let data = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
let name = "host-cards-\(language)-\(Int(width))-\(scheme).png"
|
||||
try data.write(to: URL(filePath: directory).appending(path: name))
|
||||
Attachment.record(data, named: name)
|
||||
}
|
||||
}
|
||||
|
||||
private func render(_ content: some View) throws -> CGImage {
|
||||
// ImageRenderer omits native glass content; capture an offscreen hosting view instead.
|
||||
let view = NSHostingView(rootView: content)
|
||||
view.frame = CGRect(origin: .zero, size: view.fittingSize)
|
||||
view.layoutSubtreeIfNeeded()
|
||||
let bitmap = try #require(
|
||||
NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(view.bounds.width * 2), pixelsHigh: Int(view.bounds.height * 2),
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)
|
||||
)
|
||||
bitmap.size = view.bounds.size
|
||||
view.cacheDisplay(in: view.bounds, to: bitmap)
|
||||
return try #require(bitmap.cgImage)
|
||||
}
|
||||
|
||||
private func pixels(_ image: CGImage, in rect: CGRect) throws -> [UInt8] {
|
||||
let crop = try #require(image.cropping(to: rect.applying(CGAffineTransform(scaleX: 2, y: 2))))
|
||||
var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4)
|
||||
try bytes.withUnsafeMutableBytes { buffer in
|
||||
let context = try #require(
|
||||
CGContext(
|
||||
data: buffer.baseAddress, width: crop.width, height: crop.height,
|
||||
bitsPerComponent: 8, bytesPerRow: crop.width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
)
|
||||
context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct HostCardFixture {
|
||||
let host: TermindDomain.Host
|
||||
let app: AppModel
|
||||
let window = WindowModel()
|
||||
|
||||
init(connected: Bool) {
|
||||
let host = TermindDomain.Host(name: "THome", host: "thome-router.internal.endpoint.example.invalid")
|
||||
self.host = host
|
||||
let history = ConnectionHistoryModel(
|
||||
entries: connected
|
||||
? [
|
||||
ConnectionHistoryEntry(
|
||||
hostID: host.id, kind: .ssh, connectedAt: Date().addingTimeInterval(-4 * 24 * 60 * 60))
|
||||
] : []
|
||||
)
|
||||
app = AppModel(hosts: HostLibraryModel(hosts: [host]), connectionHistory: history)
|
||||
}
|
||||
|
||||
func card(tagCount: Int) -> some View {
|
||||
var host = host
|
||||
host.tags = Array(["router", "home"].prefix(tagCount))
|
||||
return HostCard(
|
||||
host: host, isSelected: false, select: {}, extendSelection: {},
|
||||
actionHosts: [host], requestDelete: {}
|
||||
)
|
||||
.environment(app)
|
||||
.environment(window)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user