Chat with any SQLite database using natural language. Built on AnyLanguageModel (HuggingFace) for LLM-agnostic provider support and GRDB for SQLite access. Core features: - Auto schema introspection from sqlite_master (zero config) - NL → SQL generation via any AnyLanguageModel provider - Three rendering modes: text summary, data table, Swift Charts - Drop-in DataChatView (SwiftUI) and headless ChatEngine - Operation allowlist with read-only default - Mutation policy with per-table control - ToolExecutionDelegate for destructive operation confirmation - Multi-turn conversation context - 352 tests across 24 suites, all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
// TextSummaryRendererTests.swift
|
|
// SwiftDBAI
|
|
|
|
import AnyLanguageModel
|
|
import Testing
|
|
import Foundation
|
|
@testable import SwiftDBAI
|
|
|
|
@Suite("TextSummaryRenderer")
|
|
struct TextSummaryRendererTests {
|
|
|
|
// MARK: - QueryResult.Value Tests
|
|
|
|
@Test("Value description renders correctly")
|
|
func valueDescriptions() {
|
|
#expect(QueryResult.Value.text("hello").description == "hello")
|
|
#expect(QueryResult.Value.integer(42).description == "42")
|
|
#expect(QueryResult.Value.real(3.14).description == "3.14")
|
|
#expect(QueryResult.Value.null.description == "NULL")
|
|
#expect(QueryResult.Value.blob(Data([0x01, 0x02])).description == "<2 bytes>")
|
|
}
|
|
|
|
@Test("Value doubleValue extracts numeric values")
|
|
func valueDoubleValues() {
|
|
#expect(QueryResult.Value.integer(42).doubleValue == 42.0)
|
|
#expect(QueryResult.Value.real(3.14).doubleValue == 3.14)
|
|
#expect(QueryResult.Value.text("100").doubleValue == 100.0)
|
|
#expect(QueryResult.Value.text("not a number").doubleValue == nil)
|
|
#expect(QueryResult.Value.null.doubleValue == nil)
|
|
#expect(QueryResult.Value.blob(Data()).doubleValue == nil)
|
|
}
|
|
|
|
@Test("Value isNull works correctly")
|
|
func valueIsNull() {
|
|
#expect(QueryResult.Value.null.isNull == true)
|
|
#expect(QueryResult.Value.text("").isNull == false)
|
|
#expect(QueryResult.Value.integer(0).isNull == false)
|
|
}
|
|
|
|
// MARK: - QueryResult Tests
|
|
|
|
@Test("Empty result has correct properties")
|
|
func emptyResult() {
|
|
let result = QueryResult(
|
|
columns: ["id", "name"],
|
|
rows: [],
|
|
sql: "SELECT id, name FROM users",
|
|
executionTime: 0.01
|
|
)
|
|
#expect(result.rowCount == 0)
|
|
#expect(result.isAggregate == false)
|
|
#expect(result.tabularDescription == "(empty result set)")
|
|
}
|
|
|
|
@Test("Single aggregate result is detected")
|
|
func aggregateDetection() {
|
|
let result = QueryResult(
|
|
columns: ["COUNT(*)"],
|
|
rows: [["COUNT(*)": .integer(42)]],
|
|
sql: "SELECT COUNT(*) FROM users",
|
|
executionTime: 0.01
|
|
)
|
|
#expect(result.isAggregate == true)
|
|
}
|
|
|
|
@Test("Multi-row result is not aggregate")
|
|
func nonAggregateDetection() {
|
|
let result = QueryResult(
|
|
columns: ["name"],
|
|
rows: [
|
|
["name": .text("Alice")],
|
|
["name": .text("Bob")],
|
|
],
|
|
sql: "SELECT name FROM users",
|
|
executionTime: 0.01
|
|
)
|
|
#expect(result.isAggregate == false)
|
|
}
|
|
|
|
@Test("Tabular description formats correctly")
|
|
func tabularDescription() {
|
|
let result = QueryResult(
|
|
columns: ["id", "name"],
|
|
rows: [
|
|
["id": .integer(1), "name": .text("Alice")],
|
|
["id": .integer(2), "name": .text("Bob")],
|
|
],
|
|
sql: "SELECT id, name FROM users",
|
|
executionTime: 0.01
|
|
)
|
|
let desc = result.tabularDescription
|
|
#expect(desc.contains("id | name"))
|
|
#expect(desc.contains("1 | Alice"))
|
|
#expect(desc.contains("2 | Bob"))
|
|
}
|
|
|
|
@Test("values(forColumn:) extracts column values")
|
|
func valuesForColumn() {
|
|
let result = QueryResult(
|
|
columns: ["name"],
|
|
rows: [
|
|
["name": .text("Alice")],
|
|
["name": .text("Bob")],
|
|
],
|
|
sql: "SELECT name FROM users",
|
|
executionTime: 0.01
|
|
)
|
|
let values = result.values(forColumn: "name")
|
|
#expect(values.count == 2)
|
|
#expect(values[0] == .text("Alice"))
|
|
}
|
|
|
|
// MARK: - Local Summary Tests (no LLM required)
|
|
|
|
@Test("Local summary for empty result")
|
|
func localSummaryEmpty() {
|
|
let result = makeResult(columns: ["id"], rows: [])
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "Any users?")
|
|
#expect(summary == "No results found for your query.")
|
|
}
|
|
|
|
@Test("Local summary for single aggregate")
|
|
func localSummarySingleAggregate() {
|
|
let result = makeResult(
|
|
columns: ["COUNT(*)"],
|
|
rows: [["COUNT(*)": .integer(42)]]
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "How many?")
|
|
#expect(summary.contains("42"))
|
|
}
|
|
|
|
@Test("Local summary for multiple aggregates")
|
|
func localSummaryMultipleAggregates() {
|
|
let result = makeResult(
|
|
columns: ["COUNT(*)", "AVG(price)"],
|
|
rows: [["COUNT(*)": .integer(10), "AVG(price)": .real(25.5)]]
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "Stats?")
|
|
#expect(summary.contains("count"))
|
|
#expect(summary.contains("average price"))
|
|
}
|
|
|
|
@Test("Local summary for single record")
|
|
func localSummarySingleRecord() {
|
|
let result = makeResult(
|
|
columns: ["name", "email"],
|
|
rows: [["name": .text("Alice"), "email": .text("alice@example.com")]]
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "Who?")
|
|
#expect(summary.contains("1 result"))
|
|
#expect(summary.contains("Alice"))
|
|
}
|
|
|
|
@Test("Local summary for multiple records with name column")
|
|
func localSummaryMultipleWithNames() {
|
|
let result = makeResult(
|
|
columns: ["name", "age"],
|
|
rows: [
|
|
["name": .text("Alice"), "age": .integer(30)],
|
|
["name": .text("Bob"), "age": .integer(25)],
|
|
["name": .text("Charlie"), "age": .integer(35)],
|
|
["name": .text("Diana"), "age": .integer(28)],
|
|
]
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "List users")
|
|
#expect(summary.contains("4 results"))
|
|
#expect(summary.contains("Alice"))
|
|
#expect(summary.contains("1 more"))
|
|
}
|
|
|
|
@Test("Local summary for mutation result")
|
|
func localSummaryMutation() {
|
|
let result = QueryResult(
|
|
columns: [],
|
|
rows: [],
|
|
sql: "INSERT INTO users (name) VALUES ('Test')",
|
|
executionTime: 0.01,
|
|
rowsAffected: 1
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "Add user")
|
|
#expect(summary == "Successfully inserted 1 row.")
|
|
}
|
|
|
|
@Test("Local summary for delete mutation")
|
|
func localSummaryDelete() {
|
|
let result = QueryResult(
|
|
columns: [],
|
|
rows: [],
|
|
sql: "DELETE FROM users WHERE id = 5",
|
|
executionTime: 0.01,
|
|
rowsAffected: 3
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "Delete old users")
|
|
#expect(summary == "Successfully deleted 3 rows.")
|
|
}
|
|
|
|
@Test("Local summary for update mutation")
|
|
func localSummaryUpdate() {
|
|
let result = QueryResult(
|
|
columns: [],
|
|
rows: [],
|
|
sql: "UPDATE users SET active = 0 WHERE id = 1",
|
|
executionTime: 0.01,
|
|
rowsAffected: 1
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = renderer.localSummary(result: result, userQuestion: "Deactivate user")
|
|
#expect(summary == "Successfully updated 1 row.")
|
|
}
|
|
|
|
// MARK: - LLM-based Summary Tests (using MockLanguageModel)
|
|
|
|
@Test("Summarize with LLM returns mock response for multi-row results")
|
|
func summarizeWithLLM() async throws {
|
|
let result = makeResult(
|
|
columns: ["name", "age"],
|
|
rows: [
|
|
["name": .text("Alice"), "age": .integer(30)],
|
|
["name": .text("Bob"), "age": .integer(25)],
|
|
]
|
|
)
|
|
let mockModel = MockLanguageModel(responseText: "There are 2 users: Alice (30) and Bob (25).")
|
|
let renderer = TextSummaryRenderer(model: mockModel)
|
|
let summary = try await renderer.summarize(result: result, userQuestion: "List all users")
|
|
#expect(summary == "There are 2 users: Alice (30) and Bob (25).")
|
|
}
|
|
|
|
@Test("Summarize returns empty result message without calling LLM")
|
|
func summarizeEmptyResult() async throws {
|
|
let result = makeResult(columns: ["id"], rows: [])
|
|
let renderer = makeMockRenderer()
|
|
let summary = try await renderer.summarize(result: result, userQuestion: "Find users")
|
|
#expect(summary == "No results found for your query.")
|
|
}
|
|
|
|
@Test("Summarize returns direct aggregate without calling LLM")
|
|
func summarizeAggregate() async throws {
|
|
let result = makeResult(
|
|
columns: ["COUNT(*)"],
|
|
rows: [["COUNT(*)": .integer(42)]]
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = try await renderer.summarize(result: result, userQuestion: "How many?")
|
|
#expect(summary.contains("42"))
|
|
}
|
|
|
|
@Test("Summarize mutation returns template without calling LLM")
|
|
func summarizeMutation() async throws {
|
|
let result = QueryResult(
|
|
columns: [],
|
|
rows: [],
|
|
sql: "UPDATE users SET name = 'Test' WHERE id = 1",
|
|
executionTime: 0.01,
|
|
rowsAffected: 1
|
|
)
|
|
let renderer = makeMockRenderer()
|
|
let summary = try await renderer.summarize(result: result, userQuestion: "Update user")
|
|
#expect(summary == "Successfully updated 1 row.")
|
|
}
|
|
|
|
@Test("Summarize passes context to LLM prompt")
|
|
func summarizeWithContext() async throws {
|
|
let result = makeResult(
|
|
columns: ["total"],
|
|
rows: [
|
|
["total": .real(100.0)],
|
|
["total": .real(200.0)],
|
|
]
|
|
)
|
|
let mockModel = MockLanguageModel(responseText: "The totals are 100 and 200.")
|
|
let renderer = TextSummaryRenderer(model: mockModel)
|
|
let summary = try await renderer.summarize(
|
|
result: result,
|
|
userQuestion: "Show totals",
|
|
context: "Amounts are in USD"
|
|
)
|
|
#expect(summary == "The totals are 100 and 200.")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makeResult(
|
|
columns: [String],
|
|
rows: [[String: QueryResult.Value]],
|
|
sql: String = "SELECT * FROM test"
|
|
) -> QueryResult {
|
|
QueryResult(columns: columns, rows: rows, sql: sql, executionTime: 0.01)
|
|
}
|
|
|
|
/// Creates a renderer with a mock model (for localSummary tests that don't hit the LLM).
|
|
private func makeMockRenderer() -> TextSummaryRenderer {
|
|
TextSummaryRenderer(model: MockLanguageModel())
|
|
}
|
|
}
|