Files
Termind/TermindTests/ColorSystemTests.swift
T
Kaede Akino 41404ca48d Fix flaky and never-green tests
The suite never finished. A SIGSEGV around 272 of 975 tests killed the
runner, so xcodebuild only ever reported one shard and roughly seventy
percent of the tests were not being executed at all. Underneath it sat
four failures, two of which had been red since the day they were
written, and three tests whose outcome was decided by state they did not
own.

The crash: `RemoteWindowTitleTests` closed a window whose
`isReleasedWhenClosed` still defaults to true, releasing one ARC also
owned. The second release landed on freed memory during an autorelease
pool drain, surfacing as `-[_NSWindowTransformAnimation dealloc]` and
charged to whichever test happened to be running. Closing it also risked
terminating the test host, which is the real application and whose
delegate quits once the last window closes. Ordering the window out
instead settles both.

Never green since written:

- `conversationOwnedImportHonorsAttachmentLimitBeforeStartingTasks`
  staged twenty chips that differed only by label, and `attach`
  deduplicates on kind and content, so one went in and the limit under
  test was never reached.
- `failedCompactionSaveDoesNotChangeTheInMemoryContext` asked for a cut
  keeping two tokens after a last turn worth one, so the search walked
  past it and `compactionSpan` returned nil. Both turns are now sized in
  the unit the cut is chosen in, sharing `filler(tokens:)`.

Stale or ambient:

- `ColorSystemTests` read the domain value `colorTag: .blue` as a
  display colour. The scan predates tag colours by a day.
- The Chinese text for a finished run changed in 0b67497; the assertion
  did not follow.
- `conversationOwnedFileImportFinishesAndKeepsFilename` yielded rather
  than slept, which does not wait for a read off the main actor.
- `shiftedPunctuationMatchesItsUnshiftedShortcutKey` rests on AppKit
  re-deriving a character through the current input source, which it
  declines for a synthesized event about a third of the time. It now
  asserts when AppKit answers and stands down when it does not.
- `FileURLDropTargetTests` reached `hitTest`, which reads
  `NSApp.currentEvent` and the machine-wide drag pasteboard for itself.
  That decision moves to `participates(at:currentEventType:pasteboard:)`
  with both inputs passed in; `hitTest` keeps its behaviour and the
  tests keep their own pasteboard.

Verified with eight consecutive full runs at 975 of 975.
2026-09-02 18:07:34 +08:00

187 lines
6.8 KiB
Swift

