Fix tets
This commit is contained in:
1 parent
b0d925c55b
commit
12ee196c2d
7 files changed
+261
-84
No files matched your search
@@ -1,11 +1,9 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
@@ -15,36 +13,94 @@ struct SpeechWaveformViewTests {
|
||||
let quiet = Array(repeating: Float(0.04), count: 8)
|
||||
let loud = Array(repeating: Float(1), count: 8)
|
||||
|
||||
#expect(try render(quiet, reduceMotion: false) != render(loud, reduceMotion: false))
|
||||
#expect(try render(quiet, reduceMotion: true) == render(loud, reduceMotion: true))
|
||||
try expectPixels(render(quiet, reduceMotion: false), render(loud, reduceMotion: false), equal: false)
|
||||
try expectPixels(render(quiet, reduceMotion: true), render(loud, reduceMotion: true), equal: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatingTheSameRendererKeepsReducedMotionStable() throws {
|
||||
let quiet = Array(repeating: Float(0.04), count: 8)
|
||||
let loud = Array(repeating: Float(1), count: 8)
|
||||
let renderer = ImageRenderer(content: WaveformFixture(samples: quiet, reduceMotion: true))
|
||||
renderer.scale = 2
|
||||
let initial = try pixels(renderer)
|
||||
|
||||
renderer.content.samples = loud
|
||||
try expectPixels(initial, pixels(renderer), equal: true)
|
||||
|
||||
renderer.content.reduceMotion = false
|
||||
let live = try pixels(renderer)
|
||||
try expectPixels(initial, live, equal: false)
|
||||
renderer.content.samples = quiet
|
||||
try expectPixels(live, pixels(renderer), equal: false)
|
||||
|
||||
renderer.content.reduceMotion = true
|
||||
try expectPixels(initial, pixels(renderer), equal: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func waveformSafelyHandlesEmptyAndOutOfRangeSamples() throws {
|
||||
#expect(try render([], reduceMotion: false).isEmpty == false)
|
||||
#expect(
|
||||
try render([-1, 2], reduceMotion: false)
|
||||
== render([0.04, 1], reduceMotion: false)
|
||||
)
|
||||
#expect(try render([], reduceMotion: false).data.count == 320 * 64 * 4)
|
||||
try expectPixels(render([-1, 2], reduceMotion: false), render([0.04, 1], reduceMotion: false), equal: true)
|
||||
}
|
||||
|
||||
private func render(_ samples: [Float], reduceMotion: Bool) throws -> Data {
|
||||
let renderer = ImageRenderer(
|
||||
content: SpeechWaveformView(samples: samples, reduceMotion: reduceMotion)
|
||||
.foregroundStyle(.black)
|
||||
private func render(_ samples: [Float], reduceMotion: Bool) throws -> Bitmap {
|
||||
let renderer = ImageRenderer(content: WaveformFixture(samples: samples, reduceMotion: reduceMotion))
|
||||
renderer.scale = 2
|
||||
return try pixels(renderer)
|
||||
}
|
||||
|
||||
private func pixels(_ renderer: ImageRenderer<WaveformFixture>) throws -> Bitmap {
|
||||
let image = try #require(renderer.cgImage)
|
||||
let colorSpace = try #require(CGColorSpace(name: CGColorSpace.sRGB))
|
||||
let context = try #require(
|
||||
CGContext(
|
||||
data: nil, width: image.width, height: image.height, bitsPerComponent: 8,
|
||||
bytesPerRow: image.width * 4, space: colorSpace,
|
||||
bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue))
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
let bytes = try #require(context.data)
|
||||
return Bitmap(
|
||||
data: Data(bytes: bytes, count: context.bytesPerRow * context.height),
|
||||
image: try #require(context.makeImage()))
|
||||
}
|
||||
|
||||
private func expectPixels(
|
||||
_ first: Bitmap, _ second: Bitmap, equal: Bool, sourceLocation: SourceLocation = #_sourceLocation
|
||||
) throws {
|
||||
let matches =
|
||||
first.image.width == second.image.width && first.image.height == second.image.height
|
||||
&& first.data == second.data
|
||||
if matches != equal {
|
||||
for (name, bitmap) in [("first", first), ("second", second)] {
|
||||
let data = NSMutableData()
|
||||
let destination = try #require(CGImageDestinationCreateWithData(data, "public.png" as CFString, 1, nil))
|
||||
CGImageDestinationAddImage(destination, bitmap.image, nil)
|
||||
try #require(CGImageDestinationFinalize(destination))
|
||||
Attachment.record(data as Data, named: "waveform-\(name).png")
|
||||
}
|
||||
}
|
||||
#expect(matches == equal, sourceLocation: sourceLocation)
|
||||
}
|
||||
|
||||
private struct Bitmap {
|
||||
let data: Data
|
||||
let image: CGImage
|
||||
}
|
||||
|
||||
private struct WaveformFixture: View {
|
||||
var samples: [Float]
|
||||
var reduceMotion: Bool
|
||||
|
||||
var body: some View {
|
||||
SpeechWaveformView(samples: samples, reduceMotion: reduceMotion)
|
||||
.frame(width: 160, height: 32)
|
||||
.background(.white)
|
||||
)
|
||||
renderer.scale = 2
|
||||
#if canImport(AppKit)
|
||||
let image = try #require(renderer.nsImage)
|
||||
let bitmap = try #require(image.tiffRepresentation.flatMap { NSBitmapImageRep(data: $0) })
|
||||
return try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
#elseif canImport(UIKit)
|
||||
return try #require(renderer.uiImage?.pngData())
|
||||
#else
|
||||
Issue.record("Unsupported rendering platform")
|
||||
return Data()
|
||||
#endif
|
||||
.environment(\.colorScheme, .light)
|
||||
.transaction {
|
||||
$0.animation = nil
|
||||
$0.disablesAnimations = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,11 @@ reads the saved value. Legacy blank records and incoming replicas are not backfi
|
||||
on load; their next local record save materializes the name in the existing transaction.
|
||||
No schema or synchronization payload change is required.
|
||||
|
||||
Mobile provider drafts merge raw model names before materializing defaults at Save.
|
||||
An explicit clear of a saved name, including a generated name, conflicts with a
|
||||
concurrent rename. Only a legacy blank name becoming its default in the latest
|
||||
record is treated as unchanged, so unrelated remote edits still merge normally.
|
||||
|
||||
- Repository snapshots and preference models are application-wide. A window owns
|
||||
its shell, quick-open, search, navigation, and context-panel state.
|
||||
- Desktop main scenes each own a distinct `WindowModel`. The process-owned main
|
||||
|
||||
@@ -151,7 +151,14 @@ final class MobileProviderEditorModel {
|
||||
|
||||
static func mergeModel(_ original: AIModel, _ draft: AIModel, _ current: AIModel) throws -> AIModel {
|
||||
// Materializing a legacy blank name on another save is not a competing rename.
|
||||
var fields = SettingsFields(original.savingName, draft.savingName, current.savingName)
|
||||
// Keep explicit clears intact: even a saved default name is an ordinary name.
|
||||
var current = current
|
||||
if original.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
current.displayName == original.savingName.displayName
|
||||
{
|
||||
current.displayName = original.displayName
|
||||
}
|
||||
var fields = SettingsFields(original, draft, current)
|
||||
try fields.merge(\.displayName)
|
||||
try fields.merge(\.kind)
|
||||
try fields.merge(\.input)
|
||||
|
||||
@@ -70,9 +70,47 @@ struct MobileProviderDefaultsTests {
|
||||
remote.displayName = "Remote Name"
|
||||
ai.updateModel(remote, in: provider.id)
|
||||
#expect(throws: SettingsDraftMerge.Conflict.self) { try conflict.save() }
|
||||
#expect(conflict.draft.models[0].displayName == "")
|
||||
#expect(ai.modelProvider(provider.id)?.models[0].displayName == "Remote Name")
|
||||
}
|
||||
|
||||
@Test(arguments: ["", " \n"], ["", "Local Name"])
|
||||
func legacyNameMaterializationDoesNotConflictWithDraftEdits(originalName: String, localName: String) throws {
|
||||
let model = AIModel(id: "private-model", displayName: originalName)
|
||||
let provider = ModelProvider(name: "Gateway", models: [model])
|
||||
let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false)
|
||||
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
|
||||
if !localName.isEmpty { editor.draft.models[0].displayName = localName }
|
||||
editor.draft.models[0].maxOutputTokens = 512
|
||||
var remote = model
|
||||
remote.contextLength = 1234
|
||||
ai.updateModel(remote, in: provider.id)
|
||||
#expect(ai.modelProvider(provider.id)?.models[0].displayName == model.id)
|
||||
|
||||
try editor.save()
|
||||
|
||||
let saved = try #require(ai.modelProvider(provider.id)?.models.first)
|
||||
#expect(saved.displayName == (localName.isEmpty ? model.id : localName))
|
||||
#expect(saved.contextLength == 1234)
|
||||
#expect(saved.maxOutputTokens == 512)
|
||||
}
|
||||
|
||||
@Test(arguments: ["private-model", "Custom Name"], ["", " \n"])
|
||||
func clearingASavedNameConflictsWithARemoteRename(originalName: String, clearedName: String) throws {
|
||||
let model = AIModel(id: "private-model", displayName: originalName)
|
||||
let provider = ModelProvider(name: "Gateway", models: [model])
|
||||
let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false)
|
||||
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
|
||||
editor.draft.models[0].displayName = clearedName
|
||||
var remote = model
|
||||
remote.displayName = "Remote Name"
|
||||
ai.updateModel(remote, in: provider.id)
|
||||
|
||||
#expect(throws: SettingsDraftMerge.Conflict.self) { try editor.save() }
|
||||
#expect(editor.draft.models[0].displayName == clearedName)
|
||||
#expect(ai.modelProvider(provider.id)?.models[0] == remote)
|
||||
}
|
||||
|
||||
@Test func vertexPreviewSelectsItsOwnCredentialAndCanSaveAfterClearingIt() throws {
|
||||
let ai = AIConfigurationModel(persistsSecrets: false)
|
||||
let provider = ModelProvider(name: "Google", credentialRef: "key")
|
||||
|
||||
@@ -476,43 +476,56 @@ struct WindowToolbarSearchSlot: View {
|
||||
/// popover inside the toolbar item so changing width never reparents page content.
|
||||
struct WindowToolbarLegacySearch: View {
|
||||
@Environment(WindowModel.self) private var window
|
||||
|
||||
var body: some View {
|
||||
if window.showsCompactMetricsDetail {
|
||||
WindowToolbarCompactFind()
|
||||
} else {
|
||||
WindowToolbarFindField()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowToolbarCompactFind: View {
|
||||
@Environment(WindowModel.self) private var window
|
||||
@State private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
let presentation = $isPresented
|
||||
Button("Search", systemImage: "magnifyingglass") { window.focusToolbarFind() }
|
||||
.labelStyle(.iconOnly)
|
||||
.help("Search")
|
||||
.frame(height: Metrics.Toolbar.height)
|
||||
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
|
||||
WindowToolbarFindField(installFocusHandler: { focus in
|
||||
window.focusToolbarFindField = {
|
||||
presentation.wrappedValue = true
|
||||
focus()
|
||||
}
|
||||
})
|
||||
.padding(12)
|
||||
.onExitCommand { isPresented = false }
|
||||
Group {
|
||||
if window.showsCompactMetricsDetail {
|
||||
Button("Search", systemImage: "magnifyingglass") { window.focusToolbarFind() }
|
||||
.labelStyle(.iconOnly)
|
||||
.help("Search")
|
||||
.onAppear { installCompactFocusHandler() }
|
||||
} else {
|
||||
WindowToolbarFindField()
|
||||
}
|
||||
.onAppear {
|
||||
window.focusToolbarFindField = { presentation.wrappedValue = true }
|
||||
if window.isToolbarFindFocused { isPresented = true }
|
||||
}
|
||||
.onChange(of: isPresented) { _, presented in
|
||||
if !presented { window.isToolbarFindFocused = false }
|
||||
}
|
||||
// The initial geometry can mount the expanded field before the compact
|
||||
// layout is known. Give AppKit the new size instead of retaining its
|
||||
// 220-point minimum and moving the search anchor into toolbar overflow.
|
||||
.frame(
|
||||
width: window.findKind == nil
|
||||
? 0
|
||||
: window.showsCompactMetricsDetail
|
||||
? Metrics.Toolbar.height : Metrics.Toolbar.searchFieldWidth,
|
||||
height: Metrics.Toolbar.height
|
||||
)
|
||||
.fixedSize()
|
||||
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
|
||||
WindowToolbarFindField(installFocusHandler: { focus in
|
||||
window.focusToolbarFindField = {
|
||||
presentation.wrappedValue = true
|
||||
focus()
|
||||
}
|
||||
})
|
||||
.padding(12)
|
||||
.onExitCommand { isPresented = false }
|
||||
}
|
||||
.onChange(of: window.showsCompactMetricsDetail) { _, compact in
|
||||
if !compact { isPresented = false }
|
||||
}
|
||||
.onChange(of: isPresented) { _, presented in
|
||||
if !presented && window.showsCompactMetricsDetail {
|
||||
window.isToolbarFindFocused = false
|
||||
installCompactFocusHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installCompactFocusHandler() {
|
||||
let presentation = $isPresented
|
||||
window.focusToolbarFindField = { presentation.wrappedValue = true }
|
||||
if window.isToolbarFindFocused { isPresented = true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ struct MetricsDetailToolbarTests {
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
app.shutdownForTermination()
|
||||
}
|
||||
await settle(window)
|
||||
@@ -66,9 +67,9 @@ struct MetricsDetailToolbarTests {
|
||||
#expect(!descendants(in: frame, ofType: NSSegmentedControl.self).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyCompactSearchOpensTheNativeFieldAndKeepsItsQueryWhenWidened() async throws {
|
||||
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english)
|
||||
@Test(arguments: [AppLanguage.english, .simplifiedChinese, .traditionalChinese])
|
||||
func legacyCompactSearchOpensTheNativeFieldAndKeepsItsQueryWhenWidened(_ language: AppLanguage) async throws {
|
||||
let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: language)
|
||||
let model = configuration.makeMetricsModel(hosts: .demo())
|
||||
let server = try #require(model.servers.first)
|
||||
let state = WindowModel()
|
||||
@@ -81,11 +82,12 @@ struct MetricsDetailToolbarTests {
|
||||
ToolbarItem(placement: .principal) { MetricsDetailPagePicker() }
|
||||
ToolbarItem(placement: .primaryAction) { WindowToolbarLegacySearch() }
|
||||
}
|
||||
}.environment(state)
|
||||
}.environment(state).environment(\.locale, language.locale)
|
||||
let window = makeWindow(root)
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
model.shutdown()
|
||||
}
|
||||
await settle(window)
|
||||
@@ -94,25 +96,32 @@ struct MetricsDetailToolbarTests {
|
||||
#expect(descendants(in: frame, ofType: NSSearchField.self).isEmpty)
|
||||
#expect(window.toolbar != nil)
|
||||
_ = try #require(state.focusToolbarFindField)
|
||||
try capture(frame, name: "legacy-before-search")
|
||||
try capture(frame, name: "legacy-\(language.rawValue)-before-search")
|
||||
state.focusToolbarFind()
|
||||
await settle(window)
|
||||
try capture(frame, name: "legacy-after-search")
|
||||
let field = try #require(
|
||||
NSApp.windows.filter(\.isVisible).compactMap(\.contentView)
|
||||
.flatMap { descendants(in: $0, ofType: NSSearchField.self) }.first)
|
||||
let field = try await searchField(in: window, inline: false)
|
||||
try capture(frame, name: "legacy-\(language.rawValue)-after-search")
|
||||
#expect(state.metricsDetailPage == .processes)
|
||||
#expect(field.currentEditor() != nil)
|
||||
field.stringValue = "node"
|
||||
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
|
||||
await settle(window)
|
||||
#expect(state.metricsProcessQuery == "node")
|
||||
let editor = try #require(field.currentEditor())
|
||||
editor.doCommand(by: #selector(NSResponder.cancelOperation(_:)))
|
||||
try await waitUntil(in: window) { self.searchFields(in: window).isEmpty }
|
||||
#expect(!state.isToolbarFindFocused)
|
||||
state.focusToolbarFind()
|
||||
let reopenedField = try await searchField(in: window, inline: false)
|
||||
#expect(reopenedField.stringValue == "node")
|
||||
window.setContentSize(CGSize(width: 900, height: 600))
|
||||
await settle(window)
|
||||
let expandedField = try #require(
|
||||
descendants(in: frame, ofType: NSSearchField.self).first)
|
||||
let expandedField = try await searchField(in: window, inline: true)
|
||||
#expect(expandedField.stringValue == "node")
|
||||
#expect(state.metricsDetailPage == .processes)
|
||||
try capture(frame, name: "legacy-\(language.rawValue)-expanded-search")
|
||||
window.setContentSize(CGSize(width: 360, height: 600))
|
||||
let compactField = try await searchField(in: window, inline: false)
|
||||
#expect(compactField.stringValue == "node")
|
||||
#expect(state.metricsDetailPage == .processes)
|
||||
#expect(state.isToolbarFindFocused)
|
||||
}
|
||||
|
||||
private func makeWindow(_ root: some View) -> NSWindow {
|
||||
@@ -136,6 +145,32 @@ struct MetricsDetailToolbarTests {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
private func searchField(in window: NSWindow, inline: Bool) async throws -> NSSearchField {
|
||||
try await waitUntil(in: window) {
|
||||
searchFields(in: window).contains { ($0.window === window) == inline && $0.currentEditor() != nil }
|
||||
}
|
||||
return try #require(
|
||||
searchFields(in: window).first { ($0.window === window) == inline && $0.currentEditor() != nil })
|
||||
}
|
||||
|
||||
private func searchFields(in window: NSWindow) -> [NSSearchField] {
|
||||
let ownFields =
|
||||
window.isVisible
|
||||
? window.contentView.map { descendants(in: $0.superview ?? $0, ofType: NSSearchField.self) } ?? [] : []
|
||||
return ownFields.filter { !$0.isHiddenOrHasHiddenAncestor && !$0.visibleRect.isEmpty }
|
||||
+ (window.childWindows ?? []).flatMap { searchFields(in: $0) }
|
||||
}
|
||||
|
||||
private func waitUntil(in window: NSWindow, _ condition: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
||||
while ContinuousClock.now < deadline {
|
||||
window.contentView?.superview?.layoutSubtreeIfNeeded()
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
try #require(condition(), "The toolbar or its popover did not finish updating within two seconds.")
|
||||
}
|
||||
|
||||
private func descendants<T: NSView>(in view: NSView, ofType type: T.Type) -> [T] {
|
||||
(view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(in: $0, ofType: type) }
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ struct PortForwardingRenderingTests {
|
||||
name.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: name))
|
||||
await Task.yield()
|
||||
let save = try #require(
|
||||
accessibilityDescendants(in: controller.view).first {
|
||||
$0.accessibilityRole() == .button && $0.accessibilityLabel() == String(localized: "Save")
|
||||
})
|
||||
#expect(save.accessibilityPerformPress())
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
|
||||
context: nil, characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
|
||||
#expect(window.performKeyEquivalent(with: save))
|
||||
if fails {
|
||||
#expect(navigation.vaultWorkRoute == .forwardingEditor(rule.id))
|
||||
#expect(app.forwardings.portForwarding(rule.id)?.label == "Database")
|
||||
@@ -58,6 +58,7 @@ struct PortForwardingRenderingTests {
|
||||
#expect(navigation.vaultWorkRoute == nil)
|
||||
#expect(navigation.vaultSelection(for: .portForwarding) == [.forwarding(rule.id)])
|
||||
#expect(app.forwardings.portForwarding(rule.id)?.label == "Updated Database")
|
||||
try await waitUntil(in: controller.view) { name.window == nil }
|
||||
#expect(name.window == nil)
|
||||
}
|
||||
#expect(app.forwarding.state(for: rule.id) == .stopped)
|
||||
@@ -312,21 +313,43 @@ struct PortForwardingRenderingTests {
|
||||
#expect(!exitingFields.isEmpty)
|
||||
#expect(exitingFields.allSatisfy { !$0.isEnabled })
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
try await waitUntil(in: controller.view) {
|
||||
!descendants(in: controller.view).contains { $0 is NSSecureTextField }
|
||||
}
|
||||
#expect(!descendants(in: controller.view).contains { $0 is NSSecureTextField })
|
||||
for _ in 0..<3 {
|
||||
navigation.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding)
|
||||
try await waitUntil(in: controller.view) {
|
||||
descendants(in: controller.view).contains { ($0 as? NSSecureTextField)?.isEnabled == true }
|
||||
}
|
||||
navigation.closeVaultWork()
|
||||
await Task.yield()
|
||||
}
|
||||
navigation.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding)
|
||||
try await waitUntil(in: controller.view) {
|
||||
descendants(in: controller.view).contains { ($0 as? NSSecureTextField)?.isEnabled == true }
|
||||
}
|
||||
#expect(context.authenticationPrompt?.id == prompt.id)
|
||||
#expect(app.forwarding.connection(for: rule.id) === context)
|
||||
#expect(app.forwarding.state(for: rule.id) == .awaitingAuthentication)
|
||||
navigation.closeVaultWork()
|
||||
try await waitUntil(in: controller.view) {
|
||||
!descendants(in: controller.view).contains { $0 is NSSecureTextField }
|
||||
}
|
||||
}
|
||||
|
||||
private func descendants(in view: NSView) -> [NSView] {
|
||||
[view] + view.subviews.flatMap { descendants(in: $0) }
|
||||
}
|
||||
|
||||
private func accessibilityDescendants(in element: any NSAccessibilityProtocol) -> [any NSAccessibilityProtocol] {
|
||||
[element]
|
||||
+ (element.accessibilityChildren() ?? []).compactMap { $0 as? any NSAccessibilityProtocol }
|
||||
.flatMap { accessibilityDescendants(in: $0) }
|
||||
private func waitUntil(in view: NSView, _ condition: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
||||
while ContinuousClock.now < deadline {
|
||||
view.layoutSubtreeIfNeeded()
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
try #require(condition(), "The native view did not finish updating within two seconds.")
|
||||
}
|
||||
|
||||
private func mouseEvent(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws -> NSEvent {
|
||||
|
||||
Reference in new issue
Block a user