SwiftDBAI: natural language queries for any SQLite database
Drop-in SwiftUI chat view, headless ChatEngine, LLM-agnostic via AnyLanguageModel. Read-only by default with configurable allowlists. Robust SQL parser with 63 tests. Includes demo app with GitHub stars dataset.
This commit is contained in:
489
Tests/SwiftDBAITests/ViewInspectorTests.swift
Normal file
489
Tests/SwiftDBAITests/ViewInspectorTests.swift
Normal file
@@ -0,0 +1,489 @@
|
||||
// ViewInspectorTests.swift
|
||||
// SwiftDBAITests
|
||||
//
|
||||
// ViewInspector-based tests for SwiftDBAI's SwiftUI views.
|
||||
// Tests content and structure of MessageBubbleView, ErrorMessageView,
|
||||
// ScrollableDataTableView, ChatViewConfiguration, and BarChartView.
|
||||
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import ViewInspector
|
||||
@testable import SwiftDBAI
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Helper to build a DataTable for tests.
|
||||
private func makeDataTable(
|
||||
columnNames: [String] = ["id", "name", "score"],
|
||||
inferredTypes: [DataTable.InferredType] = [.integer, .text, .real],
|
||||
rowCount: Int = 3
|
||||
) -> DataTable {
|
||||
let columns = columnNames.enumerated().map { idx, name in
|
||||
DataTable.Column(name: name, index: idx, inferredType: inferredTypes[idx])
|
||||
}
|
||||
let rows = (0..<rowCount).map { i in
|
||||
DataTable.Row(
|
||||
id: i,
|
||||
values: [
|
||||
.integer(Int64(i + 1)),
|
||||
.text("Item \(i + 1)"),
|
||||
.real(Double(i) * 10.5),
|
||||
],
|
||||
columnNames: columnNames
|
||||
)
|
||||
}
|
||||
return DataTable(columns: columns, rows: rows, sql: "SELECT * FROM test", executionTime: 0.015)
|
||||
}
|
||||
|
||||
/// Helper to build a QueryResult for tests.
|
||||
private func makeQueryResult(
|
||||
columns: [String] = ["id", "name"],
|
||||
rowCount: Int = 2
|
||||
) -> QueryResult {
|
||||
let rows: [[String: QueryResult.Value]] = (0..<rowCount).map { i in
|
||||
["id": .integer(Int64(i + 1)), "name": .text("User \(i + 1)")]
|
||||
}
|
||||
return QueryResult(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
sql: "SELECT id, name FROM users",
|
||||
executionTime: 0.01
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - MessageBubbleView Tests
|
||||
|
||||
@Suite("MessageBubbleView - ViewInspector")
|
||||
struct MessageBubbleViewInspectorTests {
|
||||
|
||||
@Test("User message bubble renders the user text")
|
||||
@MainActor
|
||||
func userMessageShowsText() throws {
|
||||
let message = ChatMessage(role: .user, content: "Show me all users")
|
||||
let view = MessageBubbleView(message: message)
|
||||
let inspected = try view.inspect()
|
||||
let found = try inspected.find(text: "Show me all users")
|
||||
#expect(try found.string() == "Show me all users")
|
||||
}
|
||||
|
||||
@Test("Assistant message renders summary text")
|
||||
@MainActor
|
||||
func assistantMessageShowsSummary() throws {
|
||||
let message = ChatMessage(
|
||||
role: .assistant,
|
||||
content: "Found 42 users in the database."
|
||||
)
|
||||
let view = MessageBubbleView(message: message)
|
||||
let inspected = try view.inspect()
|
||||
let found = try inspected.find(text: "Found 42 users in the database.")
|
||||
#expect(try found.string() == "Found 42 users in the database.")
|
||||
}
|
||||
|
||||
@Test("Assistant message with SQL shows disclosure group")
|
||||
@MainActor
|
||||
func assistantMessageWithSQLShowsDisclosure() throws {
|
||||
let message = ChatMessage(
|
||||
role: .assistant,
|
||||
content: "Here are the results.",
|
||||
sql: "SELECT * FROM users"
|
||||
)
|
||||
let view = MessageBubbleView(message: message)
|
||||
let inspected = try view.inspect()
|
||||
// The SQL disclosure contains "SQL Query" label text
|
||||
let sqlLabel = try inspected.find(text: "SQL Query")
|
||||
#expect(try sqlLabel.string() == "SQL Query")
|
||||
}
|
||||
|
||||
@Test("Error message renders error text")
|
||||
@MainActor
|
||||
func errorMessageShowsText() throws {
|
||||
let error = SwiftDBAIError.databaseError(reason: "connection lost")
|
||||
let message = ChatMessage(
|
||||
role: .error,
|
||||
content: error.localizedDescription,
|
||||
error: error
|
||||
)
|
||||
let view = MessageBubbleView(message: message)
|
||||
let inspected = try view.inspect()
|
||||
// The error message text should be present
|
||||
let found = try inspected.find(text: error.localizedDescription)
|
||||
#expect(try found.string() == error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ErrorMessageView Tests
|
||||
|
||||
@Suite("ErrorMessageView - ViewInspector")
|
||||
struct ErrorMessageViewInspectorTests {
|
||||
|
||||
@Test("Safety error shows Operation Blocked title")
|
||||
@MainActor
|
||||
func safetyErrorShowsTitle() throws {
|
||||
let error = SwiftDBAIError.dangerousOperationBlocked(keyword: "DROP")
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Operation Blocked")
|
||||
#expect(try title.string() == "Operation Blocked")
|
||||
}
|
||||
|
||||
@Test("Safety error shows error message")
|
||||
@MainActor
|
||||
func safetyErrorShowsMessage() throws {
|
||||
let error = SwiftDBAIError.operationNotAllowed(operation: "DELETE")
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let msg = try inspected.find(text: error.localizedDescription)
|
||||
#expect(try msg.string() == error.localizedDescription)
|
||||
}
|
||||
|
||||
@Test("LLM response unparseable error shows recovery hint")
|
||||
@MainActor
|
||||
func parsingErrorShowsRecoveryHint() throws {
|
||||
let error = SwiftDBAIError.llmResponseUnparseable(response: "gibberish")
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let hint = try inspected.find(text: "Try rephrasing your question.")
|
||||
#expect(try hint.string() == "Try rephrasing your question.")
|
||||
}
|
||||
|
||||
@Test("Database error shows Database Error title")
|
||||
@MainActor
|
||||
func databaseErrorShowsTitle() throws {
|
||||
let error = SwiftDBAIError.databaseError(reason: "disk full")
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Database Error")
|
||||
#expect(try title.string() == "Database Error")
|
||||
}
|
||||
|
||||
@Test("LLM timeout shows AI Provider Error title and recovery hint")
|
||||
@MainActor
|
||||
func timeoutErrorShowsTitleAndHint() throws {
|
||||
let error = SwiftDBAIError.llmTimeout(seconds: 30)
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "AI Provider Error")
|
||||
#expect(try title.string() == "AI Provider Error")
|
||||
let hint = try inspected.find(text: "The AI took too long. Try a simpler question.")
|
||||
#expect(try hint.string() == "The AI took too long. Try a simpler question.")
|
||||
}
|
||||
|
||||
@Test("LLM failure error shows AI Provider Error title")
|
||||
@MainActor
|
||||
func llmFailureShowsTitle() throws {
|
||||
let error = SwiftDBAIError.llmFailure(reason: "rate limited")
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "AI Provider Error")
|
||||
#expect(try title.string() == "AI Provider Error")
|
||||
}
|
||||
|
||||
@Test("Generic error from plain string shows message text")
|
||||
@MainActor
|
||||
func genericStringErrorShowsMessage() throws {
|
||||
let view = ErrorMessageView(message: "Something went wrong")
|
||||
let inspected = try view.inspect()
|
||||
let msg = try inspected.find(text: "Something went wrong")
|
||||
#expect(try msg.string() == "Something went wrong")
|
||||
}
|
||||
|
||||
@Test("Recoverable error with retry shows retry button")
|
||||
@MainActor
|
||||
func recoverableErrorShowsRetryButton() throws {
|
||||
let error = SwiftDBAIError.noSQLGenerated
|
||||
let view = ErrorMessageView(error: error, onRetry: { })
|
||||
let inspected = try view.inspect()
|
||||
let button = try inspected.find(text: "Try Again")
|
||||
#expect(try button.string() == "Try Again")
|
||||
}
|
||||
|
||||
@Test("LLM error with retry shows Retry button")
|
||||
@MainActor
|
||||
func llmErrorShowsRetryButton() throws {
|
||||
let error = SwiftDBAIError.llmFailure(reason: "timeout")
|
||||
let view = ErrorMessageView(error: error, onRetry: { })
|
||||
let inspected = try view.inspect()
|
||||
let button = try inspected.find(text: "Retry")
|
||||
#expect(try button.string() == "Retry")
|
||||
}
|
||||
|
||||
@Test("Query timed out shows Database Error title and recovery hint")
|
||||
@MainActor
|
||||
func queryTimedOutShowsTitleAndHint() throws {
|
||||
let error = SwiftDBAIError.queryTimedOut(seconds: 10)
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Database Error")
|
||||
#expect(try title.string() == "Database Error")
|
||||
let hint = try inspected.find(text: "Try a simpler query or add database indexes.")
|
||||
#expect(try hint.string() == "Try a simpler query or add database indexes.")
|
||||
}
|
||||
|
||||
@Test("Empty schema error shows Database Error title and recovery hint")
|
||||
@MainActor
|
||||
func emptySchemaShowsTitleAndHint() throws {
|
||||
let error = SwiftDBAIError.emptySchema
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Database Error")
|
||||
#expect(try title.string() == "Database Error")
|
||||
let hint = try inspected.find(text: "Add some tables to your database first.")
|
||||
#expect(try hint.string() == "Add some tables to your database first.")
|
||||
}
|
||||
|
||||
@Test("Configuration error shows Configuration Error title")
|
||||
@MainActor
|
||||
func configurationErrorShowsTitle() throws {
|
||||
let error = SwiftDBAIError.configurationError(reason: "missing API key")
|
||||
let view = ErrorMessageView(error: error)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Configuration Error")
|
||||
#expect(try title.string() == "Configuration Error")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChatViewConfiguration Tests
|
||||
|
||||
@Suite("ChatViewConfiguration - ViewInspector")
|
||||
struct ChatViewConfigurationInspectorTests {
|
||||
|
||||
@Test("Dark configuration has expected color values")
|
||||
func darkConfigHasCorrectColors() {
|
||||
let dark = ChatViewConfiguration.dark
|
||||
#expect(dark.userTextColor == .white)
|
||||
#expect(dark.backgroundColor == .black)
|
||||
#expect(dark.accentColor == .blue)
|
||||
}
|
||||
|
||||
@Test("Default configuration has expected placeholder and empty state text")
|
||||
func defaultConfigHasExpectedText() {
|
||||
let config = ChatViewConfiguration.default
|
||||
#expect(config.inputPlaceholder == "Ask about your data\u{2026}")
|
||||
#expect(config.emptyStateTitle == "Ask a question about your data")
|
||||
#expect(config.emptyStateSubtitle == "Try something like \"How many records are in the database?\"")
|
||||
}
|
||||
|
||||
@Test("Custom inputPlaceholder propagates through environment")
|
||||
@MainActor
|
||||
func customPlaceholderInEnvironment() throws {
|
||||
var config = ChatViewConfiguration.default
|
||||
config.inputPlaceholder = "Ask about recipes..."
|
||||
config.emptyStateTitle = "Recipe Search"
|
||||
|
||||
// Verify the configuration values are set correctly
|
||||
#expect(config.inputPlaceholder == "Ask about recipes...")
|
||||
#expect(config.emptyStateTitle == "Recipe Search")
|
||||
}
|
||||
|
||||
@Test("Compact configuration has smaller padding and hidden SQL disclosure")
|
||||
func compactConfigProperties() {
|
||||
let compact = ChatViewConfiguration.compact
|
||||
#expect(compact.messagePadding == 8)
|
||||
#expect(compact.bubbleCornerRadius == 10)
|
||||
#expect(compact.showSQLDisclosure == false)
|
||||
#expect(compact.showTimestamps == false)
|
||||
}
|
||||
|
||||
@Test("Dark configuration userBubbleColor is dark gray")
|
||||
func darkConfigUserBubble() {
|
||||
let dark = ChatViewConfiguration.dark
|
||||
// Dark config uses Color(white: 0.25) for user bubble
|
||||
#expect(dark.userBubbleColor == Color(white: 0.25))
|
||||
#expect(dark.assistantBubbleColor == Color(white: 0.15))
|
||||
#expect(dark.inputBarBackgroundColor == Color(white: 0.1))
|
||||
}
|
||||
|
||||
@Test("ErrorMessageView uses environment config for database error color")
|
||||
@MainActor
|
||||
func errorViewUsesDarkConfig() throws {
|
||||
let error = SwiftDBAIError.databaseError(reason: "test error")
|
||||
let view = ErrorMessageView(error: error)
|
||||
.chatViewConfiguration(.dark)
|
||||
let inspected = try view.inspect()
|
||||
// Should still render the error message text
|
||||
let msg = try inspected.find(text: error.localizedDescription)
|
||||
#expect(try msg.string() == error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ScrollableDataTableView Tests
|
||||
|
||||
@Suite("ScrollableDataTableView - ViewInspector")
|
||||
struct ScrollableDataTableViewInspectorTests {
|
||||
|
||||
@Test("Column headers appear in the view")
|
||||
@MainActor
|
||||
func columnHeadersAppear() throws {
|
||||
let table = makeDataTable()
|
||||
let view = ScrollableDataTableView(dataTable: table)
|
||||
let inspected = try view.inspect()
|
||||
|
||||
// Each column header should be present
|
||||
let idHeader = try inspected.find(text: "id")
|
||||
#expect(try idHeader.string() == "id")
|
||||
|
||||
let nameHeader = try inspected.find(text: "name")
|
||||
#expect(try nameHeader.string() == "name")
|
||||
|
||||
let scoreHeader = try inspected.find(text: "score")
|
||||
#expect(try scoreHeader.string() == "score")
|
||||
}
|
||||
|
||||
@Test("Row count text appears in footer")
|
||||
@MainActor
|
||||
func rowCountFooterAppears() throws {
|
||||
let table = makeDataTable(rowCount: 5)
|
||||
let view = ScrollableDataTableView(dataTable: table, showFooter: true)
|
||||
let inspected = try view.inspect()
|
||||
|
||||
let footer = try inspected.find(text: "5 rows")
|
||||
#expect(try footer.string() == "5 rows")
|
||||
}
|
||||
|
||||
@Test("Single row shows singular 'row' text")
|
||||
@MainActor
|
||||
func singleRowFooter() throws {
|
||||
let table = makeDataTable(rowCount: 1)
|
||||
let view = ScrollableDataTableView(dataTable: table, showFooter: true)
|
||||
let inspected = try view.inspect()
|
||||
|
||||
let footer = try inspected.find(text: "1 row")
|
||||
#expect(try footer.string() == "1 row")
|
||||
}
|
||||
|
||||
@Test("Empty table shows No results text")
|
||||
@MainActor
|
||||
func emptyTableShowsNoResults() throws {
|
||||
let table = DataTable(columns: [], rows: [], sql: "", executionTime: 0)
|
||||
let view = ScrollableDataTableView(dataTable: table)
|
||||
let inspected = try view.inspect()
|
||||
|
||||
let empty = try inspected.find(text: "No results")
|
||||
#expect(try empty.string() == "No results")
|
||||
}
|
||||
|
||||
@Test("Execution time appears in footer when > 0")
|
||||
@MainActor
|
||||
func executionTimeAppearsInFooter() throws {
|
||||
let columns = [DataTable.Column(name: "val", index: 0, inferredType: .integer)]
|
||||
let rows = [DataTable.Row(id: 0, values: [.integer(1)], columnNames: ["val"])]
|
||||
let table = DataTable(columns: columns, rows: rows, sql: "SELECT 1", executionTime: 0.023)
|
||||
let view = ScrollableDataTableView(dataTable: table, showFooter: true)
|
||||
let inspected = try view.inspect()
|
||||
|
||||
let timing = try inspected.find(text: "23.0 ms")
|
||||
#expect(try timing.string() == "23.0 ms")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BarChartView Tests
|
||||
|
||||
@Suite("BarChartView - ViewInspector")
|
||||
struct BarChartViewInspectorTests {
|
||||
|
||||
@Test("BarChartView with title renders the title text")
|
||||
@MainActor
|
||||
func barChartShowsTitle() throws {
|
||||
let columns: [DataTable.Column] = [
|
||||
.init(name: "dept", index: 0, inferredType: .text),
|
||||
.init(name: "revenue", index: 1, inferredType: .real),
|
||||
]
|
||||
let rows: [DataTable.Row] = [
|
||||
.init(id: 0, values: [.text("Sales"), .real(100.0)], columnNames: ["dept", "revenue"]),
|
||||
.init(id: 1, values: [.text("Eng"), .real(200.0)], columnNames: ["dept", "revenue"]),
|
||||
]
|
||||
let table = DataTable(columns: columns, rows: rows)
|
||||
|
||||
let view = BarChartView(
|
||||
dataTable: table,
|
||||
categoryColumn: "dept",
|
||||
valueColumn: "revenue",
|
||||
title: "Revenue by Department"
|
||||
)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Revenue by Department")
|
||||
#expect(try title.string() == "Revenue by Department")
|
||||
}
|
||||
|
||||
@Test("BarChartView with empty data shows empty state")
|
||||
@MainActor
|
||||
func barChartEmptyState() throws {
|
||||
let table = DataTable(columns: [], rows: [])
|
||||
let view = BarChartView(
|
||||
dataTable: table,
|
||||
categoryColumn: "x",
|
||||
valueColumn: "y"
|
||||
)
|
||||
let inspected = try view.inspect()
|
||||
let empty = try inspected.find(text: "No chartable data")
|
||||
#expect(try empty.string() == "No chartable data")
|
||||
}
|
||||
|
||||
@Test("BarChartView with truncated data shows truncation notice")
|
||||
@MainActor
|
||||
func barChartTruncationNotice() throws {
|
||||
let columns: [DataTable.Column] = [
|
||||
.init(name: "cat", index: 0, inferredType: .text),
|
||||
.init(name: "val", index: 1, inferredType: .real),
|
||||
]
|
||||
// Create 10 rows but set maxBars to 3
|
||||
let rows: [DataTable.Row] = (0..<10).map { i in
|
||||
.init(id: i, values: [.text("Cat \(i)"), .real(Double(i) * 10)], columnNames: ["cat", "val"])
|
||||
}
|
||||
let table = DataTable(columns: columns, rows: rows)
|
||||
|
||||
let view = BarChartView(
|
||||
dataTable: table,
|
||||
categoryColumn: "cat",
|
||||
valueColumn: "val",
|
||||
maxBars: 3
|
||||
)
|
||||
let inspected = try view.inspect()
|
||||
let notice = try inspected.find(text: "Showing 3 of 10 categories")
|
||||
#expect(try notice.string() == "Showing 3 of 10 categories")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PieChartView Tests
|
||||
|
||||
@Suite("PieChartView - ViewInspector")
|
||||
struct PieChartViewInspectorTests {
|
||||
|
||||
@Test("PieChartView with title renders the title text")
|
||||
@MainActor
|
||||
func pieChartShowsTitle() throws {
|
||||
let columns: [DataTable.Column] = [
|
||||
.init(name: "status", index: 0, inferredType: .text),
|
||||
.init(name: "count", index: 1, inferredType: .integer),
|
||||
]
|
||||
let rows: [DataTable.Row] = [
|
||||
.init(id: 0, values: [.text("Active"), .integer(40)], columnNames: ["status", "count"]),
|
||||
.init(id: 1, values: [.text("Inactive"), .integer(10)], columnNames: ["status", "count"]),
|
||||
]
|
||||
let table = DataTable(columns: columns, rows: rows)
|
||||
|
||||
let view = PieChartView(
|
||||
dataTable: table,
|
||||
categoryColumn: "status",
|
||||
valueColumn: "count",
|
||||
title: "Users by Status"
|
||||
)
|
||||
let inspected = try view.inspect()
|
||||
let title = try inspected.find(text: "Users by Status")
|
||||
#expect(try title.string() == "Users by Status")
|
||||
}
|
||||
|
||||
@Test("PieChartView with empty data shows empty state")
|
||||
@MainActor
|
||||
func pieChartEmptyState() throws {
|
||||
let table = DataTable(columns: [], rows: [])
|
||||
let view = PieChartView(
|
||||
dataTable: table,
|
||||
categoryColumn: "x",
|
||||
valueColumn: "y"
|
||||
)
|
||||
let inspected = try view.inspect()
|
||||
let empty = try inspected.find(text: "No chartable data")
|
||||
#expect(try empty.string() == "No chartable data")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user