cmux/cmuxTests/GhosttyConfigTests.swift
atani 12e91aa4fe fix: address review feedback for CJK font fallback
- Split Unicode ranges by language to avoid mapping Hangul to Hiragino
  Sans or Kana to Apple SD Gothic Neo. Shared CJK ranges (ideographs,
  symbols, fullwidth forms) use the first CJK language's font, while
  script-specific ranges (Kana, Hangul) only map to their own font.
- Use UUID-based temp file path to prevent race conditions on concurrent
  launches.
- Move fallback injection after ghostty_config_load_recursive_files so
  that config-file includes are already loaded when checking for
  existing font-codepoint-map entries.
- Follow config-file directives when scanning for existing
  font-codepoint-map entries.
- Extract test helper withTempConfig to reduce duplication.
- Add tests for multi-language mappings and config-file includes.
- Replace placeholder issue URL with actual PR link.
2026-03-06 23:16:15 +09:00

1455 lines
52 KiB
Swift

import XCTest
import AppKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class SidebarPathFormatterTests: XCTestCase {
func testShortenedPathReplacesExactHomeDirectory() {
XCTAssertEqual(
SidebarPathFormatter.shortenedPath(
"/Users/example",
homeDirectoryPath: "/Users/example"
),
"~"
)
}
func testShortenedPathReplacesHomeDirectoryPrefix() {
XCTAssertEqual(
SidebarPathFormatter.shortenedPath(
"/Users/example/projects/cmux",
homeDirectoryPath: "/Users/example"
),
"~/projects/cmux"
)
}
func testShortenedPathLeavesExternalPathUnchanged() {
XCTAssertEqual(
SidebarPathFormatter.shortenedPath(
"/tmp/cmux",
homeDirectoryPath: "/Users/example"
),
"/tmp/cmux"
)
}
}
final class GhosttyConfigTests: XCTestCase {
private struct RGB: Equatable {
let red: Int
let green: Int
let blue: Int
}
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
let resolved = GhosttyConfig.resolveThemeName(
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
preferredColorScheme: .light
)
XCTAssertEqual(resolved, "Builtin Solarized Light")
}
func testResolveThemeNamePrefersDarkEntryForPairedTheme() {
let resolved = GhosttyConfig.resolveThemeName(
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
preferredColorScheme: .dark
)
XCTAssertEqual(resolved, "Builtin Solarized Dark")
}
func testThemeNameCandidatesIncludeBuiltinAliasForms() {
let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Light")
XCTAssertEqual(candidates.first, "Builtin Solarized Light")
XCTAssertTrue(candidates.contains("Solarized Light"))
XCTAssertTrue(candidates.contains("iTerm2 Solarized Light"))
}
func testThemeNameCandidatesMapSolarizedDarkToITerm2Alias() {
let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Dark")
XCTAssertTrue(candidates.contains("Solarized Dark"))
XCTAssertTrue(candidates.contains("iTerm2 Solarized Dark"))
}
func testThemeSearchPathsIncludeXDGDataDirsThemes() {
let pathA = "/tmp/cmux-theme-a"
let pathB = "/tmp/cmux-theme-b"
let paths = GhosttyConfig.themeSearchPaths(
forThemeName: "Solarized Light",
environment: ["XDG_DATA_DIRS": "\(pathA):\(pathB)"],
bundleResourceURL: nil
)
XCTAssertTrue(paths.contains("\(pathA)/ghostty/themes/Solarized Light"))
XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light"))
}
func testLoadThemeResolvesPairedThemeValueByColorScheme() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)")
let themesDir = root.appendingPathComponent("themes")
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try """
background = #fdf6e3
foreground = #657b83
""".write(
to: themesDir.appendingPathComponent("Light Theme"),
atomically: true,
encoding: .utf8
)
try """
background = #002b36
foreground = #93a1a1
""".write(
to: themesDir.appendingPathComponent("Dark Theme"),
atomically: true,
encoding: .utf8
)
var lightConfig = GhosttyConfig()
lightConfig.loadTheme(
"light:Light Theme,dark:Dark Theme",
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
bundleResourceURL: nil,
preferredColorScheme: .light
)
XCTAssertEqual(rgb255(lightConfig.backgroundColor), RGB(red: 253, green: 246, blue: 227))
var darkConfig = GhosttyConfig()
darkConfig.loadTheme(
"light:Light Theme,dark:Dark Theme",
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
bundleResourceURL: nil,
preferredColorScheme: .dark
)
XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54))
}
func testParseBackgroundOpacityReadsConfigValue() {
var config = GhosttyConfig()
config.parse("background-opacity = 0.42")
XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001)
}
func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)")
let themesDir = root.appendingPathComponent("themes")
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let themePath = themesDir.appendingPathComponent("Solarized Light")
let themeContents = """
background = #fdf6e3
foreground = #657b83
"""
try themeContents.write(to: themePath, atomically: true, encoding: .utf8)
var config = GhosttyConfig()
config.loadTheme(
"Builtin Solarized Light",
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
bundleResourceURL: nil
)
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
}
func testLoadCachesPerColorScheme() {
GhosttyConfig.invalidateLoadCache()
defer { GhosttyConfig.invalidateLoadCache() }
var loadCount = 0
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in
loadCount += 1
var config = GhosttyConfig()
config.fontFamily = "\(scheme)-\(loadCount)"
return config
}
let lightFirst = GhosttyConfig.load(
preferredColorScheme: .light,
loadFromDisk: loadFromDisk
)
let lightSecond = GhosttyConfig.load(
preferredColorScheme: .light,
loadFromDisk: loadFromDisk
)
let darkFirst = GhosttyConfig.load(
preferredColorScheme: .dark,
loadFromDisk: loadFromDisk
)
XCTAssertEqual(loadCount, 2)
XCTAssertEqual(lightFirst.fontFamily, "light-1")
XCTAssertEqual(lightSecond.fontFamily, "light-1")
XCTAssertEqual(darkFirst.fontFamily, "dark-2")
}
func testLoadCacheInvalidationForcesReload() {
GhosttyConfig.invalidateLoadCache()
defer { GhosttyConfig.invalidateLoadCache() }
var loadCount = 0
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in
loadCount += 1
var config = GhosttyConfig()
config.fontFamily = "reload-\(loadCount)"
return config
}
let first = GhosttyConfig.load(
preferredColorScheme: .dark,
loadFromDisk: loadFromDisk
)
GhosttyConfig.invalidateLoadCache()
let second = GhosttyConfig.load(
preferredColorScheme: .dark,
loadFromDisk: loadFromDisk
)
XCTAssertEqual(loadCount, 2)
XCTAssertEqual(first.fontFamily, "reload-1")
XCTAssertEqual(second.fontFamily, "reload-2")
}
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() {
XCTAssertTrue(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
newConfigFileSize: 0,
legacyConfigFileSize: 42
)
)
}
func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() {
XCTAssertFalse(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
newConfigFileSize: nil,
legacyConfigFileSize: 42
)
)
XCTAssertFalse(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
newConfigFileSize: 10,
legacyConfigFileSize: 42
)
)
XCTAssertFalse(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
newConfigFileSize: 0,
legacyConfigFileSize: 0
)
)
XCTAssertFalse(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
newConfigFileSize: 0,
legacyConfigFileSize: nil
)
)
}
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
XCTAssertTrue(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
currentBundleIdentifier: "com.cmuxterm.app.debug",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: 128,
releaseLegacyConfigFileSize: nil
)
)
}
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
XCTAssertFalse(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: 64,
releaseConfigFileSize: 128,
releaseLegacyConfigFileSize: nil
)
)
}
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
XCTAssertFalse(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
currentBundleIdentifier: "com.cmuxterm.app",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: 128,
releaseLegacyConfigFileSize: nil
)
)
XCTAssertFalse(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
currentBundleIdentifier: "com.cmuxterm.app.debug",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: nil,
releaseLegacyConfigFileSize: 0
)
)
}
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
XCTAssertTrue(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .unscoped,
incomingScope: .app
)
)
XCTAssertTrue(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .app,
incomingScope: .surface
)
)
XCTAssertTrue(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .surface,
incomingScope: .surface
)
)
XCTAssertFalse(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .surface,
incomingScope: .app
)
)
XCTAssertFalse(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .surface,
incomingScope: .unscoped
)
)
}
func testAppearanceChangeReloadsWhenColorSchemeChanges() {
XCTAssertTrue(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: .dark,
currentColorScheme: .light
)
)
XCTAssertTrue(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: nil,
currentColorScheme: .dark
)
)
}
func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() {
XCTAssertFalse(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: .light,
currentColorScheme: .light
)
)
XCTAssertFalse(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: .dark,
currentColorScheme: .dark
)
)
}
func testScrollLagCaptureRequiresSustainedLag() {
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 4,
averageMs: 18,
maxMs: 85,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 10,
averageMs: 6,
maxMs: 85,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 10,
averageMs: 18,
maxMs: 35,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
XCTAssertTrue(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 10,
averageMs: 18,
maxMs: 85,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
}
func testScrollLagCaptureRespectsCooldownWindow() {
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 12,
averageMs: 22,
maxMs: 90,
thresholdMs: 40,
nowUptime: 1200,
lastReportedUptime: 1005,
cooldown: 300
)
)
XCTAssertTrue(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 12,
averageMs: 22,
maxMs: 90,
thresholdMs: 40,
nowUptime: 1406,
lastReportedUptime: 1005,
cooldown: 300
)
)
}
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
}
func testClaudeCodeIntegrationRespectsStoredPreference() {
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.set(true, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
defaults.set(false, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
}
func testTelemetryDefaultsToEnabledWhenUnset() {
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey)
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
}
func testTelemetryRespectsStoredPreference() {
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults))
}
private func rgb255(_ color: NSColor) -> RGB {
let srgb = color.usingColorSpace(.sRGB)!
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return RGB(
red: Int(round(red * 255)),
green: Int(round(green * 255)),
blue: Int(round(blue * 255))
)
}
}
final class WorkspaceChromeThemeTests: XCTestCase {
func testResolvedChromeColorsUsesLightGhosttyBackground() {
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test color")
return
}
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
XCTAssertNil(colors.borderHex)
}
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
guard let backgroundColor = NSColor(hex: "#272822") else {
XCTFail("Expected valid test color")
return
}
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
XCTAssertEqual(colors.backgroundHex, "#272822")
XCTAssertNil(colors.borderHex)
}
}
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
guard let loadedBackground = NSColor(hex: "#112233"),
let runtimeBackground = NSColor(hex: "#FDF6E3"),
let loadedForeground = NSColor(hex: "#ABCDEF") else {
XCTFail("Expected valid test colors")
return
}
var loaded = GhosttyConfig()
loaded.backgroundColor = loadedBackground
loaded.foregroundColor = loadedForeground
loaded.unfocusedSplitOpacity = 0.42
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
loadConfig: { loaded },
defaultBackground: { runtimeBackground }
)
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
}
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
guard let loadedBackground = NSColor(hex: "#112233"),
let runtimeBackground = NSColor(hex: "#FDF6E3"),
let explicitOverride = NSColor(hex: "#272822") else {
XCTFail("Expected valid test colors")
return
}
var loaded = GhosttyConfig()
loaded.backgroundColor = loadedBackground
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
backgroundOverride: explicitOverride,
loadConfig: { loaded },
defaultBackground: { runtimeBackground }
)
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
}
}
@MainActor
final class WorkspaceChromeColorTests: XCTestCase {
func testBonsplitChromeHexIncludesAlphaWhenTranslucent() {
let color = NSColor(
srgbRed: 17.0 / 255.0,
green: 34.0 / 255.0,
blue: 51.0 / 255.0,
alpha: 1.0
)
let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5)
XCTAssertEqual(hex, "#1122337F")
}
func testBonsplitChromeHexOmitsAlphaWhenOpaque() {
let color = NSColor(
srgbRed: 17.0 / 255.0,
green: 34.0 / 255.0,
blue: 51.0 / 255.0,
alpha: 1.0
)
let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0)
XCTAssertEqual(hex, "#112233")
}
}
final class WindowTransparencyDecisionTests: XCTestCase {
private let sidebarBlendModeKey = "sidebarBlendMode"
private let bgGlassEnabledKey = "bgGlassEnabled"
func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() {
withTemporaryWindowBackgroundDefaults {
let defaults = UserDefaults.standard
defaults.set("withinWindow", forKey: sidebarBlendModeKey)
defaults.set(false, forKey: bgGlassEnabledKey)
XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow())
XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80))
XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0))
}
}
func testBehindWindowGlassPathStillControlsTransparentWindowFallback() {
withTemporaryWindowBackgroundDefaults {
let defaults = UserDefaults.standard
defaults.set("behindWindow", forKey: sidebarBlendModeKey)
defaults.set(true, forKey: bgGlassEnabledKey)
let expectedTransparentFallback = !WindowGlassEffect.isAvailable
XCTAssertEqual(cmuxShouldUseTransparentBackgroundWindow(), expectedTransparentFallback)
XCTAssertEqual(
cmuxShouldUseClearWindowBackground(for: 1.0),
expectedTransparentFallback
)
}
}
private func withTemporaryWindowBackgroundDefaults(_ body: () -> Void) {
let defaults = UserDefaults.standard
let originalBlendMode = defaults.object(forKey: sidebarBlendModeKey)
let originalGlassEnabled = defaults.object(forKey: bgGlassEnabledKey)
defer {
restoreDefaultsValue(originalBlendMode, key: sidebarBlendModeKey, defaults: defaults)
restoreDefaultsValue(originalGlassEnabled, key: bgGlassEnabledKey, defaults: defaults)
}
body()
}
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
final class WindowBackgroundSelectionGateTests: XCTestCase {
func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() {
let tabId = UUID()
let activeSelectedTabId = UUID()
XCTAssertTrue(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: tabId,
owningManagerExists: true,
owningSelectedTabId: tabId,
activeSelectedTabId: activeSelectedTabId
)
)
}
func testShouldApplyWindowBackgroundRejectsWhenOwningSelectionDiffers() {
let tabId = UUID()
XCTAssertFalse(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: tabId,
owningManagerExists: true,
owningSelectedTabId: UUID(),
activeSelectedTabId: tabId
)
)
}
func testShouldApplyWindowBackgroundAllowsWhenOwningManagerSelectionIsTemporarilyNil() {
let tabId = UUID()
XCTAssertTrue(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: tabId,
owningManagerExists: true,
owningSelectedTabId: nil,
activeSelectedTabId: UUID()
)
)
}
func testShouldApplyWindowBackgroundFallsBackToActiveSelection() {
let tabId = UUID()
XCTAssertTrue(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: tabId,
owningManagerExists: false,
owningSelectedTabId: nil,
activeSelectedTabId: tabId
)
)
XCTAssertFalse(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: tabId,
owningManagerExists: false,
owningSelectedTabId: nil,
activeSelectedTabId: UUID()
)
)
}
func testShouldApplyWindowBackgroundAllowsWhenNoSelectionContext() {
XCTAssertTrue(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: UUID(),
owningManagerExists: false,
owningSelectedTabId: nil,
activeSelectedTabId: nil
)
)
XCTAssertTrue(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: nil,
owningManagerExists: false,
owningSelectedTabId: nil,
activeSelectedTabId: nil
)
)
XCTAssertTrue(
GhosttyNSView.shouldApplyWindowBackground(
surfaceTabId: nil,
owningManagerExists: true,
owningSelectedTabId: UUID(),
activeSelectedTabId: UUID()
)
)
}
}
final class NotificationBurstCoalescerTests: XCTestCase {
func testSignalsInSameBurstFlushOnce() {
let coalescer = NotificationBurstCoalescer(delay: 0.01)
let expectation = expectation(description: "flush once")
expectation.expectedFulfillmentCount = 1
var flushCount = 0
DispatchQueue.main.async {
for _ in 0..<8 {
coalescer.signal {
flushCount += 1
expectation.fulfill()
}
}
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(flushCount, 1)
}
func testLatestActionWinsWithinBurst() {
let coalescer = NotificationBurstCoalescer(delay: 0.01)
let expectation = expectation(description: "latest action flushed")
var value = 0
DispatchQueue.main.async {
coalescer.signal {
value = 1
}
coalescer.signal {
value = 2
expectation.fulfill()
}
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(value, 2)
}
func testSignalsAcrossBurstsFlushMultipleTimes() {
let coalescer = NotificationBurstCoalescer(delay: 0.01)
let expectation = expectation(description: "flush twice")
expectation.expectedFulfillmentCount = 2
var flushCount = 0
DispatchQueue.main.async {
coalescer.signal {
flushCount += 1
expectation.fulfill()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
coalescer.signal {
flushCount += 1
expectation.fulfill()
}
}
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(flushCount, 2)
}
}
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
func testSignalCoalescesBurstToLatestBackground() {
guard let dark = NSColor(hex: "#272822"),
let light = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test colors")
return
}
let expectation = expectation(description: "coalesced notification")
expectation.expectedFulfillmentCount = 1
var postedUserInfos: [[AnyHashable: Any]] = []
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
delay: 0.01,
postNotification: { userInfo in
postedUserInfos.append(userInfo)
expectation.fulfill()
}
)
DispatchQueue.main.async {
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(postedUserInfos.count, 1)
XCTAssertEqual(
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
"#FDF6E3"
)
XCTAssertEqual(
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
0.75,
accuracy: 0.0001
)
XCTAssertEqual(
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
2
)
XCTAssertEqual(
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
"test.light"
)
}
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
guard let dark = NSColor(hex: "#272822"),
let light = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test colors")
return
}
let expectation = expectation(description: "two notifications")
expectation.expectedFulfillmentCount = 2
var postedHexes: [String] = []
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
delay: 0.01,
postNotification: { userInfo in
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
postedHexes.append(hex)
expectation.fulfill()
}
)
DispatchQueue.main.async {
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
}
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
}
private func postedOpacity(from value: Any?) -> Double {
if let value = value as? Double {
return value
}
if let value = value as? NSNumber {
return value.doubleValue
}
XCTFail("Expected background opacity payload")
return -1
}
}
final class RecentlyClosedBrowserStackTests: XCTestCase {
func testPopReturnsEntriesInLIFOOrder() {
var stack = RecentlyClosedBrowserStack(capacity: 20)
stack.push(makeSnapshot(index: 1))
stack.push(makeSnapshot(index: 2))
stack.push(makeSnapshot(index: 3))
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
XCTAssertNil(stack.pop())
}
func testPushDropsOldestEntriesWhenCapacityExceeded() {
var stack = RecentlyClosedBrowserStack(capacity: 3)
for index in 1...5 {
stack.push(makeSnapshot(index: index))
}
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
XCTAssertNil(stack.pop())
}
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
ClosedBrowserPanelRestoreSnapshot(
workspaceId: UUID(),
url: URL(string: "https://example.com/\(index)"),
originalPaneId: UUID(),
originalTabIndex: index,
fallbackSplitOrientation: .horizontal,
fallbackSplitInsertFirst: false,
fallbackAnchorPaneId: UUID()
)
}
}
final class TabManagerNotificationOrderingSourceTests: XCTestCase {
func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws {
let projectRoot = findProjectRoot()
let tabManagerURL = projectRoot.appendingPathComponent("Sources/TabManager.swift")
let source = try String(contentsOf: tabManagerURL, encoding: .utf8)
guard let titleObserverStart = source.range(of: "forName: .ghosttyDidSetTitle"),
let focusObserverStart = source.range(
of: "forName: .ghosttyDidFocusSurface",
range: titleObserverStart.upperBound..<source.endIndex
) else {
XCTFail("Failed to locate TabManager notification observer block in Sources/TabManager.swift")
return
}
let block = String(source[titleObserverStart.lowerBound..<focusObserverStart.lowerBound])
XCTAssertFalse(
block.contains("Task {"),
"""
The .ghosttyDidSetTitle observer must update model state in the notification callback.
Using Task can reorder updates and leave titlebar/toolbar one event behind.
"""
)
XCTAssertTrue(
block.contains("MainActor.assumeIsolated"),
"Expected .ghosttyDidSetTitle observer to run synchronously on MainActor."
)
XCTAssertTrue(
block.contains("enqueuePanelTitleUpdate"),
"Expected .ghosttyDidSetTitle observer to enqueue panel title updates."
)
}
private func findProjectRoot() -> URL {
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
for _ in 0..<10 {
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
if FileManager.default.fileExists(atPath: marker.path) {
return dir
}
dir = dir.deletingLastPathComponent()
}
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
}
}
final class SocketControlSettingsTests: XCTestCase {
func testMigrateModeSupportsExpandedSocketModes() {
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
// Legacy aliases
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
}
func testSocketModePermissions() {
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
}
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
XCTAssertNil(
SocketControlSettings.envOverrideMode(
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
)
)
XCTAssertEqual(
SocketControlSettings.effectiveMode(
userMode: .password,
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
),
.password
)
}
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux.sock")
}
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app.nightly",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
}
func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock",
],
bundleIdentifier: "com.cmuxterm.app.debug.my-tag",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock")
}
func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock",
],
bundleIdentifier: "com.cmuxterm.app.staging.my-tag",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock")
}
func testStableReleaseCanOptInToSocketOverride() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock",
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
}
func testDefaultSocketPathByChannel() {
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
"/tmp/cmux.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
"/tmp/cmux-nightly.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
"/tmp/cmux-debug.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
"/tmp/cmux-staging.sock"
)
}
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
XCTAssertTrue(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: [:],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testUntaggedDebugBundleAllowedWithLaunchTag() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["CMUX_TAG": "tests-v1"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: [:],
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
isDebugBuild: true
)
)
}
func testReleaseBuildIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: [:],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: false
)
)
}
func testXCTestLaunchIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["XCInjectBundle": "/tmp/fake.xctest"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testXCTestDyldLaunchIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
// XCUITest launches the app as a separate process without XCTest env vars.
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["CMUX_UI_TEST_MODE": "1"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
}
final class PostHogAnalyticsPropertiesTests: XCTestCase {
func testDailyActivePropertiesIncludeVersionAndBuild() {
let properties = PostHogAnalytics.dailyActiveProperties(
dayUTC: "2026-02-21",
reason: "didBecomeActive",
infoDictionary: [
"CFBundleShortVersionString": "0.31.0",
"CFBundleVersion": "230",
]
)
XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21")
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
XCTAssertEqual(properties["app_build"] as? String, "230")
}
func testSuperPropertiesIncludePlatformVersionAndBuild() {
let properties = PostHogAnalytics.superProperties(
infoDictionary: [
"CFBundleShortVersionString": "0.31.0",
"CFBundleVersion": "230",
]
)
XCTAssertEqual(properties["platform"] as? String, "cmuxterm")
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
XCTAssertEqual(properties["app_build"] as? String, "230")
}
func testHourlyActivePropertiesIncludeVersionAndBuild() {
let properties = PostHogAnalytics.hourlyActiveProperties(
hourUTC: "2026-02-21T14",
reason: "didBecomeActive",
infoDictionary: [
"CFBundleShortVersionString": "0.31.0",
"CFBundleVersion": "230",
]
)
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
XCTAssertEqual(properties["app_build"] as? String, "230")
}
func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() {
let properties = PostHogAnalytics.hourlyActiveProperties(
hourUTC: "2026-02-21T14",
reason: "activeTimer",
infoDictionary: [:]
)
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
XCTAssertEqual(properties["reason"] as? String, "activeTimer")
XCTAssertNil(properties["app_version"])
XCTAssertNil(properties["app_build"])
}
func testPropertiesOmitVersionFieldsWhenUnavailable() {
let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:])
XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm")
XCTAssertNil(superProperties["app_version"])
XCTAssertNil(superProperties["app_build"])
let dailyProperties = PostHogAnalytics.dailyActiveProperties(
dayUTC: "2026-02-21",
reason: "activeTimer",
infoDictionary: [:]
)
XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21")
XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer")
XCTAssertNil(dailyProperties["app_version"])
XCTAssertNil(dailyProperties["app_build"])
}
func testFlushPolicyIncludesDailyAndHourlyActiveEvents() {
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active"))
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active"))
XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event"))
}
}
final class GhosttyMouseFocusTests: XCTestCase {
func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() {
XCTAssertTrue(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: false
)
)
}
func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() {
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: false,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: false
)
)
}
func testShouldNotRequestFirstResponderDuringMouseDrag() {
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 1,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: false
)
)
}
func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() {
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: false,
hiddenInHierarchy: false
)
)
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: true
)
)
}
// MARK: - CJK Font Fallback
private func withTempConfig(
_ contents: String,
body: (String) -> Void
) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let file = dir.appendingPathComponent("config")
try contents.write(to: file, atomically: true, encoding: .utf8)
body(file.path)
}
// MARK: cjkFontMappings
func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
let fonts = Set(mappings.map(\.1))
let ranges = mappings.map(\.0)
XCTAssertTrue(fonts.contains("Hiragino Sans"))
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
}
func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])!
let fonts = Set(mappings.map(\.1))
let ranges = mappings.map(\.0)
XCTAssertTrue(fonts.contains("Apple SD Gothic Neo"))
XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables")
XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo")
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana")
}
func testCJKFontMappingsReturnsPingFangForChinese() {
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })
let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })
let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
}
func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
}
func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0)
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo")
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
}
// MARK: userConfigContainsCJKCodepointMap
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
XCTAssertFalse(
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"])
)
}
func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "font-family = Menlo\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
}