diff --git a/CHANGELOG.md b/CHANGELOG.md index a158def98d..abf1321c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JSON and PHP tree values use the editor's syntax colors, matching the same value shown as text. - Theme file format 3, where every color is optional. Themes written for earlier versions are not read and need to be recreated. - Triggers and ClickHouse Parts in table structure shown as data grids, with multiple selection and Copy. -- Filter and maintenance SQL previews highlighted as SQL in the editor font. -- Result bar items separated by space instead of a dot. +- Filter and maintenance previews highlighted in the editor font, DynamoDB's as JSON. - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 2e352fa772..97dcbca123 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -582,7 +582,11 @@ extension FilterCoordinator: FilterPanelActions { } extension FilterCoordinator: FilterSQLPreviewing { + var filterPreviewDatabaseType: DatabaseType { + parent.connection.type + } + func filterPreviewSQL() -> String { - generateFilterPreviewSQL(databaseType: parent.connection.type) + generateFilterPreviewSQL(databaseType: filterPreviewDatabaseType) } } diff --git a/TablePro/Models/UI/DataGridConfiguration.swift b/TablePro/Models/UI/DataGridConfiguration.swift index f70ccc3be5..adef635396 100644 --- a/TablePro/Models/UI/DataGridConfiguration.swift +++ b/TablePro/Models/UI/DataGridConfiguration.swift @@ -56,4 +56,7 @@ struct DataGridConfiguration: Equatable { /// Why these rows cannot be written back, when they cannot. A grid that silently refuses every /// keystroke reads as broken, so the reason rides with the configuration and the grid shows it. var editRefusalMessage: String? + + /// Keyed by heading; a column not named here keeps `DataGridMetrics.dataColumnMinWidth`. + var columnMinimumWidths: [String: CGFloat] = [:] } diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index f17a68b71c..e92117ef8d 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -18,8 +18,14 @@ struct HighlightedSQLTextView: NSViewRepresentable { case fitted(maxLines: Int) } + internal enum Highlighting: Equatable { + case engine + case json + } + let sql: String var databaseType: DatabaseType = .mysql + var highlighting: Highlighting = .engine /// A SwiftUI `.accessibilityIdentifier` lands on the representable's wrapper, not on the text /// view AppKit publishes, so the only way to name this element is to set it on the text view. var accessibilityIdentifier: String? @@ -103,12 +109,15 @@ struct HighlightedSQLTextView: NSViewRepresentable { textView.string = sql } - /// The highlighting bakes a colour per range, so it is applied again whenever the theme or - /// the editor font moves, not only when the query does. - guard !sql.isEmpty, textChanged || fontChanged || context.coordinator.revision != themeRevision else { + /// The highlighting bakes a colour per range, so it is applied again whenever the theme, the + /// editor font or the highlighting moves, not only when the query does. + let highlightingChanged = context.coordinator.highlighting != highlighting + guard !sql.isEmpty, + textChanged || fontChanged || highlightingChanged || context.coordinator.revision != themeRevision else { return } context.coordinator.revision = themeRevision + context.coordinator.highlighting = highlighting applyHighlighting(to: textView) } @@ -148,6 +157,7 @@ struct HighlightedSQLTextView: NSViewRepresentable { @MainActor final class Coordinator { var revision = -1 + var highlighting: Highlighting? private var themeCancellable: AnyCancellable? /// An Accent, Highlight or contrast change moves no published value, so nothing would otherwise redraw the @@ -242,6 +252,28 @@ struct HighlightedSQLTextView: NSViewRepresentable { return patterns }() + /// The JSON editor's colors: keys and values both as strings. Strings go last so a digit or a literal inside one stays string-colored. + private static let jsonPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { + let sources: [(pattern: String, slot: ThemeSlot)] = [ + (#"-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b"#, .syntaxNumber), + (#"\b(?:true|false|null)\b"#, .syntaxNull), + (#""(?:[^"\\]|\\.)*""#, .syntaxString), + ] + return sources.compactMap { source in + (try? NSRegularExpression(pattern: source.pattern)).map { (regex: $0, slot: source.slot) } + } + }() + + private var activePatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] { + if highlighting == .json { return Self.jsonPatterns } + switch PluginManager.shared.editorLanguage(for: databaseType) { + case .javascript: + return Self.mqlPatterns + default: + return Self.syntaxPatterns + } + } + private func applyHighlighting(to textView: NSTextView) { guard let textStorage = textView.textStorage else { return } guard textStorage.length > 0 else { return } @@ -254,13 +286,6 @@ struct HighlightedSQLTextView: NSViewRepresentable { textStorage.addAttribute(.font, value: font, range: fullRange) textStorage.addAttribute(.foregroundColor, value: ThemeEngine.shared.palette[.editorText], range: fullRange) - let activePatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] - switch PluginManager.shared.editorLanguage(for: databaseType) { - case .javascript: - activePatterns = Self.mqlPatterns - default: - activePatterns = Self.syntaxPatterns - } let text = textStorage.string let maxHighlightLength = 10_000 let highlightRange: NSRange diff --git a/TablePro/Views/Filter/FilterPanelHosting.swift b/TablePro/Views/Filter/FilterPanelHosting.swift index 0f994f5aa7..1e35f2230f 100644 --- a/TablePro/Views/Filter/FilterPanelHosting.swift +++ b/TablePro/Views/Filter/FilterPanelHosting.swift @@ -31,6 +31,7 @@ internal extension FilterPanelActions { @MainActor internal protocol FilterSQLPreviewing: AnyObject { + var filterPreviewDatabaseType: DatabaseType { get } func filterPreviewSQL() -> String } diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 79b8a86560..a90f61c2b2 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -48,7 +48,9 @@ struct FilterPanelView: View { focusedFilterId = addDefaultFilter(columns: newColumns) } .sheet(isPresented: $showSQLSheet) { - SQLPreviewSheet(sql: generatedSQL) + if let sqlPreview = configuration.sqlPreview { + SQLPreviewSheet(sql: generatedSQL, databaseType: sqlPreview.filterPreviewDatabaseType) + } } } diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index f00b755ca3..8ffaf1a8db 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -7,6 +7,7 @@ import SwiftUI struct SQLPreviewSheet: View { let sql: String + let databaseType: DatabaseType @Environment(\.dismiss) private var dismiss @State private var copied = false @@ -16,7 +17,7 @@ struct SQLPreviewSheet: View { .font(.body.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) - HighlightedSQLTextView(sql: sql.isEmpty ? "(no conditions)" : sql, lineLayout: .wrapped) + HighlightedSQLTextView(sql: sql.isEmpty ? "(no conditions)" : sql, databaseType: databaseType, lineLayout: .wrapped) .frame(maxHeight: 180) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 9dcd1d7494..1167853cf8 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -89,6 +89,7 @@ final class DataGridColumnPool { firstClickSortDirection: SortDirection, supportsColumnSort: Bool = true, supportsValueFilter: Bool = true, + minimumWidths: [String: CGFloat] = [:], widthCalculator: (String, Int) -> CGFloat ) -> Bool { attach(to: tableView) @@ -117,6 +118,7 @@ final class DataGridColumnPool { columnType: slot < columnTypes.count ? columnTypes[slot] : nil, comment: comment, width: resolvedWidth, + minWidth: minimumWidths[columnName] ?? DataGridMetrics.dataColumnMinWidth, isEditable: isEditable, firstClickSortDirection: firstClickSortDirection, supportsColumnSort: supportsColumnSort, @@ -264,6 +266,7 @@ final class DataGridColumnPool { columnType: ColumnType?, comment: String?, width: CGFloat, + minWidth: CGFloat, isEditable: Bool, firstClickSortDirection: SortDirection, supportsColumnSort: Bool = true, @@ -295,9 +298,7 @@ final class DataGridColumnPool { column.headerCell.setAccessibilityLabel(label) } - if column.width != width { - column.width = width - } + applyWidth(max(width, minWidth), minWidth: minWidth, to: column) if column.isEditable != isEditable { column.isEditable = isEditable } @@ -314,6 +315,19 @@ final class DataGridColumnPool { } } + /// Raising `minWidth` past `width` moves the width without updating the table's column geometry, so the width goes in first. + private func applyWidth(_ width: CGFloat, minWidth: CGFloat, to column: NSTableColumn) { + if column.minWidth > minWidth { + column.minWidth = minWidth + } + if column.width != width { + column.width = width + } + if column.minWidth != minWidth { + column.minWidth = minWidth + } + } + private func accessibilityLabel(name: String, comment: String?) -> String { let label = String(format: String(localized: "Column: %@"), name) guard let comment else { return label } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 8d260cd95b..02a9a325a7 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -549,6 +549,7 @@ struct DataGridView: NSViewRepresentable { firstClickSortDirection: coordinator.firstClickSortDirection, supportsColumnSort: configuration.supportsColumnSort, supportsValueFilter: configuration.supportsValueFilter, + minimumWidths: configuration.columnMinimumWidths, widthCalculator: { columnName, slot in coordinator.automaticColumnWidth( for: columnName, diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index f444c19195..2912d606af 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -57,6 +57,8 @@ protocol DataGridViewDelegate: AnyObject { /// A checkbox cell's state, or nil when that row offers no checkbox. func dataGridCheckboxState(row: Int, column: Int) -> Bool? func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) + /// What an assistive client reads for a cell in place of its drawn text, or nil to read the text. + func dataGridAccessibilityText(row displayRow: Int, column: Int) -> String? /// Nil lets the grid derive the rules from its column types and display formats. func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? } @@ -68,6 +70,7 @@ extension DataGridViewDelegate { func dataGridMenuOptions(forRow row: Int, columnIndex: Int) -> [GridMenuOption]? { nil } func dataGridCheckboxState(row: Int, column: Int) -> Bool? { nil } func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) {} + func dataGridAccessibilityText(row displayRow: Int, column: Int) -> String? { nil } func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? { nil } func dataGridDidEditCell(row: Int, column: Int, newValue: String?) {} func dataGridDeleteRows(_ indices: Set) {} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 9af1e3b37e..5e89def5a7 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -114,6 +114,9 @@ extension TableViewCoordinator { let tableRows = tableRowsProvider() guard let displayRow = displayRow(at: row, in: tableRows), columnIndex < displayRow.values.count else { return nil } + if let spoken = delegate?.dataGridAccessibilityText(row: row, column: columnIndex) { + return spoken + } let rawValue = displayRow.values[columnIndex] let columnType = columnIndex < tableRows.columnTypes.count ? tableRows.columnTypes[columnIndex] : nil let text = displayValue( diff --git a/TablePro/Views/Results/ResultSetMenu.swift b/TablePro/Views/Results/ResultSetMenu.swift index e881f9d29e..4dbb570fd3 100644 --- a/TablePro/Views/Results/ResultSetMenu.swift +++ b/TablePro/Views/Results/ResultSetMenu.swift @@ -5,14 +5,7 @@ import SwiftUI -/// The control that chooses which result set the pane shows. -/// -/// A pull-down in the status bar's leading zone, beside the view-mode switcher, which is where -/// Postico 2 puts the same control and where Script Editor puts its Description/Result/Log -/// selector. It costs no band of its own, which is the point: the strip it replaces spent 32pt in -/// every query tab, including the overwhelmingly common one with a single result. -/// -/// Pin, Unpin, Close and Close Others move across unchanged from the strip's context menu. +/// A pull-down in the status bar rather than a strip of its own, which spent 32pt in every query tab, even with one result. struct ResultSetMenu: View { let model: ResultSetMenuModel /// Spelled out where the bar has room, counted in figures where it does not. diff --git a/TablePro/Views/Sidebar/MaintenanceSheet.swift b/TablePro/Views/Sidebar/MaintenanceSheet.swift index f12667ee96..b6f758e405 100644 --- a/TablePro/Views/Sidebar/MaintenanceSheet.swift +++ b/TablePro/Views/Sidebar/MaintenanceSheet.swift @@ -8,7 +8,7 @@ import TableProPluginKit /// Confirms a maintenance operation and shows the statements that will run. /// -/// The preview is the driver's own SQL, handed in as a closure, and the Execute button sends the same +/// The preview is the driver's own statements, handed in as a closure, and the Execute button sends the same /// option values back through the same builder. It used to write its own copy of the statement and /// disagree with it: `REINDEX orders`, which is not valid SQL, where `REINDEX TABLE "orders"` ran. /// The option controls come from the operation for the same reason, rather than from a switch gated @@ -58,17 +58,7 @@ struct MaintenanceSheet: View { } } - VStack(alignment: .leading, spacing: 4) { - Text(String(localized: "SQL Preview")) - .font(.caption) - .foregroundStyle(.secondary) - HighlightedSQLTextView( - sql: sqlPreview, - databaseType: databaseType, - lineLayout: .fitted(maxLines: Self.previewMaxLines) - ) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } + previewSection Divider() @@ -89,6 +79,24 @@ struct MaintenanceSheet: View { .frame(width: 420) } + private var previewSection: some View { + let statements = preview(values) + let text = statements.joined(separator: ";\n") + let syntax = Self.previewSyntax(of: statements) + return VStack(alignment: .leading, spacing: 4) { + Text(syntax == .json ? String(localized: "Preview") : String(localized: "SQL Preview")) + .font(.caption) + .foregroundStyle(.secondary) + HighlightedSQLTextView( + sql: text, + databaseType: databaseType, + highlighting: syntax, + lineLayout: .fitted(maxLines: Self.previewMaxLines) + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + private var header: some View { HStack { Image(systemName: "wrench.and.screwdriver") @@ -147,4 +155,18 @@ struct MaintenanceSheet: View { } internal static let previewMaxLines = 8 + + internal static func previewSyntax(of statements: [String]) -> HighlightedSQLTextView.Highlighting { + guard !statements.isEmpty, statements.allSatisfy(isJSONRequest) else { return .engine } + return .json + } + + /// An API driver writes a statement as an action name and its JSON request body, which the SQL patterns barely color. + private static func isJSONRequest(_ statement: String) -> Bool { + let trimmed = statement.trimmingCharacters(in: .whitespacesAndNewlines) + let action = trimmed.prefix { $0.isLetter } + let body = trimmed.dropFirst(action.count).drop { $0.isWhitespace } + guard !action.isEmpty, body.first == "{" else { return false } + return (try? JSONSerialization.jsonObject(with: Data(body.utf8))) is [String: Any] + } } diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index e2545ca3eb..f420ef8e64 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -2,8 +2,6 @@ // ClickHousePartsView.swift // TablePro // -// Displays ClickHouse partition/part information from system.parts. -// import os import SwiftUI @@ -101,6 +99,7 @@ struct ClickHousePartsView: View { model: grid, columns: Self.columnTitles, defaultWidths: Self.columnWidths, + minimumWidths: Self.columnMinimumWidths, contentKey: hasher.finalize(), rows: { parts.map(Self.gridRow(for:)) }, databaseType: connection.type, @@ -112,6 +111,8 @@ struct ClickHousePartsView: View { } internal static let columnWidths: [CGFloat] = [120, 200, 100, 100, 160, 60] + internal static let columnMinimumWidths: [CGFloat] = [80, 100, 60, 60, 100, 50] + internal static let activeColumn = 5 static var columnTitles: [String] { [ @@ -134,7 +135,8 @@ struct ClickHousePartsView: View { ByteSizeFormatting.string(bytes: part.bytesOnDisk), part.modificationTime, part.active ? "YES" : "NO", - ] + ], + spokenValues: [activeColumn: part.active ? String(localized: "Active") : String(localized: "Inactive")] ) } diff --git a/TablePro/Views/Structure/StructureListGrid.swift b/TablePro/Views/Structure/StructureListGrid.swift index 7a4da1484b..5e88c21ca1 100644 --- a/TablePro/Views/Structure/StructureListGrid.swift +++ b/TablePro/Views/Structure/StructureListGrid.swift @@ -11,6 +11,8 @@ import TableProPluginKit internal struct StructureListRow: Hashable { internal let id: ID internal let values: [String] + /// Keyed by column, for a cell whose drawn text reads wrong aloud, such as YES for Enabled. + internal var spokenValues: [Int: String] = [:] } /// Selected row ids held by reference: a read made during a SwiftUI update gets a `State` or `Binding` snapshot. @@ -29,6 +31,7 @@ internal final class StructureListGridModel: ObservableObject, Dat internal private(set) var tableRows = TableRows() internal private(set) var rowIDs: [ID] = [] + private var spokenValues: [[Int: String]] = [] private var loadedKey: Int? private weak var coordinator: TableViewCoordinator? @@ -46,6 +49,7 @@ internal final class StructureListGridModel: ObservableObject, Dat let listed = rows() rowIDs = listed.map(\.id) + spokenValues = listed.map(\.spokenValues) tableRows = TableRows.from( queryRows: listed.map { $0.values.map(PluginCellValue.text) }, columns: columns, @@ -107,6 +111,10 @@ internal final class StructureListGridModel: ObservableObject, Dat tableViewCoordinator.hasRestoredSelection = false } + internal func dataGridAccessibilityText(row: Int, column: Int) -> String? { + rowIndex(forDisplayRow: row).flatMap { spokenValues[$0][column] } + } + internal func dataGridSortStateChanged(_ state: SortState) { onSortStateChanged?(state) } @@ -121,11 +129,12 @@ internal final class StructureListGridModel: ObservableObject, Dat } } -/// A nil `sortState` turns header sorting off. `defaultWidths` follows `columns` by index. +/// A nil `sortState` turns header sorting off. `defaultWidths` and `minimumWidths` follow `columns` by index. internal struct StructureListGrid: View { internal let model: StructureListGridModel internal let columns: [String] internal var defaultWidths: [CGFloat] = [] + internal var minimumWidths: [CGFloat] = [] internal let contentKey: Int internal let rows: () -> [StructureListRow] internal let databaseType: DatabaseType @@ -146,6 +155,10 @@ internal struct StructureListGrid: View { return filled } + internal static func minimumWidths(_ widths: [CGFloat], columns: [String]) -> [String: CGFloat] { + Dictionary(zip(columns, widths), uniquingKeysWith: { first, _ in first }) + } + internal var body: some View { let model = model let columns = columns @@ -165,7 +178,8 @@ internal struct StructureListGrid: View { databaseType: databaseType, supportsColumnCommands: false, supportsValueFilter: false, - supportsColumnSort: sortState != nil + supportsColumnSort: sortState != nil, + columnMinimumWidths: Self.minimumWidths(minimumWidths, columns: columns) ), delegate: model, selectedRowIndices: model.selection(get: selectedIDs, set: select), diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 0fa09b5fba..bbeb726147 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -2,8 +2,6 @@ // TriggerDetailView.swift // TablePro // -// Read-only master-detail view of a table's triggers. -// import Combine import SwiftUI @@ -47,6 +45,7 @@ final class TriggerInspectorState: ObservableObject { enum TriggerListColumns { static let nameColumn = 0 + static let enabledColumn = 3 static var defaultSort: SortState { SortState( @@ -64,12 +63,21 @@ enum TriggerListColumns { showsEnabled ? [220, 90, 150, 70] : [240, 90, 160] } + internal static func minimumWidths(showsEnabled: Bool) -> [CGFloat] { + showsEnabled ? [140, 70, 90, 60] : [140, 70, 90] + } + static func values(for trigger: TriggerInfo, showsEnabled: Bool) -> [String] { let base = [trigger.name, trigger.timing, trigger.event] guard showsEnabled else { return base } return base + [trigger.enabled.map { $0 ? "YES" : "NO" } ?? ""] } + static func spokenValues(for trigger: TriggerInfo, showsEnabled: Bool) -> [Int: String] { + guard showsEnabled, let enabled = trigger.enabled else { return [:] } + return [enabledColumn: enabled ? String(localized: "Enabled") : String(localized: "Disabled")] + } + static func sorted(_ triggers: [TriggerInfo], by state: SortState) -> [TriggerInfo] { guard !state.columns.isEmpty else { return triggers } let keyed = triggers.enumerated().map { offset, trigger in @@ -295,7 +303,11 @@ internal struct TriggerListPane: View { let showsEnabled = showsEnabled let inspector = state let rows = inspector.displayed(triggers).map { trigger in - StructureListRow(id: trigger.id, values: TriggerListColumns.values(for: trigger, showsEnabled: showsEnabled)) + StructureListRow( + id: trigger.id, + values: TriggerListColumns.values(for: trigger, showsEnabled: showsEnabled), + spokenValues: TriggerListColumns.spokenValues(for: trigger, showsEnabled: showsEnabled) + ) } var hasher = Hasher() hasher.combine(rows) @@ -303,6 +315,7 @@ internal struct TriggerListPane: View { model: grid, columns: TriggerListColumns.titles(showsEnabled: showsEnabled), defaultWidths: TriggerListColumns.widths(showsEnabled: showsEnabled), + minimumWidths: TriggerListColumns.minimumWidths(showsEnabled: showsEnabled), contentKey: hasher.finalize(), rows: { rows }, databaseType: databaseType, diff --git a/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift b/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift index 5f4edd7364..a8d028ce54 100644 --- a/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift +++ b/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift @@ -17,6 +17,9 @@ struct HighlightedSQLTextViewLayoutTests { private static let longStatement = #"VACUUM (FULL, FREEZE, VERBOSE, ANALYZE) "public"."orders_with_a_very_long_table_name_for_testing""# + private static let longRequest = + #"UpdateContinuousBackups {"PointInTimeRecoverySpecification":{"PointInTimeRecoveryEnabled":false},"TableName":"orders_with_a_very_long_table_name"}"# + private static let maintenanceSize = NSSize(width: 420, height: 520) private var lineHeight: CGFloat { @@ -54,7 +57,7 @@ struct HighlightedSQLTextViewLayoutTests { @Test("A long filter clause wraps inside the preview instead of running past its edge") func filterPreviewWraps() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .light) { - let window = ThemeTestFixture.host(SQLPreviewSheet(sql: Self.longClause), size: NSSize(width: 480, height: 300)) + let window = ThemeTestFixture.host(SQLPreviewSheet(sql: Self.longClause, databaseType: .mysql), size: NSSize(width: 480, height: 300)) let textView = try #require(ThemeTestFixture.textViews(in: window).first) let scrollView = try #require(textView.enclosingScrollView) @@ -92,6 +95,28 @@ struct HighlightedSQLTextViewLayoutTests { } } + @Test("A JSON request wraps in the same fitted preview and shows all of it") + func maintenancePreviewFitsJSONRequest() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host( + maintenanceSheet([Self.longRequest], databaseType: .dynamodb), + size: Self.maintenanceSize + ) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + let scrollView = try #require(textView.enclosingScrollView) + let insets = textView.textContainerInset.height * 2 + + #expect(textView.string == Self.longRequest) + #expect(scrollView.frame.height > lineHeight * 2 + insets) + #expect(scrollView.frame.height < lineHeight * CGFloat(MaintenanceSheet.previewMaxLines) + insets) + + let used = usedSize(of: textView) + #expect(used.width <= textWidth(in: scrollView, textView: textView) + 0.5) + #expect(used.height + insets <= scrollView.contentSize.height + 0.5) + withExtendedLifetime(window) {} + } + } + @Test("A preview taller than its cap stops at the cap and scrolls vertically") func maintenancePreviewCapsAndScrolls() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .light) { diff --git a/TableProTests/Views/Results/DataGridColumnPoolTests.swift b/TableProTests/Views/Results/DataGridColumnPoolTests.swift index 4f226e13a6..99e518fce8 100644 --- a/TableProTests/Views/Results/DataGridColumnPoolTests.swift +++ b/TableProTests/Views/Results/DataGridColumnPoolTests.swift @@ -449,6 +449,79 @@ struct DataGridColumnPoolTests { } } + private func minimumWidthsByName(in tableView: NSTableView) -> [String: CGFloat] { + Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).map { ($0.headerCell.stringValue, $0.minWidth) }) + } + + @Test("A column named in the minimum widths gets that floor and the others keep the default") + func reconcile_appliesPerColumnMinimumWidths() { + let pool = DataGridColumnPool() + let tableView = makeTableView() + + pool.reconcile( + tableView: tableView, + schema: ColumnIdentitySchema(columns: ["id", "name"]), + columnTypes: makeColumnTypes(count: 2), + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + minimumWidths: ["name": 140], + widthCalculator: defaultWidthCalculator + ) + + #expect(minimumWidthsByName(in: tableView) == ["id": DataGridMetrics.dataColumnMinWidth, "name": 140]) + } + + /// Raising `minWidth` past `width` first moves the width without moving the table's column + /// geometry, so the column would report one width and draw at another. + @Test("A width below the column's floor opens at the floor, and the table's geometry follows") + func reconcile_raisesWidthToTheFloor() throws { + let pool = DataGridColumnPool() + let tableView = makeTableView() + let schema = ColumnIdentitySchema(columns: ["id", "name"]) + + pool.reconcile( + tableView: tableView, schema: schema, columnTypes: makeColumnTypes(count: 2), savedLayout: nil, + isEditable: true, hiddenColumnNames: [], firstClickSortDirection: .ascending, + widthCalculator: defaultWidthCalculator + ) + pool.reconcile( + tableView: tableView, schema: schema, columnTypes: makeColumnTypes(count: 2), savedLayout: nil, + isEditable: true, hiddenColumnNames: [], firstClickSortDirection: .ascending, + minimumWidths: ["name": 140], + widthCalculator: defaultWidthCalculator + ) + + let name = try #require(dataColumns(in: tableView).first { $0.headerCell.stringValue == "name" }) + let index = tableView.column(withIdentifier: name.identifier) + #expect(name.width == 140) + #expect(name.minWidth == 140) + #expect(tableView.rect(ofColumn: index).width == 140 + tableView.intercellSpacing.width) + } + + @Test("A reused slot drops the floor of the result it showed before") + func reconcile_resetsTheFloorOfAReusedSlot() { + let pool = DataGridColumnPool() + let tableView = makeTableView() + + pool.reconcile( + tableView: tableView, schema: ColumnIdentitySchema(columns: ["name"]), columnTypes: makeColumnTypes(count: 1), + savedLayout: nil, isEditable: true, hiddenColumnNames: [], firstClickSortDirection: .ascending, + minimumWidths: ["name": 140], + widthCalculator: defaultWidthCalculator + ) + pool.reconcile( + tableView: tableView, schema: ColumnIdentitySchema(columns: ["id"]), columnTypes: makeColumnTypes(count: 1), + savedLayout: nil, isEditable: true, hiddenColumnNames: [], firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 50 } + ) + + let column = dataColumns(in: tableView).first + #expect(column?.minWidth == DataGridMetrics.dataColumnMinWidth) + #expect(column?.width == 50) + } + @Test("reconcile publishes column comments to the header view") func reconcile_publishesColumnCommentsToHeaderView() throws { let pool = DataGridColumnPool() diff --git a/TableProTests/Views/SQLCodeFieldThemeTests.swift b/TableProTests/Views/SQLCodeFieldThemeTests.swift index 82f4e3ee9b..66cbf78ef5 100644 --- a/TableProTests/Views/SQLCodeFieldThemeTests.swift +++ b/TableProTests/Views/SQLCodeFieldThemeTests.swift @@ -20,7 +20,7 @@ struct SQLCodeFieldThemeTests { @Test("The filter's SQL preview is a code view on the editor background") func filterPreview() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .light) { - let window = ThemeTestFixture.host(SQLPreviewSheet(sql: "id = 1"), size: NSSize(width: 480, height: 300)) + let window = ThemeTestFixture.host(SQLPreviewSheet(sql: "id = 1", databaseType: .mysql), size: NSSize(width: 480, height: 300)) let textView = try #require(ThemeTestFixture.textViews(in: window).first) #expect(textView.string == "id = 1") #expect(!textView.isEditable) @@ -37,7 +37,7 @@ struct SQLCodeFieldThemeTests { @Test("An empty filter still says it has no conditions") func filterPreviewEmpty() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .light) { - let window = ThemeTestFixture.host(SQLPreviewSheet(sql: ""), size: NSSize(width: 480, height: 300)) + let window = ThemeTestFixture.host(SQLPreviewSheet(sql: "", databaseType: .mysql), size: NSSize(width: 480, height: 300)) let textView = try #require(ThemeTestFixture.textViews(in: window).first) #expect(textView.string == "(no conditions)") withExtendedLifetime(window) {} diff --git a/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift b/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift new file mode 100644 index 0000000000..36b6998799 --- /dev/null +++ b/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift @@ -0,0 +1,146 @@ +// +// MaintenancePreviewSyntaxTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Statement previews highlight in the language they are written in", .serialized) +@MainActor +struct MaintenancePreviewSyntaxTests { + private static let updateTable = #"UpdateTable {"TableName":"orders","DeletionProtectionEnabled":true}"# + private static let backups = + #"UpdateContinuousBackups {"PointInTimeRecoverySpecification":{"PointInTimeRecoveryEnabled":false},"TableName":"orders"}"# + + @Test("An action name followed by a JSON request body is previewed as JSON") + func apiRequestsAreJSON() { + #expect(MaintenanceSheet.previewSyntax(of: [Self.updateTable]) == .json) + #expect(MaintenanceSheet.previewSyntax(of: [Self.backups]) == .json) + #expect(MaintenanceSheet.previewSyntax(of: [Self.updateTable, Self.backups]) == .json) + } + + @Test("SQL, shell commands and anything not wholly a JSON request keep the engine's highlighting") + func everythingElseIsTheEngines() { + #expect(MaintenanceSheet.previewSyntax(of: [#"VACUUM (ANALYZE) "orders""#]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: ["OPTIMIZE TABLE `orders`", "ANALYZE TABLE `orders`"]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: ["db.orders.reIndex()"]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: [#"db.runCommand({"compact": "orders"})"#]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: ["UpdateTable {TableName: orders}"]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: [#"UpdateTable ["orders"]"#]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: [#"{"TableName":"orders"}"#]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: [Self.updateTable, "VACUUM orders"]) == .engine) + #expect(MaintenanceSheet.previewSyntax(of: []) == .engine) + } + + private func textView(in window: NSWindow) throws -> NSTextView { + try #require(ThemeTestFixture.textViews(in: window).first) + } + + private func hex(of fragment: String, in textView: NSTextView) throws -> String? { + let range = (textView.string as NSString).range(of: fragment) + try #require(range.location != NSNotFound) + let color = textView.textStorage?.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? NSColor + return ThemeTestFixture.hex(color) + } + + @Test("A JSON request is previewed in the query preview, colored the way the JSON editor colors it") + func jsonRequestIsColoredAsJSON() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let palette = ThemeEngine.shared.palette + let text = ThemeTestFixture.hex(palette[.editorText]) + let string = ThemeTestFixture.hex(palette[.syntaxString]) + let literal = ThemeTestFixture.hex(palette[.syntaxNull]) + try #require(Set([text, string, literal]).count == 3) + + let sheet = MaintenanceSheet( + operation: PluginMaintenanceOperation(name: "Point-in-Time Recovery", appliesTo: [.table], scope: .object), + tableName: "orders", + databaseName: nil, + databaseType: .dynamodb, + preview: { _ in [Self.backups] }, + onExecute: { _ in } + ) + let window = ThemeTestFixture.host(sheet, size: NSSize(width: 420, height: 360)) + let preview = try textView(in: window) + + #expect(preview.string == Self.backups) + #expect(preview.accessibilityLabel() == String(localized: "Query preview")) + #expect(try hex(of: "UpdateContinuousBackups", in: preview) == text) + #expect(try hex(of: #""TableName""#, in: preview) == string) + #expect(try hex(of: #""orders""#, in: preview) == string) + #expect(try hex(of: "false", in: preview) == literal) + withExtendedLifetime(window) {} + } + } + + @Test("JSON highlighting keeps a digit or a literal inside a string string-colored") + func jsonStringsWinOverWhatTheyContain() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let palette = ThemeEngine.shared.palette + let request = #"Scan {"Partition":"2024","Note":"null","Limit":-5,"Ratio":1.5e3,"Next":null}"# + let window = ThemeTestFixture.host( + HighlightedSQLTextView(sql: request, highlighting: .json), + size: NSSize(width: 900, height: 120) + ) + let preview = try textView(in: window) + + #expect(try hex(of: "2024", in: preview) == ThemeTestFixture.hex(palette[.syntaxString])) + #expect(try hex(of: #"null","#, in: preview) == ThemeTestFixture.hex(palette[.syntaxString])) + #expect(try hex(of: "-5", in: preview) == ThemeTestFixture.hex(palette[.syntaxNumber])) + #expect(try hex(of: "1.5e3", in: preview) == ThemeTestFixture.hex(palette[.syntaxNumber])) + #expect(try hex(of: "null}", in: preview) == ThemeTestFixture.hex(palette[.syntaxNull])) + withExtendedLifetime(window) {} + } + } + + @Test("Switching the highlighting on unchanged text colors it again") + func highlightingChangeRecolors() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let palette = ThemeEngine.shared.palette + let host = NSHostingView(rootView: HighlightedSQLTextView(sql: Self.updateTable, databaseType: .mysql)) + host.frame = NSRect(x: 0, y: 0, width: 900, height: 120) + let window = NSWindow(contentRect: host.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = host + host.layoutSubtreeIfNeeded() + let preview = try textView(in: window) + try #require(try hex(of: #""orders""#, in: preview) != ThemeTestFixture.hex(palette[.syntaxString])) + + host.rootView = HighlightedSQLTextView(sql: Self.updateTable, databaseType: .mysql, highlighting: .json) + host.layoutSubtreeIfNeeded() + + #expect(try hex(of: #""orders""#, in: preview) == ThemeTestFixture.hex(palette[.syntaxString])) + withExtendedLifetime(window) {} + } + } + + // MARK: - Filter preview + + @Test("The filter preview highlights with the connection's own patterns") + func filterPreviewUsesTheConnectionsPatterns() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let palette = ThemeEngine.shared.palette + let clause = #"name = "abc""# + + let document = ThemeTestFixture.host( + SQLPreviewSheet(sql: clause, databaseType: .mongodb), + size: NSSize(width: 480, height: 300) + ) + let documentText = try textView(in: document) + #expect(try hex(of: #""abc""#, in: documentText) == ThemeTestFixture.hex(palette[.syntaxString])) + + let relational = ThemeTestFixture.host( + SQLPreviewSheet(sql: clause, databaseType: .mysql), + size: NSSize(width: 480, height: 300) + ) + let relationalText = try textView(in: relational) + #expect(try hex(of: #""abc""#, in: relationalText) == ThemeTestFixture.hex(palette[.editorText])) + withExtendedLifetime((document, relational)) {} + } + } +} diff --git a/TableProTests/Views/Structure/ClickHousePartsGridTests.swift b/TableProTests/Views/Structure/ClickHousePartsGridTests.swift index 4833f207f7..5b88d3fb19 100644 --- a/TableProTests/Views/Structure/ClickHousePartsGridTests.swift +++ b/TableProTests/Views/Structure/ClickHousePartsGridTests.swift @@ -40,12 +40,31 @@ struct ClickHousePartsGridTests { #expect(ClickHousePartsView.gridRow(for: inactive).values.last == "NO") } + @Test("Active reads aloud as Active or Inactive rather than YES or NO") + func activeIsSpokenByName() { + let inactive = ClickHousePartInfo( + partition: "", name: "p", rows: 0, bytesOnDisk: 0, modificationTime: "", active: false + ) + let active = ClickHousePartsView.activeColumn + + #expect(ClickHousePartsView.columnTitles[active] == String(localized: "Active")) + #expect(ClickHousePartsView.gridRow(for: part).spokenValues == [active: String(localized: "Active")]) + #expect(ClickHousePartsView.gridRow(for: inactive).spokenValues == [active: String(localized: "Inactive")]) + } + @Test("Each column opens at the width the parts list has always given it") func defaultWidths() { #expect(ClickHousePartsView.columnWidths == [120, 200, 100, 100, 160, 60]) #expect(ClickHousePartsView.columnWidths.count == ClickHousePartsView.columnTitles.count) } + @Test("Each column keeps the minimum width the parts list has always given it") + func minimumWidths() { + #expect(ClickHousePartsView.columnMinimumWidths == [80, 100, 60, 60, 100, 50]) + #expect(ClickHousePartsView.columnMinimumWidths.count == ClickHousePartsView.columnTitles.count) + #expect(zip(ClickHousePartsView.columnMinimumWidths, ClickHousePartsView.columnWidths).allSatisfy { $0 <= $1 }) + } + @Test("Drop and Detach name the one selected part's partition, and none while several are selected") func selectedPartition() { let other = ClickHousePartInfo( diff --git a/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift b/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift new file mode 100644 index 0000000000..c61e99d3b8 --- /dev/null +++ b/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift @@ -0,0 +1,159 @@ +// +// StructureListGridAccessibilityTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class SpokenValueLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@Suite("Structure list cells speak what they mean", .serialized) +@MainActor +struct StructureListGridAccessibilityTests { + private struct Grid { + let window: NSWindow + let coordinator: TableViewCoordinator + let model: StructureListGridModel + } + + private func makeGrid(columns: [String], rows: [StructureListRow]) -> Grid { + let model = StructureListGridModel() + let tableRows = model.ensureLoaded(key: 1, columns: columns) { rows } + let coordinator = TableViewCoordinator( + changeManager: model.changeManager, + isEditable: false, + selectedRowIndices: .constant([]), + delegate: model, + layoutPersister: SpokenValueLayoutPersister() + ) + coordinator.tableRowsProvider = { tableRows } + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.updateCache() + + let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 700, height: 200)) + tableView.columnAutoresizingStyle = .noColumnAutoresizing + tableView.rowHeight = 21 + tableView.coordinator = coordinator + tableView.dataSource = coordinator + tableView.delegate = coordinator + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + coordinator.tableView = tableView + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: columns.count), + savedLayout: nil, + isEditable: false, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 100 } + ) + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 700, height: 200)) + scrollView.documentView = tableView + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = scrollView + model.dataGridAttach(tableViewCoordinator: coordinator) + tableView.reloadData() + tableView.layoutSubtreeIfNeeded() + window.layoutIfNeeded() + return Grid(window: window, coordinator: coordinator, model: model) + } + + private func cell(in grid: Grid, row: Int, dataColumn: Int) throws -> DataGridCellAccessibilityView { + let tableView = try #require(grid.coordinator.tableView) + let column = try #require(grid.coordinator.tableColumnIndex(for: dataColumn)) + return try #require( + tableView.view(atColumn: column, row: row, makeIfNecessary: true) as? DataGridCellAccessibilityView + ) + } + + private func spoken(in grid: Grid, row: Int, dataColumn: Int) throws -> String? { + try cell(in: grid, row: row, dataColumn: dataColumn).accessibilityValue() as? String + } + + private func drawn(in grid: Grid, row: Int, dataColumn: Int) -> String? { + grid.coordinator.cellAppearance(row: row, columnIndex: dataColumn, onEmphasizedSelection: false)?.text + } + + @Test("The Enabled column draws YES and NO and reads Enabled and Disabled") + func triggerEnabledIsSpoken() throws { + DataGridAccessibility.isActive = true + defer { DataGridAccessibility.isActive = false } + let triggers = [ + TriggerInfo(name: "trg_on", timing: "BEFORE", event: "UPDATE", statement: "", enabled: true), + TriggerInfo(name: "trg_off", timing: "AFTER", event: "INSERT", statement: "", enabled: false), + ] + let rows = triggers.map { trigger in + StructureListRow( + id: trigger.id, + values: TriggerListColumns.values(for: trigger, showsEnabled: true), + spokenValues: TriggerListColumns.spokenValues(for: trigger, showsEnabled: true) + ) + } + let grid = makeGrid(columns: TriggerListColumns.titles(showsEnabled: true), rows: rows) + let enabled = TriggerListColumns.enabledColumn + + #expect(drawn(in: grid, row: 0, dataColumn: enabled) == "YES") + #expect(drawn(in: grid, row: 1, dataColumn: enabled) == "NO") + #expect(try spoken(in: grid, row: 0, dataColumn: enabled) == String(localized: "Enabled")) + #expect(try spoken(in: grid, row: 1, dataColumn: enabled) == String(localized: "Disabled")) + #expect(try cell(in: grid, row: 1, dataColumn: enabled).accessibilityLabel() == String( + format: String(localized: "Row %d, column %d: %@"), 2, enabled + 1, String(localized: "Disabled") + )) + #expect(try spoken(in: grid, row: 0, dataColumn: TriggerListColumns.nameColumn) == "trg_on") + withExtendedLifetime(grid) {} + } + + @Test("The Active column draws YES and NO and reads Active and Inactive") + func partActiveIsSpoken() throws { + DataGridAccessibility.isActive = true + defer { DataGridAccessibility.isActive = false } + let parts = [true, false].map { active in + ClickHousePartInfo( + partition: "202601", name: "p", rows: 1, bytesOnDisk: 1, modificationTime: "", active: active + ) + } + let rows = parts.map { part in + let row = ClickHousePartsView.gridRow(for: part) + return StructureListRow(id: part.id.uuidString, values: row.values, spokenValues: row.spokenValues) + } + let grid = makeGrid(columns: ClickHousePartsView.columnTitles, rows: rows) + let active = ClickHousePartsView.activeColumn + + #expect(drawn(in: grid, row: 0, dataColumn: active) == "YES") + #expect(try spoken(in: grid, row: 0, dataColumn: active) == String(localized: "Active")) + #expect(try spoken(in: grid, row: 1, dataColumn: active) == String(localized: "Inactive")) + #expect(try spoken(in: grid, row: 0, dataColumn: 0) == "202601") + withExtendedLifetime(grid) {} + } + + @Test("A cell with no spoken value reads the text it draws") + func unspokenCellReadsItsText() throws { + DataGridAccessibility.isActive = true + defer { DataGridAccessibility.isActive = false } + let grid = makeGrid( + columns: ["Name", "Unique"], + rows: [StructureListRow(id: "idx", values: ["idx_name", "YES"])] + ) + + #expect(try spoken(in: grid, row: 0, dataColumn: 1) == "YES") + #expect(grid.model.dataGridAccessibilityText(row: 0, column: 1) == nil) + withExtendedLifetime(grid) {} + } +} diff --git a/TableProTests/Views/Structure/StructureListGridModelTests.swift b/TableProTests/Views/Structure/StructureListGridModelTests.swift index 4020b509f7..c3562b1095 100644 --- a/TableProTests/Views/Structure/StructureListGridModelTests.swift +++ b/TableProTests/Views/Structure/StructureListGridModelTests.swift @@ -185,6 +185,29 @@ struct StructureListGridModelTests { #expect(StructureListGrid.layout(stored, columns: columns, defaultWidths: []) == stored) } + @Test("Minimum widths follow the columns by index and name nothing past them") + func minimumWidthsFollowColumns() { + #expect(StructureListGrid.minimumWidths([140, 70], columns: columns) == ["Name": 140, "Timing": 70]) + #expect(StructureListGrid.minimumWidths([140], columns: columns) == ["Name": 140]) + #expect(StructureListGrid.minimumWidths([], columns: columns).isEmpty) + } + + @Test("A spoken value is answered for its display row and column, and nothing else is") + func answersSpokenValues() { + let model = StructureListGridModel() + _ = model.ensureLoaded(key: 1, columns: columns) { + [ + StructureListRow(id: "a", values: ["alpha", "YES"], spokenValues: [1: "Enabled"]), + StructureListRow(id: "b", values: ["beta", "NO"]), + ] + } + + #expect(model.dataGridAccessibilityText(row: 0, column: 1) == "Enabled") + #expect(model.dataGridAccessibilityText(row: 0, column: 0) == nil) + #expect(model.dataGridAccessibilityText(row: 1, column: 1) == nil) + #expect(model.dataGridAccessibilityText(row: 5, column: 1) == nil) + } + @Test("The grid is named for the list it shows") func namesTheGrid() { let tableView = NSTableView() diff --git a/TableProTests/Views/Structure/TriggerListGridTests.swift b/TableProTests/Views/Structure/TriggerListGridTests.swift index ad49dc1ea4..87c443146f 100644 --- a/TableProTests/Views/Structure/TriggerListGridTests.swift +++ b/TableProTests/Views/Structure/TriggerListGridTests.swift @@ -3,7 +3,9 @@ // TableProTests // +import AppKit import Foundation +import SwiftUI @testable import TablePro import Testing @@ -86,6 +88,48 @@ struct TriggerListGridTests { #expect(TriggerListColumns.widths(showsEnabled: false).count == TriggerListColumns.titles(showsEnabled: false).count) } + @Test("Enabled reads aloud as Enabled or Disabled, and says nothing where the engine does not") + func enabledIsSpokenByName() { + let enabled = TriggerListColumns.enabledColumn + #expect(TriggerListColumns.titles(showsEnabled: true)[enabled] == String(localized: "Enabled")) + #expect(TriggerListColumns.spokenValues(for: triggers[0], showsEnabled: true) == [enabled: String(localized: "Enabled")]) + #expect(TriggerListColumns.spokenValues(for: triggers[1], showsEnabled: true) == [enabled: String(localized: "Disabled")]) + #expect(TriggerListColumns.spokenValues(for: triggers[2], showsEnabled: true).isEmpty) + #expect(TriggerListColumns.spokenValues(for: triggers[0], showsEnabled: false).isEmpty) + } + + @Test("Each column keeps the minimum width the list has always given it") + func minimumWidths() { + #expect(TriggerListColumns.minimumWidths(showsEnabled: true) == [140, 70, 90, 60]) + #expect(TriggerListColumns.minimumWidths(showsEnabled: false) == [140, 70, 90]) + for showsEnabled in [true, false] { + let minimums = TriggerListColumns.minimumWidths(showsEnabled: showsEnabled) + #expect(minimums.count == TriggerListColumns.titles(showsEnabled: showsEnabled).count) + #expect(zip(minimums, TriggerListColumns.widths(showsEnabled: showsEnabled)).allSatisfy { $0 <= $1 }) + } + } + + private func tableView(in view: NSView?) -> NSTableView? { + guard let view else { return nil } + if let tableView = view as? KeyHandlingTableView { return tableView } + return view.subviews.lazy.compactMap { self.tableView(in: $0) }.first + } + + @Test("The list's columns carry their minimum widths into the grid") + func gridColumnsCarryTheirMinimums() throws { + let window = ThemeTestFixture.host( + TriggerListPane(triggers: triggers, databaseType: .mysql, state: TriggerInspectorState()), + size: NSSize(width: 700, height: 300) + ) + let tableView = try #require(tableView(in: window.contentView)) + let presented = tableView.tableColumns.filter { $0.identifier != ColumnIdentitySchema.rowNumberIdentifier && !$0.isHidden } + let minimums = Dictionary(uniqueKeysWithValues: presented.map { ($0.headerCell.stringValue, $0.minWidth) }) + + let titles = TriggerListColumns.titles(showsEnabled: true) + #expect(minimums == Dictionary(uniqueKeysWithValues: zip(titles, TriggerListColumns.minimumWidths(showsEnabled: true)))) + withExtendedLifetime(window) {} + } + @Test("A selected trigger keeps its row through a sort") func selectionFollowsTheTriggerThroughASort() { let state = TriggerInspectorState()