From 12ee196c2d68e856ec1773eec05c3163738d13a3 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 19 Sep 2026 01:13:01 +0800 Subject: [PATCH] Fix tets --- FrameworkTests/SpeechWaveformViewTests.swift | 112 +++++++++++++----- Memory/ARCHITECTURE.md | 5 + .../MobileProviderEditorModel.swift | 9 +- TFITests/MobileProviderDefaultsTests.swift | 38 ++++++ TFM/UI/Components/WindowToolbar.swift | 75 +++++++----- TFMTests/MetricsDetailToolbarTests.swift | 63 +++++++--- TFMTests/PortForwardingRenderingTests.swift | 43 +++++-- 7 files changed, 261 insertions(+), 84 deletions(-) diff --git a/FrameworkTests/SpeechWaveformViewTests.swift b/FrameworkTests/SpeechWaveformViewTests.swift index 95ad92ed..54c448eb 100644 --- a/FrameworkTests/SpeechWaveformViewTests.swift +++ b/FrameworkTests/SpeechWaveformViewTests.swift @@ -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) 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 + } + } } } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index bac8b6c9..1568fa92 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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 diff --git a/TFI/Application/FeatureModels/MobileProviderEditorModel.swift b/TFI/Application/FeatureModels/MobileProviderEditorModel.swift index af600ad6..55979281 100644 --- a/TFI/Application/FeatureModels/MobileProviderEditorModel.swift +++ b/TFI/Application/FeatureModels/MobileProviderEditorModel.swift @@ -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) diff --git a/TFITests/MobileProviderDefaultsTests.swift b/TFITests/MobileProviderDefaultsTests.swift index ae246c04..73e7e5b7 100644 --- a/TFITests/MobileProviderDefaultsTests.swift +++ b/TFITests/MobileProviderDefaultsTests.swift @@ -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") diff --git a/TFM/UI/Components/WindowToolbar.swift b/TFM/UI/Components/WindowToolbar.swift index 47fc68af..b9df0439 100644 --- a/TFM/UI/Components/WindowToolbar.swift +++ b/TFM/UI/Components/WindowToolbar.swift @@ -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 } } } diff --git a/TFMTests/MetricsDetailToolbarTests.swift b/TFMTests/MetricsDetailToolbarTests.swift index a11fae15..481b6a9d 100644 --- a/TFMTests/MetricsDetailToolbarTests.swift +++ b/TFMTests/MetricsDetailToolbarTests.swift @@ -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(in view: NSView, ofType type: T.Type) -> [T] { (view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(in: $0, ofType: type) } } diff --git a/TFMTests/PortForwardingRenderingTests.swift b/TFMTests/PortForwardingRenderingTests.swift index ff1e3da0..8937f50f 100644 --- a/TFMTests/PortForwardingRenderingTests.swift +++ b/TFMTests/PortForwardingRenderingTests.swift @@ -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 {