Files
Termind/TFMTests/TerminalBackgroundTests.swift

153 lines
7.5 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite(.serialized) @MainActor
struct TerminalBackgroundTests {
@Test func mountedBackgroundFollowsThemeCustomConfigurationAndAppearance() async throws {
let state = TerminalViewState(configSource: .none)
state.applyPreferences(.init())
let host = NSHostingView(rootView: TerminalBackground(state: state).frame(width: 80, height: 80))
host.frame = CGRect(x: 0, y: 0, width: 80, height: 80)
try await expectPixel(in: host, x: 40, y: 40, color: state.backgroundColor)
state.applyPreferences(
.init(
darkCustomEnabled: true, lightCustomEnabled: true,
darkCustomConfiguration: "background = 445566", lightCustomConfiguration: "background = 112233"))
let light = TerminalColor(red: 0x11, green: 0x22, blue: 0x33)
#expect(state.backgroundColor == light)
try await expectPixel(in: host, x: 40, y: 40, color: light)
state.adopt(terminalColorScheme: .dark)
let dark = TerminalColor(red: 0x44, green: 0x55, blue: 0x66)
#expect(state.backgroundColor == dark)
try await expectPixel(in: host, x: 40, y: 40, color: dark)
state.terminalDidChangeColor(.init(kind: .foreground, color: light))
try await expectPixel(in: host, x: 40, y: 40, color: dark)
}
@Test func oscBackgroundAndResetUpdateOnlyTheirOwnSplitHeaderWithoutReplacingSurfaces() async throws {
let firstHost = TermindDomain.Host(name: "First", host: "first.example.invalid")
let secondHost = TermindDomain.Host(name: "Second", host: "second.example.invalid")
let workspace = Workspace.sshSession(host: firstHost)
let first = try #require(workspace.focusedPane.sshSession)
let peer = Pane.sshSession(host: secondHost)
let second = try #require(peer.sshSession)
workspace.splitFocusedPane(axis: .horizontal, with: peer)
first.status = .connected
second.status = .connected
let app = AppModel(hosts: HostLibraryModel(hosts: [firstHost, secondHost]))
first.terminalState.applyPreferences(app.terminalSettings.preferences)
second.terminalState.applyPreferences(app.terminalSettings.preferences)
let navigation = WindowModel(workspaces: [workspace])
defer { app.shutdownForTermination() }
let host = NSHostingView(
rootView: WorkspaceContentView(workspace: workspace, isActive: true)
.environment(app).environment(navigation).environment(\.colorScheme, .light)
.frame(width: 800, height: 400))
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 800, height: 400),
styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = host
defer {
window.contentView = nil
window.close()
}
host.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(150))
let firstSurface = try #require(first.terminalState.surface)
let secondSurface = try #require(second.terminalState.surface)
let firstSize = first.terminalState.surfaceSize
let firstController = first.terminalState.controller
let initial = first.terminalState.backgroundColor
let peerColor = second.terminalState.backgroundColor
guard case .inMemory(let backend) = first.terminalState.configuration.backend else {
Issue.record("Expected an in-memory terminal")
return
}
backend.receive("\u{1B}]11;#123456\u{07}")
backend.waitForPendingOutput()
let changed = TerminalColor(red: 0x12, green: 0x34, blue: 0x56)
try await expectPixel(in: host, x: 200, y: 10, color: changed)
#expect(first.terminalState.backgroundColor == changed)
try await expectPixel(in: host, x: 600, y: 10, color: peerColor)
#expect(second.terminalState.backgroundColor == peerColor)
backend.receive("\u{1B}]111\u{07}")
backend.waitForPendingOutput()
try await expectPixel(in: host, x: 200, y: 10, color: initial)
#expect(first.terminalState.backgroundColor == initial)
#expect(first.terminalState.surface === firstSurface)
#expect(second.terminalState.surface === secondSurface)
#expect(first.terminalState.controller === firstController)
#expect(first.terminalState.surfaceSize == firstSize)
}
private func expectPixel(in view: NSView, x: Int, y: Int, color: TerminalColor) async throws {
// Keep the same hosted view mounted across updates: a fresh ImageRenderer
// would read the new value even if the view forgot to subscribe to it.
let reference = NSHostingView(
rootView: Color(
.sRGB, red: Double(color.red) / 255, green: Double(color.green) / 255,
blue: Double(color.blue) / 255, opacity: 1
).frame(width: 20, height: 20))
reference.frame = CGRect(x: 0, y: 0, width: 20, height: 20)
var referenceWindow: NSWindow?
if let window = view.window {
let sample = NSWindow(
contentRect: reference.frame, styleMask: [.borderless], backing: .buffered, defer: false)
sample.isReleasedWhenClosed = false
sample.colorSpace = window.colorSpace
sample.appearance = window.effectiveAppearance
sample.contentView = reference
referenceWindow = sample
}
defer {
referenceWindow?.contentView = nil
referenceWindow?.close()
}
// Compare to a static swatch drawn through the same display color space;
// cacheDisplay's calibrated RGB tagging varies between windowed and detached views.
// SwiftUI and Ghostty publish on separate turns. Wait for the mounted view
// to render the color instead of assuming the whole test host is idle in 100 ms.
for attempt in 0..<100 {
try await Task.sleep(for: .milliseconds(20))
let bitmap = try snapshot(view)
let expected = try #require(try snapshot(reference).colorAt(x: 10, y: 10)?.usingColorSpace(.sRGB))
let scaleX = Double(bitmap.pixelsWide) / view.bounds.width
let scaleY = Double(bitmap.pixelsHigh) / view.bounds.height
let actual = try #require(
bitmap.colorAt(x: Int(Double(x) * scaleX), y: Int(Double(y) * scaleY))?.usingColorSpace(.sRGB))
let matches =
abs(actual.redComponent - expected.redComponent) < 0.015
&& abs(actual.greenComponent - expected.greenComponent) < 0.015
&& abs(actual.blueComponent - expected.blueComponent) < 0.015
if matches { return }
if attempt == 99 {
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "terminal-background-\(x)-\(color.red).png")
#expect(matches, "Pixel (\(x), \(y)): \(actual), expected \(color)")
}
}
}
private func snapshot(_ view: NSView) throws -> NSBitmapImageRep {
view.layoutSubtreeIfNeeded()
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
return bitmap
}
}