This commit is contained in:
Kaede Akino committed 2026-09-19 01:13:12 +08:00
1 parent b0d925c55b
commit 12ee196c2d
7 files changed
+261 -84

No files matched your search

+84 -28
View File
@@ -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
}
}
}
}
+5
View File
@@ -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")
+44 -31
View File
@@ -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 }
}
}
+49 -14
View File
@@ -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) }
}
+33 -10
View File
@@ -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 {