diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e1a950c..81ff5012d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The New/Edit Favorite and linked file metadata dialogs no longer open with a large empty area and fields crushed against the right edge. They now use the standard macOS grouped form layout: the query editor spans the full dialog width, the cursor marker hint sits under the editor, keyword validation shows under the keyword field, and the Global option is a checkbox that explains its scope. (#1809) - Tables and views outside the connection's default schema now open and save correctly when reached through the Quick Switcher, a restored tab, or the MCP table-open tool. These paths used to send unqualified queries, which failed with "Invalid object name" on SQL Server or silently targeted the wrong table. (#1774) - Quick Switcher opens again on macOS Sequoia. Since 0.51.0 the panel came up invisible on macOS 15, and the toolbar button and keyboard shortcut appeared to do nothing. (#1806) - Quick Switcher keyboard navigation (Ctrl-J/K and arrow shortcuts) no longer goes dead after the switcher has been opened and closed repeatedly. (#1806) diff --git a/TablePro/Core/Storage/SQLFavoriteEditValidation.swift b/TablePro/Core/Storage/SQLFavoriteEditValidation.swift new file mode 100644 index 000000000..4518f48e6 --- /dev/null +++ b/TablePro/Core/Storage/SQLFavoriteEditValidation.swift @@ -0,0 +1,112 @@ +// +// SQLFavoriteEditValidation.swift +// TablePro +// + +import Foundation +import Observation + +internal enum SQLFavoriteKeywordValidation: Equatable { + case valid + case error(String) + case warning(String) + + var blocksSave: Bool { + if case .error = self { return true } + return false + } + + var isWarning: Bool { + if case .warning = self { return true } + return false + } + + var displayText: String? { + switch self { + case .valid: + return nil + case let .error(text), let .warning(text): + return text + } + } +} + +internal enum SQLFavoriteKeywordValidator { + static let reservedSQLKeywords: Set = [ + "select", "from", "where", "insert", "update", "delete", + "create", "drop", "alter", "join", "on", "and", "or", + "not", "in", "like", "between", "order", "group", "having", + "limit", "set", "values", "into", "as", "is", "null", + "true", "false", "case", "when", "then", "else", "end" + ] + + static func requiresAvailabilityCheck(_ trimmedKeyword: String) -> Bool { + !trimmedKeyword.isEmpty && !trimmedKeyword.contains(" ") + } + + static func classify(trimmedKeyword: String, isAvailable: Bool) -> SQLFavoriteKeywordValidation { + guard !trimmedKeyword.isEmpty else { return .valid } + guard !trimmedKeyword.contains(" ") else { + return .error(String(localized: "Keyword cannot contain spaces")) + } + guard isAvailable else { + return .error(String(localized: "This keyword is already in use")) + } + guard !reservedSQLKeywords.contains(trimmedKeyword.lowercased()) else { + return .warning(String( + format: String(localized: "Shadows the SQL keyword '%@'"), + trimmedKeyword.uppercased() + )) + } + return .valid + } +} + +@MainActor +@Observable +internal final class SQLFavoriteKeywordField { + var keyword = "" + private(set) var validation: SQLFavoriteKeywordValidation = .valid + + private var validationId = 0 + private let availabilityCheck: (String, UUID?, UUID?) async -> Bool + + init( + availabilityCheck: @escaping (String, UUID?, UUID?) async -> Bool = { keyword, connectionId, excludingFavoriteId in + await SQLFavoriteManager.shared.isKeywordAvailable( + keyword, + connectionId: connectionId, + excludingFavoriteId: excludingFavoriteId + ) + } + ) { + self.availabilityCheck = availabilityCheck + } + + var trimmedKeyword: String { + keyword.trimmingCharacters(in: .whitespaces) + } + + func validate(connectionId: UUID?, excludingFavoriteId: UUID?) async { + validationId += 1 + let currentId = validationId + let trimmed = trimmedKeyword + guard SQLFavoriteKeywordValidator.requiresAvailabilityCheck(trimmed) else { + validation = SQLFavoriteKeywordValidator.classify(trimmedKeyword: trimmed, isAvailable: true) + return + } + let available = await availabilityCheck(trimmed, connectionId, excludingFavoriteId) + guard currentId == validationId else { return } + validation = SQLFavoriteKeywordValidator.classify(trimmedKeyword: trimmed, isAvailable: available) + } +} + +internal enum SQLFavoriteEditValidation { + static func canSave( + isNameBlank: Bool, + isQueryBlank: Bool = false, + keywordValidation: SQLFavoriteKeywordValidation + ) -> Bool { + !isNameBlank && !isQueryBlank && !keywordValidation.blocksSave + } +} diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index 23a44c8e9..578f6cb5b 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -23,13 +23,10 @@ internal struct FavoriteEditDialog: View { @State private var name: String = "" @State private var query: String = "" - @State private var keyword: String = "" + @State private var keywordField = SQLFavoriteKeywordField() @State private var isGlobal: Bool = false @State private var selectedFolderId: UUID? - @State private var keywordError: String? - @State private var isKeywordWarning = false @State private var isSaving = false - @State private var validationId = 0 @State private var loadedFolders: [SQLFavoriteFolder]? enum FocusField { case name, keyword } @@ -38,9 +35,11 @@ internal struct FavoriteEditDialog: View { private var isEditing: Bool { favorite != nil } private var effectiveFolders: [SQLFavoriteFolder] { loadedFolders ?? (folders.isEmpty ? nil : folders) ?? [] } private var isValid: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty && - !query.trimmingCharacters(in: .whitespaces).isEmpty && - (keywordError == nil || isKeywordWarning) + SQLFavoriteEditValidation.canSave( + isNameBlank: !name.contains { !$0.isWhitespace }, + isQueryBlank: !query.contains { !$0.isWhitespace }, + keywordValidation: keywordField.validation + ) } private static let maxQuerySize = 500_000 @@ -60,156 +59,154 @@ internal struct FavoriteEditDialog: View { } var body: some View { - VStack(spacing: 16) { - Text(isEditing ? String(localized: "Edit Favorite") : String(localized: "New Favorite")) - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 0) { + titleRow + + Divider() Form { - TextField("Name", text: $name) - .focused($focusedField, equals: .name) + identitySection + querySection + optionsSection + } + .formStyle(.grouped) - if !effectiveFolders.isEmpty { - Picker("Folder", selection: $selectedFolderId) { - Text(String(localized: "None")).tag(nil as UUID?) - ForEach(effectiveFolders) { folder in - Text(folder.name).tag(folder.id as UUID?) - } - } - } + Divider() - LabeledContent("Query") { - TextEditor(text: $query) - .font(.system(.body, design: .monospaced)) - .accessibilityLabel(String(localized: "Query")) - .frame(height: 160) - .scrollContentBackground(.hidden) - .padding(4) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) + buttonBar + } + .frame(width: 560, height: 580) + .onAppear { + populateFields() + focusedField = .name + if folders.isEmpty { + Task { + loadedFolders = await SQLFavoriteManager.shared.fetchFolders(connectionId: connectionId) } + } + } + } - LabeledContent {} label: { - Text(String( - format: String(localized: "Type %@ in the query to set where the cursor lands after keyword expansion."), - SQLSnippetMarker.token - )) - .font(.caption) - .foregroundStyle(.secondary) - } + private var titleRow: some View { + HStack { + Text(isEditing ? String(localized: "Edit Favorite") : String(localized: "New Favorite")) + .font(.headline) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } - TextField("Keyword", text: $keyword) - .focused($focusedField, equals: .keyword) - .onChange(of: keyword) { _, newValue in - validateKeyword(newValue) - } + private var identitySection: some View { + Section { + TextField("Name", text: $name) + .focused($focusedField, equals: .name) - if let error = keywordError { - LabeledContent {} label: { - Text(error) - .foregroundStyle(isKeywordWarning ? .orange : .red) - .font(.callout) + if !effectiveFolders.isEmpty { + Picker("Folder", selection: $selectedFolderId) { + Text(String(localized: "None")).tag(nil as UUID?) + ForEach(effectiveFolders) { folder in + Text(folder.name).tag(folder.id as UUID?) } } - - Toggle("Global", isOn: $isGlobal) - .help(String(localized: "When enabled, this favorite is visible in all connections")) - .onChange(of: isGlobal) { - validateKeyword(keyword) - } } - .formStyle(.columns) + } + } - HStack { - Spacer() - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.cancelAction) + private var querySection: some View { + Section { + TextEditor(text: $query) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 180) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor)) + ) + .accessibilityLabel(String(localized: "Query")) + } header: { + Text("Query") + } footer: { + Text(String( + format: String(localized: "Type %@ in the query to set where the cursor lands after keyword expansion."), + SQLSnippetMarker.token + )) + .font(.caption) + .foregroundStyle(.secondary) + } + } - Button(isEditing ? "Save" : "Add") { - save() + private var optionsSection: some View { + Section { + TextField("Keyword", text: $keywordField.keyword) + .focused($focusedField, equals: .keyword) + .onChange(of: keywordField.keyword) { + revalidateKeyword() } - .keyboardShortcut(.defaultAction) - .disabled(!isValid || isSaving) + + if let message = keywordField.validation.displayText { + Text(message) + .font(.callout) + .foregroundStyle(keywordField.validation.isWarning ? Color.orange : Color.red) } - } - .padding(20) - .frame(width: 480) - .onAppear { - if let fav = favorite { - name = fav.name - query = fav.query - keyword = fav.keyword ?? "" - isGlobal = fav.connectionId == nil - selectedFolderId = fav.folderId - } else { - selectedFolderId = folderId - if let q = initialQuery { - query = q - } - if name.isEmpty && !query.isEmpty { - name = SQLFavorite.autoName(from: query) + + Toggle(isOn: $isGlobal) { + VStack(alignment: .leading, spacing: 2) { + Text("Global") + Text(String(localized: "Available in all connections")) + .font(.caption) + .foregroundStyle(.secondary) } } - focusedField = .name - if folders.isEmpty { - Task { - loadedFolders = await SQLFavoriteManager.shared.fetchFolders(connectionId: connectionId) - } + .toggleStyle(.checkbox) + .onChange(of: isGlobal) { + revalidateKeyword() } } } - // MARK: - Validation + private var buttonBar: some View { + HStack { + Spacer() - private func validateKeyword(_ value: String) { - let trimmed = value.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { - keywordError = nil - return + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(isEditing ? String(localized: "Save") : String(localized: "Add")) { + save() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isSaving) } - if trimmed.contains(" ") { - isKeywordWarning = false - keywordError = String(localized: "Keyword cannot contain spaces") - return + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private func populateFields() { + if let fav = favorite { + name = fav.name + query = fav.query + keywordField.keyword = fav.keyword ?? "" + isGlobal = fav.connectionId == nil + selectedFolderId = fav.folderId + } else { + selectedFolderId = folderId + if let q = initialQuery { + query = q + } + if name.isEmpty && !query.isEmpty { + name = SQLFavorite.autoName(from: query) + } } - validationId += 1 - let currentId = validationId - Task { @MainActor in - let scopeConnectionId = isGlobal ? nil : connectionId - let available = await SQLFavoriteManager.shared.isKeywordAvailable( - trimmed, - connectionId: scopeConnectionId, + } + + private func revalidateKeyword() { + Task { + await keywordField.validate( + connectionId: isGlobal ? nil : connectionId, excludingFavoriteId: favorite?.id ) - guard currentId == validationId else { return } - if !available { - isKeywordWarning = false - keywordError = String(localized: "This keyword is already in use") - } else { - let sqlKeywords: Set = [ - "select", "from", "where", "insert", "update", "delete", - "create", "drop", "alter", "join", "on", "and", "or", - "not", "in", "like", "between", "order", "group", "having", - "limit", "set", "values", "into", "as", "is", "null", - "true", "false", "case", "when", "then", "else", "end" - ] - if sqlKeywords.contains(trimmed.lowercased()) { - isKeywordWarning = true - keywordError = String( - format: String(localized: "Shadows the SQL keyword '%@'"), - trimmed.uppercased() - ) - } else { - isKeywordWarning = false - keywordError = nil - } - } } } @@ -218,7 +215,7 @@ internal struct FavoriteEditDialog: View { private func save() { isSaving = true let trimmedName = name.trimmingCharacters(in: .whitespaces) - let trimmedKeyword = keyword.trimmingCharacters(in: .whitespaces) + let trimmedKeyword = keywordField.trimmedKeyword let trimmedQuery: String if (query as NSString).length > Self.maxQuerySize { trimmedQuery = String(query.prefix(Self.maxQuerySize)) diff --git a/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift b/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift index 0a31108c0..fbc5fba2b 100644 --- a/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift +++ b/TablePro/Views/Sidebar/LinkedFavoriteMetadataDialog.swift @@ -12,133 +12,132 @@ internal struct LinkedFavoriteMetadataDialog: View { @Environment(\.dismiss) private var dismiss @State private var name: String = "" - @State private var keyword: String = "" + @State private var keywordField = SQLFavoriteKeywordField() @State private var fileDescription: String = "" - @State private var keywordError: String? - @State private var isKeywordWarning = false - @State private var validationId = 0 @State private var isSaving = false @State private var saveError: String? @FocusState private var nameFocused: Bool - private var trimmedKeyword: String { - keyword.trimmingCharacters(in: .whitespaces) - } - private var isValid: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty - && (keywordError == nil || isKeywordWarning) + SQLFavoriteEditValidation.canSave( + isNameBlank: !name.contains { !$0.isWhitespace }, + keywordValidation: keywordField.validation + ) } var body: some View { - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(String(localized: "Edit Metadata")) - .font(.headline) - Text(favorite.relativePath) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 0) { + titleRow + + Divider() Form { - TextField(String(localized: "Name"), text: $name) - .focused($nameFocused) - TextField(String(localized: "Keyword"), text: $keyword) - .onChange(of: keyword) { _, newValue in - validateKeyword(newValue) - } - if let error = keywordError { - LabeledContent {} label: { - Text(error) - .foregroundStyle(isKeywordWarning ? .orange : .red) - .font(.callout) - } - } - TextField(String(localized: "Description"), text: $fileDescription, axis: .vertical) - .lineLimit(2...4) + identitySection + keywordSection } - .formStyle(.columns) + .formStyle(.grouped) if let saveError { - Label(saveError, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) + Divider() + errorBanner(saveError) } - HStack { - Spacer() - Button(String(localized: "Cancel")) { - dismiss() - } - .keyboardShortcut(.cancelAction) + Divider() - Button(String(localized: "Save")) { - save() - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .disabled(!isValid || isSaving) - } + buttonBar } - .padding(20) - .frame(width: 460) + .frame(width: 480, height: 400) .onAppear { name = favorite.name - keyword = favorite.keyword ?? "" + keywordField.keyword = favorite.keyword ?? "" fileDescription = favorite.fileDescription ?? "" nameFocused = true } } - private func validateKeyword(_ value: String) { - let trimmed = value.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { - keywordError = nil - return + private var titleRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Edit Metadata")) + .font(.headline) + Text(favorite.relativePath) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) } - if trimmed.contains(" ") { - isKeywordWarning = false - keywordError = String(localized: "Keyword cannot contain spaces") - return + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private var identitySection: some View { + Section { + TextField(String(localized: "Name"), text: $name) + .focused($nameFocused) + TextField(String(localized: "Description"), text: $fileDescription, axis: .vertical) + .lineLimit(2...4) } - validationId += 1 - let currentId = validationId - Task { @MainActor in - let available = await SQLFavoriteManager.shared.isKeywordAvailable( - trimmed, - connectionId: connectionId, - excludingFavoriteId: nil - ) - guard currentId == validationId else { return } - if !available { - isKeywordWarning = false - keywordError = String(localized: "This keyword is already in use") - return + } + + private var keywordSection: some View { + Section { + TextField(String(localized: "Keyword"), text: $keywordField.keyword) + .onChange(of: keywordField.keyword) { + revalidateKeyword() + } + + if let message = keywordField.validation.displayText { + Text(message) + .font(.callout) + .foregroundStyle(keywordField.validation.isWarning ? Color.orange : Color.red) + } + } + } + + private func errorBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(message) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(2) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + + private var buttonBar: some View { + HStack { + Spacer() + + Button(String(localized: "Cancel")) { + dismiss() } - let sqlKeywords: Set = [ - "select", "from", "where", "insert", "update", "delete", - "create", "drop", "alter", "join", "on", "and", "or", - "not", "in", "like", "between", "order", "group", "having", - "limit", "set", "values", "into", "as", "is", "null", - "true", "false", "case", "when", "then", "else", "end" - ] - if sqlKeywords.contains(trimmed.lowercased()) { - isKeywordWarning = true - keywordError = String(format: String(localized: "Shadows the SQL keyword '%@'"), trimmed.uppercased()) - } else { - isKeywordWarning = false - keywordError = nil + .keyboardShortcut(.cancelAction) + + Button(String(localized: "Save")) { + save() } + .keyboardShortcut(.defaultAction) + .disabled(!isValid || isSaving) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private func revalidateKeyword() { + Task { + await keywordField.validate(connectionId: connectionId, excludingFavoriteId: nil) } } private func save() { isSaving = true let trimmedName = name.trimmingCharacters(in: .whitespaces) + let trimmedKeyword = keywordField.trimmedKeyword let trimmedDescription = fileDescription.trimmingCharacters(in: .whitespaces) let metadata = SQLFrontmatter.Metadata( diff --git a/TableProTests/Core/Storage/SQLFavoriteEditValidationTests.swift b/TableProTests/Core/Storage/SQLFavoriteEditValidationTests.swift new file mode 100644 index 000000000..0c74e2afa --- /dev/null +++ b/TableProTests/Core/Storage/SQLFavoriteEditValidationTests.swift @@ -0,0 +1,160 @@ +// +// SQLFavoriteEditValidationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SQLFavoriteKeywordValidator") +struct SQLFavoriteKeywordValidatorTests { + @Test("Empty keyword is valid regardless of availability") + func emptyKeywordIsValid() { + #expect(SQLFavoriteKeywordValidator.classify(trimmedKeyword: "", isAvailable: true) == .valid) + #expect(SQLFavoriteKeywordValidator.classify(trimmedKeyword: "", isAvailable: false) == .valid) + } + + @Test("Keyword containing a space is an error regardless of availability") + func spaceIsError() { + let available = SQLFavoriteKeywordValidator.classify(trimmedKeyword: "my keyword", isAvailable: true) + let unavailable = SQLFavoriteKeywordValidator.classify(trimmedKeyword: "my keyword", isAvailable: false) + #expect(available.blocksSave) + #expect(unavailable.blocksSave) + #expect(!available.isWarning) + #expect(available.displayText != nil) + } + + @Test("Unavailable keyword is an error") + func unavailableIsError() { + let result = SQLFavoriteKeywordValidator.classify(trimmedKeyword: "sel1", isAvailable: false) + #expect(result.blocksSave) + #expect(!result.isWarning) + #expect(result.displayText != nil) + } + + @Test("Reserved SQL keyword warns without blocking", arguments: ["select", "SELECT", "Select", "limit"]) + func reservedKeywordWarns(keyword: String) { + let result = SQLFavoriteKeywordValidator.classify(trimmedKeyword: keyword, isAvailable: true) + #expect(result.isWarning) + #expect(!result.blocksSave) + #expect(result.displayText?.contains(keyword.uppercased()) == true) + } + + @Test("Available non-reserved keyword is valid") + func availableKeywordIsValid() { + #expect(SQLFavoriteKeywordValidator.classify(trimmedKeyword: "selusers", isAvailable: true) == .valid) + } + + @Test("Availability check is required only for well-formed keywords") + func requiresAvailabilityCheck() { + #expect(!SQLFavoriteKeywordValidator.requiresAvailabilityCheck("")) + #expect(!SQLFavoriteKeywordValidator.requiresAvailabilityCheck("my keyword")) + #expect(SQLFavoriteKeywordValidator.requiresAvailabilityCheck("sel1")) + } +} + +@Suite("SQLFavoriteEditValidation") +struct SQLFavoriteEditValidationTests { + @Test("Blank name blocks save") + func blankNameBlocks() { + #expect(!SQLFavoriteEditValidation.canSave(isNameBlank: true, isQueryBlank: false, keywordValidation: .valid)) + } + + @Test("Blank query blocks save") + func blankQueryBlocks() { + #expect(!SQLFavoriteEditValidation.canSave(isNameBlank: false, isQueryBlank: true, keywordValidation: .valid)) + } + + @Test("Keyword error blocks save") + func keywordErrorBlocks() { + #expect(!SQLFavoriteEditValidation.canSave( + isNameBlank: false, + isQueryBlank: false, + keywordValidation: .error("taken") + )) + } + + @Test("Keyword warning does not block save") + func keywordWarningAllows() { + #expect(SQLFavoriteEditValidation.canSave( + isNameBlank: false, + isQueryBlank: false, + keywordValidation: .warning("shadows") + )) + } + + @Test("Valid fields allow save") + func validFieldsAllow() { + #expect(SQLFavoriteEditValidation.canSave(isNameBlank: false, isQueryBlank: false, keywordValidation: .valid)) + #expect(SQLFavoriteEditValidation.canSave(isNameBlank: false, keywordValidation: .valid)) + } +} + +@MainActor +@Suite("SQLFavoriteKeywordField") +struct SQLFavoriteKeywordFieldTests { + @Test("Validation reflects the availability check result") + func reflectsAvailability() async { + let field = SQLFavoriteKeywordField { _, _, _ in false } + field.keyword = "sel1" + await field.validate(connectionId: nil, excludingFavoriteId: nil) + #expect(field.validation.blocksSave) + + let availableField = SQLFavoriteKeywordField { _, _, _ in true } + availableField.keyword = "sel1" + await availableField.validate(connectionId: nil, excludingFavoriteId: nil) + #expect(availableField.validation == .valid) + } + + @Test("Malformed keywords skip the availability check") + func malformedSkipsAvailabilityCheck() async { + let field = SQLFavoriteKeywordField { _, _, _ in + Issue.record("availability check should not run") + return true + } + field.keyword = "my keyword" + await field.validate(connectionId: nil, excludingFavoriteId: nil) + #expect(field.validation.blocksSave) + + field.keyword = " " + await field.validate(connectionId: nil, excludingFavoriteId: nil) + #expect(field.validation == .valid) + } + + @Test("Keyword is trimmed before validation") + func trimsKeyword() async { + let field = SQLFavoriteKeywordField { keyword, _, _ in keyword == "sel1" } + field.keyword = " sel1 " + await field.validate(connectionId: nil, excludingFavoriteId: nil) + #expect(field.validation == .valid) + } + + @Test("Stale availability responses are discarded") + func staleResponseDiscarded() async { + let (enteredStream, enteredContinuation) = AsyncStream.makeStream(of: Void.self) + let (releaseStream, releaseContinuation) = AsyncStream.makeStream(of: Bool.self) + + let field = SQLFavoriteKeywordField { keyword, _, _ in + guard keyword == "slowkw" else { return true } + enteredContinuation.yield() + var iterator = releaseStream.makeAsyncIterator() + return await iterator.next() ?? true + } + + field.keyword = "slowkw" + let slowValidation = Task { + await field.validate(connectionId: nil, excludingFavoriteId: nil) + } + var enteredIterator = enteredStream.makeAsyncIterator() + await enteredIterator.next() + + field.keyword = "fastkw" + await field.validate(connectionId: nil, excludingFavoriteId: nil) + #expect(field.validation == .valid) + + releaseContinuation.yield(false) + await slowValidation.value + #expect(field.validation == .valid) + } +}