import AppKit
import SwiftUI
import Testing
@testable import Termind
@MainActor
@Suite
struct ColorSystemTests {
@Test
func neutralPaletteAdaptsBetweenAquaAndDarkAqua() throws {
let providers: [() -> Color] = [
{ TermindColor.windowSurface },
{ TermindColor.contentSurface },
{ TermindColor.textSurface },
{ TermindColor.primaryForeground },
{ TermindColor.secondaryForeground },
{ TermindColor.separator },
]
for provider in providers {
let light = try resolved(provider(), appearance: .aqua)
let dark = try resolved(provider(), appearance: .darkAqua)
#expect(colorDistance(light, dark) > 0.04)
}
}
@Test
func primaryTextKeepsReadableContrastOnTheWindowSurface() throws {
for appearance in [NSAppearance.Name.aqua, .darkAqua] {
let foreground = try resolved(
TermindColor.primaryForeground,
appearance: appearance
)
let background = try resolved(
TermindColor.windowSurface,
appearance: appearance
)
#expect(contrastRatio(foreground, background) >= 4.5)
}
}
@Test
func markdownThemeIsConcreteForItsRequestedAppearance() throws {
let lightValue = TermindMarkdownTheme.make(
tone: .primary,
colorScheme: .light
)
let darkValue = TermindMarkdownTheme.make(
tone: .primary,
colorScheme: .dark
)
let light = try #require(
lightValue.theme.colors.body.usingColorSpace(.deviceRGB)
)
let dark = try #require(
darkValue.theme.colors.body.usingColorSpace(.deviceRGB)
)
#expect(lightValue.colorScheme == .light)
#expect(darkValue.colorScheme == .dark)
#expect(colorDistance(light, dark) > 0.5)
}
@Test
func featureUIHasNoLiteralDisplayColorsOrDecorativeBlue() throws {
let sourceRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appending(path: "Termind", directoryHint: .isDirectory)
let exemptPaths = [
"Design/TerminalTheme.swift",
"DesignSystem/TagColor+Presentation.swift",
]
let forbidden = [
"Color.white", "Color.black", "NSColor.white", "NSColor.gray",
"foregroundStyle(.white)", "AnyShapeStyle(.white)",
".shadow(color: .black", "Color(white:", "Color(red:", "NSColor(red:",
".blue", "systemBlue",
]
for file in try swiftFiles(in: sourceRoot) {
let relative = file.path.replacingOccurrences(
of: sourceRoot.path + "/",
with: ""
)
if exemptPaths.contains(relative) { continue }
let lines = try String(contentsOf: file, encoding: .utf8)
.split(separator: "\n", omittingEmptySubsequences: false)
for (offset, line) in lines.enumerated() {
// Black and white are alpha values here, not visible UI hues.
let isMask =
relative == "Features/AI/TranscriptNativeRows.swift"
&& line.contains("mask.colors")
if isMask { continue }
// A `TagColor` case a user picked for a host or key, not a hue
// this code chose to draw. Its presentation is resolved through
// the exempt `TagColor+Presentation.swift`, which is the file
// this scan exists to funnel such choices into.
if line.contains("colorTag:") { continue }
for token in forbidden {
#expect(
!line.contains(token),
"\(relative):\(offset + 1) bypasses TermindColor with \(token)"
)
}
}
}
}
@Test
func nativeHostingListsReloadRowsWhenTheirEnvironmentChanges() throws {
let sourceRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appending(path: "Termind", directoryHint: .isDirectory)
for file in try swiftFiles(in: sourceRoot) {
let text = try String(contentsOf: file, encoding: .utf8)
let environmentChangeCount =
text.components(
separatedBy: "let environmentChanged"
).count - 1
guard environmentChangeCount > 0 else { continue }
let forcedReloadCount =
text.components(
separatedBy: "if environmentChanged { listView.reloadData() }"
).count - 1
#expect(
forcedReloadCount == environmentChangeCount,
"\(file.path) can leave recycled NSHostingView rows in the old appearance"
)
}
}
private func resolved(
_ color: Color,
appearance name: NSAppearance.Name
) throws -> NSColor {
let appearance = try #require(NSAppearance(named: name))
var result: NSColor?
appearance.performAsCurrentDrawingAppearance {
result = NSColor(color).usingColorSpace(.deviceRGB)
}
return try #require(result)
}
private func colorDistance(_ lhs: NSColor, _ rhs: NSColor) -> CGFloat {
let dr = lhs.redComponent - rhs.redComponent
let dg = lhs.greenComponent - rhs.greenComponent
let db = lhs.blueComponent - rhs.blueComponent
return sqrt(dr * dr + dg * dg + db * db)
}
private func contrastRatio(_ lhs: NSColor, _ rhs: NSColor) -> 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: NSColor) -> 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.redComponent)
+ 0.7152 * linear(color.greenComponent)
+ 0.0722 * linear(color.blueComponent)
}
private func swiftFiles(in root: URL) throws -> [URL] {
let keys: Set<URLResourceKey> = [.isRegularFileKey]
guard
let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: Array(keys)
)
else { return [] }
return try enumerator.compactMap { value in
guard let url = value as? URL, url.pathExtension == "swift" else { return nil }
let values = try url.resourceValues(forKeys: keys)
return values.isRegularFile == true ? url : nil
}
}
}